添加登录页面

This commit is contained in:
zk
2026-07-10 14:18:48 +08:00
parent 4585c824b7
commit 45b2c3073f
10 changed files with 470 additions and 3 deletions
@@ -0,0 +1,18 @@
package org.jiayunet.admin.constant;
/**
* B 端管理后台 Redis Key 常量
*
* @author zk
*/
public interface AdminRedisKeyName {
/** 管理员登录 token 前缀,完整 key: admin:login:token:{adminId} */
String LOGIN_TOKEN = "login:token:";
/** 登录失败次数计数,完整 key: admin:login:fail:{username} */
String LOGIN_FAIL_COUNT = "login:fail:";
/** 账号锁定标记,完整 key: admin:login:lock:{username} */
String LOGIN_LOCK = "login:lock:";
}
@@ -0,0 +1,29 @@
package org.jiayunet.admin.controller;
import lombok.AllArgsConstructor;
import org.jiayunet.admin.pojo.param.login.AdminLoginParam;
import org.jiayunet.admin.service.AdminLoginService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 管理员登录控制类
*
* @author zk
*/
@RestController
@RequestMapping("/public")
@AllArgsConstructor
@Validated
public class AdminLoginController {
private AdminLoginService adminLoginService;
@PostMapping("/login")
public Long login(@Validated @RequestBody AdminLoginParam param, HttpServletRequest request, HttpServletResponse response) {
return adminLoginService.login(param.getUsername(), param.getPassword(), request, response);
}
}
@@ -0,0 +1,23 @@
package org.jiayunet.admin.pojo.param.login;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 管理员登录入参
*
* @author zk
*/
@Data
public class AdminLoginParam {
@NotBlank(message = "账号不能为空")
@Size(max = 32, message = "账号长度不能超过32位")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 32, message = "密码长度6-32位")
private String password;
}
@@ -0,0 +1,158 @@
package org.jiayunet.admin.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jiayunet.admin.constant.AdminRedisKeyName;
import org.jiayunet.exception.BusinessException;
import org.jiayunet.exception.BusinessExpCodeEnum;
import org.jiayunet.mapper.AdminUserMapper;
import org.jiayunet.pojo.login.RedisLoginTokenInfo;
import org.jiayunet.pojo.po.AdminUser;
import org.jiayunet.tool.HttpIpTool;
import org.jiayunet.tool.server.RedisServerTool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 管理员登录服务
* <p>依赖:AdminUserMapper(管理员查询)、RedisServerTooltoken管理与登录失败计数)</p>
* <p>使用表:bg_admin_user(查询管理员账号)</p>
* <p>使用Redislogin:token:{adminId}(登录令牌)、login:fail:{username}(失败计数)、login:lock:{username}(账号锁定)</p>
*
* @author zk
*/
@Service
@Slf4j
public class AdminLoginService {
/** 密码错误允许最大次数 */
private static final int MAX_FAIL_COUNT = 3;
/** 账号锁定时长(小时) */
private static final int LOCK_HOURS = 24;
@Value("${app.secret.token:youweiqingnian123}")
private String secret;
@Value("${app.login.token.exceed_time:43200}")
private int tokenExceedTime;
@Value("${app.login.device_online_quantity:5}")
private int deviceOnlineQuantity;
@Autowired
private AdminUserMapper adminUserMapper;
@Autowired
private RedisServerTool redisServerTool;
/**
* 账号密码登录
* <p>1. 校验账号锁定状态 2. 查询管理员 3. 校验密码(失败累计,3次锁定24小时) 4. 生成JWT 5. 写入Redis管理多设备 6. 设置Cookie</p>
*/
public Long login(String username, String password, HttpServletRequest request, HttpServletResponse response) {
Assert.hasText(username, "账号不能为空");
Assert.hasText(password, "密码不能为空");
// 检查账号是否被锁定
Assert.isTrue(!redisServerTool.hasKey(AdminRedisKeyName.LOGIN_LOCK + username), "账号已被锁定,请24小时后再试");
// 查询管理员
AdminUser admin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUser>().eq(AdminUser::getUsername, username).eq(AdminUser::getStatus, 0));
Assert.notNull(admin, "账号或密码错误");
// 校验密码:MD5(明文 + salt)
if (!md5(password + secret).equals(admin.getPassword())) {
handleLoginFail(username);
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "账号或密码错误");
}
// 登录成功,清除失败计数
clearLoginFail(username);
// 生成JWT
String uuId = UUID.randomUUID().toString().replace("-", "");
String token = JWT.create().withClaim("userId", admin.getId()).withClaim("uuId", uuId).sign(Algorithm.HMAC256(secret));
// 构建Redis登录信息
String redisKey = AdminRedisKeyName.LOGIN_TOKEN + admin.getId();
RedisLoginTokenInfo info = redisServerTool.get(redisKey, RedisLoginTokenInfo.class);
if (info == null) {
info = new RedisLoginTokenInfo();
info.setUserId(admin.getId());
info.setAuthority(new ArrayList<>());
info.setRole(new ArrayList<>());
}
// 过滤过期设备
List<RedisLoginTokenInfo.LoginDevice> devices = info.getLoginDevices() == null ? new ArrayList<>() : info.getLoginDevices();
long expireMillis = System.currentTimeMillis() - tokenExceedTime * 1000L;
devices = devices.stream().filter(d -> d.getLastLoginTime().isAfter(Instant.ofEpochMilli(expireMillis))).collect(Collectors.toList());
// 超过设备上限,移除最早的
while (devices.size() >= deviceOnlineQuantity) {
devices.stream().min(Comparator.comparing(RedisLoginTokenInfo.LoginDevice::getLastLoginTime)).ifPresent(devices::remove);
}
// 添加当前设备
RedisLoginTokenInfo.LoginDevice device = new RedisLoginTokenInfo.LoginDevice();
device.setUuId(uuId);
device.setLastLoginTime(Instant.now());
device.setLoginIp(HttpIpTool.gteRealIP(request));
devices.add(device);
info.setLoginDevices(devices);
redisServerTool.set(redisKey, info, tokenExceedTime, TimeUnit.SECONDS);
// 设置Cookie
Cookie cookie = new Cookie("Token", token);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(tokenExceedTime);
response.addCookie(cookie);
return admin.getId();
}
/** 处理登录失败:累计失败次数,达到上限则锁定账号24小时 */
private void handleLoginFail(String username) {
String failKey = AdminRedisKeyName.LOGIN_FAIL_COUNT + username;
Integer failCount = redisServerTool.get(failKey, Integer.class);
int currentCount = (failCount == null ? 0 : failCount) + 1;
if (currentCount >= MAX_FAIL_COUNT) {
redisServerTool.set(AdminRedisKeyName.LOGIN_LOCK + username, true, LOCK_HOURS, TimeUnit.HOURS);
redisServerTool.delete(failKey);
log.warn("管理员账号 [{}] 密码错误{}次,已锁定{}小时", username, MAX_FAIL_COUNT, LOCK_HOURS);
} else {
redisServerTool.set(failKey, currentCount, LOCK_HOURS, TimeUnit.HOURS);
}
}
/** 登录成功后清除失败计数 */
private void clearLoginFail(String username) {
redisServerTool.delete(AdminRedisKeyName.LOGIN_FAIL_COUNT + username);
}
/** MD5 加密 */
private String md5(String input) {
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) { sb.append(String.format("%02x", b)); }
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,9 @@
/**
* 认证相关 API
*/
const authApi = {
/** 登录 */
login(username, password) {
return request.post('/public/login', { username, password });
}
};
@@ -0,0 +1,54 @@
/**
* 统一请求封装
*/
const request = (() => {
const BASE_URL = '/admin';
/** 从 cookie 中获取 Token */
function getToken() {
const match = document.cookie.match(/(?:^|;\s*)Token=([^;]*)/);
return match ? match[1] : '';
}
/** 统一请求方法 */
async function http(url, options = {}) {
const config = {
headers: { 'Content-Type': 'application/json', 'Token': getToken(), ...options.headers },
...options
};
const response = await fetch(BASE_URL + url, config);
// 401 未登录,跳转登录页
if (response.status === 401) {
window.location.href = BASE_URL + '/page/login';
return Promise.reject('未登录');
}
const result = await response.json();
// 业务异常
if (result.status !== 200) {
const msg = result.message || '请求失败';
alert(msg);
return Promise.reject(msg);
}
return result.data;
}
return {
get(url, params) {
const query = params ? '?' + new URLSearchParams(params).toString() : '';
return http(url + query, { method: 'GET' });
},
post(url, data) {
return http(url, { method: 'POST', body: JSON.stringify(data) });
},
put(url, data) {
return http(url, { method: 'PUT', body: JSON.stringify(data) });
},
del(url, data) {
return http(url, { method: 'DELETE', body: data ? JSON.stringify(data) : undefined });
}
};
})();
+114 -3
View File
@@ -3,10 +3,121 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>OfferPie 管理后台 - 登录</title>
<title>OfferPie 管理后台</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"/>
<style>
body { margin: 0; padding: 0; }
#app {
min-height: 100vh;
display: flex;
background: #f5f7fa;
}
.left-panel {
flex: 1;
background: linear-gradient(160deg, #1d1e3c 0%, #2b2d5e 100%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 80px;
position: relative;
overflow: hidden;
}
.left-panel::before {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: rgba(64, 158, 255, 0.06);
top: 10%;
right: -100px;
}
.left-panel .logo { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
.left-panel .logo img { height: 44px; }
.left-panel .logo span { font-size: 26px; font-weight: 600; color: #fff; }
.left-panel .desc { font-size: 15px; color: rgba(255,255,255,0.5); line-height: 1.8; }
.left-panel .footer { position: absolute; bottom: 30px; left: 80px; font-size: 12px; color: rgba(255,255,255,0.2); }
.right-panel {
width: 480px;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-box { width: 100%; max-width: 340px; }
.login-box h2 { font-size: 22px; color: #303133; margin-bottom: 6px; }
.login-box .sub { font-size: 13px; color: #909399; margin-bottom: 32px; }
.login-box .el-button { width: 100%; margin-top: 8px; }
@media (max-width: 900px) {
.left-panel { display: none; }
.right-panel { width: 100%; }
}
</style>
</head>
<body>
<h1>OfferPie 管理后台</h1>
<p>登录页面占位 - 待实现</p>
<div id="app">
<div class="left-panel">
<div class="logo">
<img src="/admin/img/logo.png" alt="logo"/>
<span>OfferPie</span>
</div>
<div class="desc">运营管理后台</div>
<div class="footer">© 2025 OfferPie</div>
</div>
<div class="right-panel">
<div class="login-box">
<h2>欢迎回来</h2>
<p class="sub">请登录管理员账号</p>
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="账号" prefix-icon="User" size="large" maxlength="32"/>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" maxlength="32" show-password/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script src="/admin/js/request.js"></script>
<script src="/admin/js/api/auth.js"></script>
<script>
const { createApp, ref, reactive } = Vue;
const app = createApp({
setup() {
const formRef = ref(null);
const loading = ref(false);
const form = reactive({ username: '', password: '' });
const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度6-32位', trigger: 'blur' }]
};
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
await authApi.login(form.username, form.password);
window.location.href = '/admin/page/index';
} catch (e) { }
finally { loading.value = false; }
};
return { formRef, form, rules, loading, handleLogin };
}
});
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
@@ -0,0 +1,11 @@
package org.jiayunet.mapper;
import org.jiayunet.pojo.po.AdminUser;
/**
* 管理员用户 Mapper
*
* @author zk
*/
public interface AdminUserMapper extends CommonMapper<AdminUser> {
}
@@ -0,0 +1,54 @@
package org.jiayunet.pojo.po;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
/**
* 管理员用户表(bg_admin_user
*
* @author zk
*/
@Data
@TableName("bg_admin_user")
public class AdminUser {
/** 主键 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 登录账号 */
private String username;
/** 密码(BCrypt */
private String password;
/** 真实姓名 */
private String realName;
/** 昵称 */
private String nickName;
/** 手机号码 */
private String mobileNumber;
/** 头像URL */
private String avatar;
/** 状态 0=正常 1=禁用 */
private Integer status;
/** 创建时间 */
private Instant createTime;
/** 更新时间 */
private Instant updateTime;
/** 逻辑删除 0=正常 */
@TableLogic(value = "0", delval = "UNIX_TIMESTAMP()")
private Long isDelete;
}