diff --git a/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java b/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java new file mode 100644 index 0000000..898eaff --- /dev/null +++ b/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java @@ -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:"; +} diff --git a/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java b/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java new file mode 100644 index 0000000..22ba64e --- /dev/null +++ b/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java @@ -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); + } +} diff --git a/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java b/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java new file mode 100644 index 0000000..d0dc47f --- /dev/null +++ b/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java @@ -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; +} diff --git a/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java b/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java new file mode 100644 index 0000000..f047c38 --- /dev/null +++ b/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java @@ -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; + +/** + * 管理员登录服务 + *

依赖:AdminUserMapper(管理员查询)、RedisServerTool(token管理与登录失败计数)

+ *

使用表:bg_admin_user(查询管理员账号)

+ *

使用Redis:login:token:{adminId}(登录令牌)、login:fail:{username}(失败计数)、login:lock:{username}(账号锁定)

+ * + * @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; + + /** + * 账号密码登录 + *

1. 校验账号锁定状态 2. 查询管理员 3. 校验密码(失败累计,3次锁定24小时) 4. 生成JWT 5. 写入Redis管理多设备 6. 设置Cookie

+ */ + 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().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 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); + } + } +} diff --git a/admin/src/main/resources/static/img/logo.png b/admin/src/main/resources/static/img/logo.png new file mode 100644 index 0000000..772ed7f Binary files /dev/null and b/admin/src/main/resources/static/img/logo.png differ diff --git a/admin/src/main/resources/static/js/api/auth.js b/admin/src/main/resources/static/js/api/auth.js new file mode 100644 index 0000000..ebcbd86 --- /dev/null +++ b/admin/src/main/resources/static/js/api/auth.js @@ -0,0 +1,9 @@ +/** + * 认证相关 API + */ +const authApi = { + /** 登录 */ + login(username, password) { + return request.post('/public/login', { username, password }); + } +}; diff --git a/admin/src/main/resources/static/js/request.js b/admin/src/main/resources/static/js/request.js new file mode 100644 index 0000000..5be5d4c --- /dev/null +++ b/admin/src/main/resources/static/js/request.js @@ -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 }); + } + }; +})(); diff --git a/admin/src/main/resources/templates/login.html b/admin/src/main/resources/templates/login.html index d58ffa5..7729ed5 100644 --- a/admin/src/main/resources/templates/login.html +++ b/admin/src/main/resources/templates/login.html @@ -3,10 +3,121 @@ - OfferPie 管理后台 - 登录 + OfferPie 管理后台 + + -

OfferPie 管理后台

-

登录页面占位 - 待实现

+
+
+ +
运营管理后台
+ +
+
+ +
+
+ + + + + + + diff --git a/manager/src/main/java/org/jiayunet/mapper/AdminUserMapper.java b/manager/src/main/java/org/jiayunet/mapper/AdminUserMapper.java new file mode 100644 index 0000000..3285362 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/mapper/AdminUserMapper.java @@ -0,0 +1,11 @@ +package org.jiayunet.mapper; + +import org.jiayunet.pojo.po.AdminUser; + +/** + * 管理员用户 Mapper + * + * @author zk + */ +public interface AdminUserMapper extends CommonMapper { +} diff --git a/manager/src/main/java/org/jiayunet/pojo/po/AdminUser.java b/manager/src/main/java/org/jiayunet/pojo/po/AdminUser.java new file mode 100644 index 0000000..2610b05 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/pojo/po/AdminUser.java @@ -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; +}