初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
package com.mokee.auth;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 认证用户服务 — 启动类
* <p>
* 负责用户登录/登出、Token 管理、用户信息查询、菜单路由、系统切换等
*/
@SpringBootApplication(scanBasePackages = {"com.mokee.auth", "com.mokee.common"})
@MapperScan("com.mokee.auth.mapper")
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
System.out.println("========================================");
System.out.println(" 认证用户服务启动成功! 端口: 9001");
System.out.println(" Knife4j 文档: http://localhost:9001/doc.html");
System.out.println("========================================");
}
}

View File

@@ -0,0 +1,129 @@
package com.mokee.auth.controller;
import com.mokee.auth.service.AuthService;
import com.mokee.common.Result;
import com.mokee.common.dto.request.LoginRequest;
import com.mokee.common.dto.response.LoginResponse;
import com.mokee.common.dto.response.MenuRouterResponse;
import com.mokee.common.dto.response.MenuRouterVO;
import com.mokee.common.dto.response.SystemVO;
import com.mokee.common.dto.response.UserInfoVO;
import com.mokee.common.utils.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 认证控制器
* <p>
* 提供登录/登出/Token校验/用户信息/菜单路由/系统切换/密码修改等接口
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 用户登录
* <p>
* 校验用户名密码,成功后返回 JWT Token 及用户信息
*/
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest req, HttpServletRequest request) {
String clientIp = IpUtil.getClientIp(request);
String userAgent = request.getHeader("User-Agent");
log.info("用户登录请求 - username: {}, ip: {}", req.getUsername(), clientIp);
LoginResponse response = authService.login(req, clientIp, userAgent);
return Result.ok(response);
}
/**
* 用户登出
* <p>
* 清除 Redis 中的登录会话,使 Token 失效
*/
@PostMapping("/logout")
public Result<?> logout(@RequestHeader("Authorization") String authHeader) {
String token = extractToken(authHeader);
log.info("用户登出请求");
authService.logout(token);
return Result.ok();
}
/**
* 获取当前登录用户信息
*/
@GetMapping("/user/info")
public Result<UserInfoVO> getUserInfo(@RequestHeader("Authorization") String authHeader) {
String token = extractToken(authHeader);
UserInfoVO userInfo = authService.getUserInfo(token);
return Result.ok(userInfo);
}
/**
* 获取当前系统的菜单路由树和权限列表
* <p>
* 根据用户角色权限,返回前端动态路由所需的树形菜单数据 + 该系统所有权限标识
*/
@GetMapping("/menu/router")
public Result<MenuRouterResponse> getMenuRouter(@RequestHeader("Authorization") String authHeader,
@RequestParam String systemCode) {
String token = extractToken(authHeader);
MenuRouterResponse menuRouter = authService.getMenuRouter(token, systemCode);
return Result.ok(menuRouter);
}
/**
* 获取当前用户可切换的业务系统列表
*/
@GetMapping("/system/list")
public Result<List<SystemVO>> getSystemList(@RequestHeader("Authorization") String authHeader) {
String token = extractToken(authHeader);
List<SystemVO> systemList = authService.getSystemList(token);
return Result.ok(systemList);
}
/**
* 切换当前业务系统
* <p>
* 切换后重新计算角色和权限,并更新 Redis 会话
*/
@PostMapping("/system/select")
public Result<?> selectSystem(@RequestHeader("Authorization") String authHeader,
@RequestParam String systemId) {
String token = extractToken(authHeader);
authService.selectSystem(token, systemId);
return Result.ok();
}
/**
* 修改密码
* <p>
* 需验证旧密码,新密码 BCrypt 加密后存储
*/
@PutMapping("/password/update")
public Result<?> updatePassword(@RequestHeader("Authorization") String authHeader,
@RequestParam String oldPwd,
@RequestParam String newPwd) {
String token = extractToken(authHeader);
authService.updatePassword(token, oldPwd, newPwd);
return Result.ok("密码修改成功");
}
/**
* 从 Authorization 头中提取 Bearer Token
*/
private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}
}

View File

@@ -0,0 +1,12 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysLoginLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 登录日志 Mapper
*/
@Mapper
public interface SysLoginLogMapper extends BaseMapper<SysLoginLog> {
}

View File

@@ -0,0 +1,50 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysMenu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 菜单 Mapper
*/
@Mapper
public interface SysMenuMapper extends BaseMapper<SysMenu> {
/**
* 查询多个角色拥有的菜单(去重)
*
* @param roleIds 角色ID列表
* @param systemId 系统ID
* @return 去重后的菜单列表
*/
@Select("<script>" +
"SELECT DISTINCT m.* FROM sys_menu m " +
"INNER JOIN sys_role_menu rm ON m.id = rm.menu_id " +
"WHERE rm.role_id IN " +
"<foreach collection='roleIds' item='roleId' open='(' separator=',' close=')'>#{roleId}</foreach> " +
"AND m.system_id = #{systemId} " +
"AND m.status = 1 " +
"ORDER BY m.sort_order ASC" +
"</script>")
List<SysMenu> selectDistinctByRoleIds(@Param("roleIds") List<String> roleIds, @Param("systemId") String systemId);
/**
* 查询多个角色的权限标识列表
*
* @param roleIds 角色ID列表
* @return 权限标识列表(已去重,过滤空值)
*/
@Select("<script>" +
"SELECT DISTINCT m.permission FROM sys_menu m " +
"INNER JOIN sys_role_menu rm ON m.id = rm.menu_id " +
"WHERE rm.role_id IN " +
"<foreach collection='roleIds' item='roleId' open='(' separator=',' close=')'>#{roleId}</foreach> " +
"AND m.permission IS NOT NULL AND m.permission != '' " +
"AND m.status = 1" +
"</script>")
List<String> selectPermissionsByRoleIds(@Param("roleIds") List<String> roleIds);
}

View File

@@ -0,0 +1,29 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysRole;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 角色 Mapper
*/
@Mapper
public interface SysRoleMapper extends BaseMapper<SysRole> {
/**
* 查询用户在指定系统中的角色列表
*
* @param userId 用户ID
* @param systemId 系统ID
* @return 角色列表
*/
@Select("SELECT r.* FROM sys_role r " +
"INNER JOIN sys_user_role ur ON r.id = ur.role_id " +
"WHERE ur.user_id = #{userId} AND r.system_id = #{systemId} " +
"AND r.status = 1 AND r.is_deleted = 0")
List<SysRole> selectListByUserIdAndSystemId(@Param("userId") String userId, @Param("systemId") String systemId);
}

View File

@@ -0,0 +1,29 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysSystem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 系统信息 Mapper
*/
@Mapper
public interface SysSystemMapper extends BaseMapper<SysSystem> {
/**
* 查询用户绑定的启用系统列表
*
* @param userId 用户ID
* @return 系统列表
*/
@Select("SELECT s.* FROM sys_system s " +
"INNER JOIN sys_user_system us ON s.id = us.system_id " +
"WHERE us.user_id = #{userId} " +
"AND s.status = 1 AND s.is_deleted = 0 " +
"ORDER BY s.sort_order ASC")
List<SysSystem> selectListByUserId(@Param("userId") String userId);
}

View File

@@ -0,0 +1,12 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 Mapper
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUserRole;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户角色关联 Mapper
*/
@Mapper
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUserSystem;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户系统关联 Mapper
*/
@Mapper
public interface SysUserSystemMapper extends BaseMapper<SysUserSystem> {
}

View File

@@ -0,0 +1,83 @@
package com.mokee.auth.service;
import com.mokee.common.dto.request.LoginRequest;
import com.mokee.common.dto.response.LoginResponse;
import com.mokee.common.dto.response.MenuRouterResponse;
import com.mokee.common.dto.response.MenuRouterVO;
import com.mokee.common.dto.response.SystemVO;
import com.mokee.common.dto.response.UserInfoVO;
import java.util.List;
/**
* 认证服务接口
* <p>
* 定义登录、登出、用户信息、菜单路由、系统切换等核心认证方法
*/
public interface AuthService {
/**
* 用户登录
*
* @param req 登录请求(用户名、密码、可选系统编码)
* @param ip 客户端 IP
* @param userAgent 浏览器 User-Agent
* @return 登录响应Token + 用户信息 + 系统列表)
*/
LoginResponse login(LoginRequest req, String ip, String userAgent);
/**
* 用户登出
* <p>
* 清除 Redis 中的登录会话
*
* @param token JWT Token
*/
void logout(String token);
/**
* 获取当前登录用户信息
*
* @param token JWT Token
* @return 用户信息 VO
*/
UserInfoVO getUserInfo(String token);
/**
* 获取指定系统的菜单路由树和权限列表
*
* @param token JWT Token
* @param systemCode 系统编码
* @return 菜单路由响应(含菜单树 + 权限列表)
*/
MenuRouterResponse getMenuRouter(String token, String systemCode);
/**
* 获取当前用户可切换的业务系统列表
*
* @param token JWT Token
* @return 系统列表
*/
List<SystemVO> getSystemList(String token);
/**
* 切换当前业务系统
* <p>
* 切换后更新 Redis 会话中的 currentSystem 信息
*
* @param token JWT Token
* @param systemId 目标系统 ID
*/
void selectSystem(String token, String systemId);
/**
* 修改密码
* <p>
* 验证旧密码后使用 BCrypt 加密新密码并更新
*
* @param token JWT Token
* @param oldPwd 旧密码
* @param newPwd 新密码
*/
void updatePassword(String token, String oldPwd, String newPwd);
}

View File

@@ -0,0 +1,33 @@
package com.mokee.auth.service;
import com.mokee.auth.mapper.SysLoginLogMapper;
import com.mokee.common.entity.SysLoginLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 异步日志写入服务
* <p>
* 将登录日志的写入从主业务线程剥离,避免同步数据库写入阻塞登录接口响应。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LogAsyncService {
private final SysLoginLogMapper sysLoginLogMapper;
/**
* 异步写入登录日志
*/
@Async("logTaskExecutor")
public void saveLoginLog(SysLoginLog logEntity) {
try {
sysLoginLogMapper.insert(logEntity);
} catch (Exception e) {
log.error("异步写入登录日志失败 - username: {}", logEntity.getUsername(), e);
}
}
}

View File

@@ -0,0 +1,575 @@
package com.mokee.auth.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.mokee.auth.mapper.*;
import com.mokee.auth.service.AuthService;
import com.mokee.auth.service.LogAsyncService;
import com.mokee.common.dto.request.LoginRequest;
import com.mokee.common.dto.response.*;
import com.mokee.common.entity.*;
import com.mokee.common.enums.StatusEnum;
import com.mokee.common.exception.BusinessException;
import com.mokee.common.exception.ErrorCode;
import com.mokee.common.utils.JwtUtil;
import com.mokee.common.utils.RedisUtil;
import io.jsonwebtoken.Claims;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 认证服务实现类
* <p>
* 完整登录流程:限流 → 锁定检查 → 用户验证 → 密码校验 → 锁定/成功处理 → 系统查询 → 角色查询 → Token生成 → Redis缓存
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysMenuMapper sysMenuMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysUserSystemMapper sysUserSystemMapper;
private final SysSystemMapper sysSystemMapper;
private final SysLoginLogMapper sysLoginLogMapper;
private final LogAsyncService logAsyncService;
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
/** 登录失败最大次数 */
private static final int MAX_FAIL_COUNT = 5;
/** 锁定时间(分钟) */
private static final int LOCK_MINUTES = 5;
/** 限流每分钟最大请求数 */
private static final int RATE_LIMIT_MAX = 10;
/** Token 缓存时间(小时) */
private static final int TOKEN_TTL_HOURS = 2;
/** Token 续期阈值(秒),小于此值自动续期 */
private static final long RENEW_THRESHOLD_SECONDS = 1800;
/** 本地缓存 TTL分钟用 Caffeine 避免跨子网 DB/Redis 查询 */
private static final int CACHE_TTL_MINUTES = 3;
// Caffeine 本地缓存(微秒级命中,不走网络)
private Cache<String, UserInfoVO> userInfoCache;
private Cache<String, List<SystemVO>> systemListCache;
private Cache<String, MenuRouterResponse> menuRouterCache;
@PostConstruct
public void initCaches() {
userInfoCache = Caffeine.newBuilder()
.maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build();
systemListCache = Caffeine.newBuilder()
.maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build();
menuRouterCache = Caffeine.newBuilder()
.maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build();
}
// ==================== 登录 ====================
@Override
public LoginResponse login(LoginRequest req, String ip, String userAgent) {
// 1. 限流检查:按 IP 限制登录频率,每分钟最多 10 次
String rateKey = RedisUtil.key("rate:login:" + ip);
Long rateCount = redisUtil.incr(rateKey);
if (rateCount == 1) {
redisUtil.expire(rateKey, 1, TimeUnit.MINUTES);
}
if (rateCount > RATE_LIMIT_MAX) {
log.warn("登录限流触发 - ip: {}, count: {}", ip, rateCount);
throw new BusinessException(ErrorCode.RATE_LIMITED);
}
// 2. 锁定检查:检查 Redis 中是否存在账号锁定标记
String lockKey = RedisUtil.key("lock:login:" + req.getUsername());
if (redisUtil.exists(lockKey)) {
log.warn("账号锁定中Redis标记 - username: {}", req.getUsername());
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
// 3. 查询用户
SysUser user = sysUserMapper.selectOne(
new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, req.getUsername())
);
if (user == null) {
log.warn("用户不存在 - username: {}", req.getUsername());
throw new BusinessException(ErrorCode.USERNAME_PASSWORD_ERROR);
}
// 4. 状态校验:禁用/锁定的用户拒绝登录
if (user.getStatus() == null || user.getStatus().equals(StatusEnum.DISABLED.getCode())) {
log.warn("用户已禁用 - username: {}", req.getUsername());
throw new BusinessException(ErrorCode.USER_DISABLED);
}
if (user.getStatus().equals(StatusEnum.LOCKED.getCode())
&& user.getLockUntil() != null
&& user.getLockUntil().isAfter(LocalDateTime.now())) {
log.warn("账号已被锁定 - username: {}, lockUntil: {}", req.getUsername(), user.getLockUntil());
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
// 5. 密码校验:使用 BCrypt 比对
boolean passwordMatch = BCrypt.checkpw(req.getPassword(), user.getPassword());
if (!passwordMatch) {
return handleLoginFailure(user, ip, userAgent, "密码错误");
}
// 6. 登录成功处理
return handleLoginSuccess(user, req.getSystemCode(), ip, userAgent);
}
/**
* 处理登录失败:累计失败次数、超限锁定账号、记录失败日志
*/
private LoginResponse handleLoginFailure(SysUser user, String ip, String userAgent, String failReason) {
int failCount = (user.getLoginFailCount() == null ? 0 : user.getLoginFailCount()) + 1;
user.setLoginFailCount(failCount);
// 连续失败 >= 5 次,锁定账号 30 分钟
if (failCount >= MAX_FAIL_COUNT) {
user.setStatus(StatusEnum.LOCKED.getCode());
LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(LOCK_MINUTES);
user.setLockUntil(lockUntil);
// 在 Redis 中设置锁定标记30 分钟后自动过期
redisUtil.set(RedisUtil.key("lock:login:" + user.getUsername()), "1", LOCK_MINUTES, TimeUnit.MINUTES);
log.warn("账号已被锁定 - username: {}, lockUntil: {}", user.getUsername(), lockUntil);
}
sysUserMapper.updateById(user);
// 记录登录失败日志
saveLoginLog(user.getId(), user.getUsername(), user.getRealName(), null, ip, userAgent, 0, failReason);
throw new BusinessException(ErrorCode.USERNAME_PASSWORD_ERROR);
}
/**
* 处理登录成功:重置失败计数、更新登录信息、查询系统/角色、生成Token并缓存Redis
*/
private LoginResponse handleLoginSuccess(SysUser user, String systemCode, String ip, String userAgent) {
// 重置失败次数并更新登录信息
user.setLoginFailCount(0);
user.setLastLoginIp(ip);
user.setLastLoginTime(LocalDateTime.now());
sysUserMapper.updateById(user);
// 查询用户绑定的业务系统列表
List<SysSystem> boundSystems = sysSystemMapper.selectListByUserId(user.getId());
if (boundSystems.isEmpty()) {
log.warn("用户未绑定任何业务系统 - userId: {}", user.getId());
throw new BusinessException(ErrorCode.SYSTEM_DISABLED);
}
// 确定当前系统:优先使用请求指定的 systemCode其次取唯一边界系统
SysSystem currentSystem = null;
if (StrUtil.isNotBlank(systemCode)) {
currentSystem = boundSystems.stream()
.filter(s -> systemCode.equals(s.getSystemCode()))
.findFirst().orElse(null);
}
if (currentSystem == null && boundSystems.size() == 1) {
currentSystem = boundSystems.get(0);
}
// 查询用户在当前系统中的角色及权限
List<RoleInfo> roleInfoList = new ArrayList<>();
List<String> permissionList = new ArrayList<>();
if (currentSystem != null) {
List<SysRole> roles = sysRoleMapper.selectListByUserIdAndSystemId(user.getId(), currentSystem.getId());
roleInfoList = roles.stream()
.map(r -> new RoleInfo(r.getId(), r.getRoleName(), r.getRoleCode()))
.collect(Collectors.toList());
if (!roles.isEmpty()) {
List<String> roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList());
permissionList = sysMenuMapper.selectPermissionsByRoleIds(roleIds);
}
}
// 生成 JWT TokenJwtUtil 内部自动生成 UUID 作为 tokenId
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
Claims tokenClaims = jwtUtil.validateToken(token);
String actualTokenId = jwtUtil.getTokenId(tokenClaims);
// 构建 TokenInfo 并缓存到 RedisTTL 2小时
TokenInfo tokenInfo = new TokenInfo();
tokenInfo.setUserId(user.getId());
tokenInfo.setUsername(user.getUsername());
tokenInfo.setRealName(user.getRealName());
tokenInfo.setEmail(user.getEmail());
tokenInfo.setPhone(user.getPhone());
tokenInfo.setAvatar(user.getAvatar());
tokenInfo.setPost(user.getPost());
tokenInfo.setRoles(roleInfoList);
tokenInfo.setPermissions(permissionList);
if (currentSystem != null) {
tokenInfo.setCurrentSystemId(currentSystem.getId());
tokenInfo.setCurrentSystemCode(currentSystem.getSystemCode());
tokenInfo.setCurrentSystemName(currentSystem.getSystemName());
}
redisUtil.set(RedisUtil.key("auth:token:" + actualTokenId), JSONUtil.toJsonStr(tokenInfo), TOKEN_TTL_HOURS, TimeUnit.HOURS);
// 反向索引userId → tokenId用于用户信息更新时同步刷新 Redis
redisUtil.set(RedisUtil.key("auth:user_session:" + user.getId()), actualTokenId, TOKEN_TTL_HOURS, TimeUnit.HOURS);
// 记录登录成功日志
saveLoginLog(user.getId(), user.getUsername(), user.getRealName(),
currentSystem != null ? currentSystem.getId() : null,
ip, userAgent, 1, null);
// 登录成功后刷新本地缓存(下次查询自动重建)
userInfoCache.invalidate(user.getId());
systemListCache.invalidate(user.getId());
if (currentSystem != null) {
menuRouterCache.invalidate(currentSystem.getId() + ":" + user.getId());
}
// 构建响应对象
UserInfoVO userInfoVO = new UserInfoVO();
userInfoVO.setUserId(user.getId());
userInfoVO.setUsername(user.getUsername());
userInfoVO.setRealName(user.getRealName());
userInfoVO.setEmail(user.getEmail());
userInfoVO.setPhone(user.getPhone());
userInfoVO.setAvatar(user.getAvatar());
userInfoVO.setPost(user.getPost());
List<SystemVO> systemVOList = boundSystems.stream()
.map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(),
s.getFrontUrl(), s.getIcon(), s.getDescription()))
.collect(Collectors.toList());
SystemVO currentSystemVO = null;
if (currentSystem != null) {
currentSystemVO = new SystemVO(currentSystem.getId(), currentSystem.getSystemCode(),
currentSystem.getSystemName(), currentSystem.getFrontUrl(),
currentSystem.getIcon(), currentSystem.getDescription());
}
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setUserInfo(userInfoVO);
response.setSystemList(systemVOList);
response.setNeedSelectSystem(currentSystem == null && boundSystems.size() > 1);
response.setCurrentSystem(currentSystemVO);
// 判断是否为平台管理员(拥有 super_admin 角色的用户可以进入后台管理)
boolean isAdmin = roleInfoList.stream().anyMatch(r -> "super_admin".equals(r.getRoleCode()));
response.setIsAdmin(isAdmin);
response.setPermissions(permissionList);
log.info("用户登录成功 - username: {}, ip: {}, tokenId: {}", user.getUsername(), ip, actualTokenId);
return response;
}
// ==================== 登出 ====================
@Override
public void logout(String token) {
try {
Claims claims = jwtUtil.validateToken(token);
String tokenId = jwtUtil.getTokenId(claims);
String userId = jwtUtil.getUserId(claims);
redisUtil.delete(RedisUtil.key(RedisUtil.key("auth:token:" + tokenId)));
redisUtil.delete(RedisUtil.key("auth:user_session:" + userId));
log.info("用户登出成功 - tokenId: {}, userId: {}", tokenId, userId);
} catch (Exception e) {
// Token 已过期或无效,忽略即可,登出本身就是为了清除会话
log.warn("登出时 Token 无效,忽略 - {}", e.getMessage());
}
}
// ==================== 获取用户信息 ====================
@Override
public UserInfoVO getUserInfo(String token) {
TokenInfo tokenInfo = getTokenInfoFromRedis(token);
String userId = tokenInfo.getUserId();
// 优先读本地缓存(微秒级)
UserInfoVO cached = userInfoCache.getIfPresent(userId);
if (cached != null) {
if ("gateway".equals(tokenInfo.getCurrentSystemCode())) {
cached.setPermissions(tokenInfo.getPermissions());
}
return cached;
}
// 缓存未命中,查 DB 构建
UserInfoVO vo = new UserInfoVO();
vo.setUserId(tokenInfo.getUserId());
vo.setUsername(tokenInfo.getUsername());
vo.setRealName(tokenInfo.getRealName());
vo.setEmail(tokenInfo.getEmail());
vo.setPhone(tokenInfo.getPhone());
vo.setAvatar(tokenInfo.getAvatar());
vo.setPost(tokenInfo.getPost());
if ("gateway".equals(tokenInfo.getCurrentSystemCode())) {
vo.setPermissions(tokenInfo.getPermissions());
}
SysUser user = sysUserMapper.selectById(userId);
if (user != null) {
vo.setCreatedAt(user.getCreatedAt() != null ? user.getCreatedAt().toString() : null);
vo.setLastLoginTime(user.getLastLoginTime() != null ? user.getLastLoginTime().toString() : null);
}
userInfoCache.put(userId, vo);
return vo;
}
// ==================== 获取菜单路由 ====================
@Override
public MenuRouterResponse getMenuRouter(String token, String systemCode) {
TokenInfo tokenInfo = getTokenInfoFromRedis(token);
String userId = tokenInfo.getUserId();
// 查询指定系统
SysSystem system = sysSystemMapper.selectOne(
new LambdaQueryWrapper<SysSystem>().eq(SysSystem::getSystemCode, systemCode)
);
if (system == null) {
throw new BusinessException(ErrorCode.NOT_FOUND);
}
String systemId = system.getId();
String cacheKey = systemId + ":" + userId;
// 优先读本地缓存(微秒级)
MenuRouterResponse cached = menuRouterCache.getIfPresent(cacheKey);
if (cached != null) {
return cached;
}
// 缓存未命中,查 DB 构建
List<SysRole> userRoles = sysRoleMapper.selectListByUserIdAndSystemId(userId, systemId);
if (userRoles.isEmpty()) {
return new MenuRouterResponse(Collections.emptyList(), Collections.emptyList());
}
List<String> roleIds = userRoles.stream().map(SysRole::getId).collect(Collectors.toList());
List<String> permissions = sysMenuMapper.selectPermissionsByRoleIds(roleIds);
List<SysMenu> menus = sysMenuMapper.selectDistinctByRoleIds(roleIds, systemId);
List<SysMenu> routerMenus = menus.stream()
.filter(m -> m.getStatus() == 1)
.filter(m -> m.getMenuType() == 1 || m.getMenuType() == 2)
.sorted(Comparator.comparingInt(m -> m.getSortOrder() != null ? m.getSortOrder() : 0))
.collect(Collectors.toList());
List<MenuRouterVO> menuTree = buildMenuTree(routerMenus, "0");
MenuRouterResponse response = new MenuRouterResponse(menuTree, permissions);
menuRouterCache.put(cacheKey, response);
return response;
}
/**
* 递归构建菜单路由树
*
* @param allMenus 所有菜单列表
* @param parentId 当前父节点 ID
* @return 树形路由节点列表
*/
private List<MenuRouterVO> buildMenuTree(List<SysMenu> allMenus, String parentId) {
List<MenuRouterVO> list = new ArrayList<>();
for (SysMenu menu : allMenus) {
if (menu.getParentId().equals(parentId)) {
MenuRouterVO vo = new MenuRouterVO();
vo.setId(menu.getId());
vo.setMenuName(menu.getMenuName());
vo.setMenuType(menu.getMenuType());
vo.setIcon(menu.getIcon());
vo.setPermission(menu.getPermission());
vo.setPath(menu.getMenuPath());
vo.setName(menu.getMenuName());
vo.setComponent(menu.getComponent());
vo.setSortOrder(menu.getSortOrder());
vo.setStatus(menu.getStatus());
vo.setMeta(new MenuMetaVO(menu.getMenuName(), menu.getIcon()));
// 递归构建子菜单
List<MenuRouterVO> children = buildMenuTree(allMenus, menu.getId());
vo.setChildren(children.isEmpty() ? null : children);
list.add(vo);
}
}
return list;
}
// ==================== 获取系统列表 ====================
@Override
public List<SystemVO> getSystemList(String token) {
TokenInfo tokenInfo = getTokenInfoFromRedis(token);
String userId = tokenInfo.getUserId();
// 优先读本地缓存(微秒级)
List<SystemVO> cached = systemListCache.getIfPresent(userId);
if (cached != null) {
return cached;
}
// 缓存未命中,查 DB
List<SysSystem> systems = sysSystemMapper.selectListByUserId(userId);
List<SystemVO> result = systems.stream()
.map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(),
s.getFrontUrl(), s.getIcon(), s.getDescription()))
.collect(Collectors.toList());
systemListCache.put(userId, result);
return result;
}
// ==================== 切换系统 ====================
@Override
public void selectSystem(String token, String systemId) {
Claims claims = jwtUtil.validateToken(token);
String tokenId = jwtUtil.getTokenId(claims);
String userId = jwtUtil.getUserId(claims);
// 验证该系统是否属于用户的绑定系统
List<SysSystem> userSystems = sysSystemMapper.selectListByUserId(userId);
boolean hasSystem = userSystems.stream().anyMatch(s -> s.getId().equals(systemId));
if (!hasSystem) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
// 获取当前 Redis 中的 TokenInfo
String redisKey = RedisUtil.key("auth:token:" + tokenId);
String jsonStr = redisUtil.get(redisKey);
if (jsonStr == null) {
throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
}
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
// 查询目标系统信息
SysSystem targetSystem = sysSystemMapper.selectById(systemId);
if (targetSystem == null || targetSystem.getStatus() == 0) {
throw new BusinessException(ErrorCode.SYSTEM_DISABLED);
}
// 更新 TokenInfo 中的当前系统信息
tokenInfo.setCurrentSystemId(targetSystem.getId());
tokenInfo.setCurrentSystemCode(targetSystem.getSystemCode());
tokenInfo.setCurrentSystemName(targetSystem.getSystemName());
// 重新查询该系统中的角色和权限
List<SysRole> roles = sysRoleMapper.selectListByUserIdAndSystemId(userId, systemId);
tokenInfo.setRoles(roles.stream()
.map(r -> new RoleInfo(r.getId(), r.getRoleName(), r.getRoleCode()))
.collect(Collectors.toList()));
if (!roles.isEmpty()) {
List<String> roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList());
tokenInfo.setPermissions(sysMenuMapper.selectPermissionsByRoleIds(roleIds));
} else {
tokenInfo.setPermissions(Collections.emptyList());
}
// 更新 Redis 缓存,保持原有 TTL
Long ttl = redisUtil.getExpire(redisKey);
if (ttl != null && ttl > 0) {
redisUtil.set(redisKey, JSONUtil.toJsonStr(tokenInfo), ttl, TimeUnit.SECONDS);
} else {
redisUtil.set(redisKey, JSONUtil.toJsonStr(tokenInfo), TOKEN_TTL_HOURS, TimeUnit.HOURS);
}
// 刷新缓存:切换系统后下次查询自动重建
menuRouterCache.invalidate(systemId + ":" + userId);
// 更新最近一条登录日志的选择系统字段
try {
SysLoginLog latestLog = sysLoginLogMapper.selectOne(
new LambdaQueryWrapper<SysLoginLog>()
.eq(SysLoginLog::getUserId, userId)
.eq(SysLoginLog::getStatus, 1)
.orderByDesc(SysLoginLog::getCreatedAt)
.last("LIMIT 1")
);
if (latestLog != null) {
latestLog.setSystemId(systemId);
sysLoginLogMapper.updateById(latestLog);
}
} catch (Exception e) {
log.warn("更新登录日志系统ID失败 - userId: {}, systemId: {}", userId, systemId, e);
}
log.info("用户切换系统 - userId: {}, systemId: {}, systemName: {}", userId, systemId, targetSystem.getSystemName());
}
// ==================== 修改密码 ====================
@Override
public void updatePassword(String token, String oldPwd, String newPwd) {
Claims claims = jwtUtil.validateToken(token);
String userId = jwtUtil.getUserId(claims);
SysUser user = sysUserMapper.selectById(userId);
if (user == null) {
throw new BusinessException(ErrorCode.NOT_FOUND);
}
// 验证旧密码
if (!BCrypt.checkpw(oldPwd, user.getPassword())) {
throw new BusinessException(400, "旧密码错误");
}
// BCrypt 加密新密码并更新
user.setPassword(BCrypt.hashpw(newPwd));
sysUserMapper.updateById(user);
log.info("用户密码修改成功 - userId: {}", userId);
}
// ==================== 私有辅助方法 ====================
/**
* 从 Redis 中获取 TokenInfo 并进行续期检查
* <p>
* 如果 Token 剩余有效时间不足 30 分钟,自动续期为 2 小时
*/
private TokenInfo getTokenInfoFromRedis(String token) {
Claims claims = jwtUtil.validateToken(token);
String tokenId = jwtUtil.getTokenId(claims);
String redisKey = RedisUtil.key("auth:token:" + tokenId);
String jsonStr = redisUtil.get(redisKey);
if (jsonStr == null) {
throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
}
// 每次请求都刷新 TTLGET + EXPIRE 共 2 次 Redis 往返,省掉 TTL 检查)
redisUtil.expire(redisKey, TOKEN_TTL_HOURS, TimeUnit.HOURS);
return JSONUtil.toBean(jsonStr, TokenInfo.class);
}
/**
* 保存登录日志到数据库
*/
private void saveLoginLog(String userId, String username, String realName, String systemId,
String ip, String userAgent, int status, String failReason) {
SysLoginLog loginLog = SysLoginLog.builder()
.userId(userId)
.username(username)
.realName(realName)
.systemId(systemId)
.loginIp(ip)
.userAgent(userAgent)
.browser(com.mokee.common.utils.UserAgentUtil.parseBrowser(userAgent))
.os(com.mokee.common.utils.UserAgentUtil.parseOs(userAgent))
.status(status)
.failReason(failReason)
.createdAt(LocalDateTime.now())
.build();
logAsyncService.saveLoginLog(loginLog);
}
}

View File

@@ -0,0 +1,40 @@
app:
env-prefix: "[PROD]"
spring:
mail:
host: smtp.qiye.aliyun.com
port: 465
username: admin@zgitm.com
password: mokee2016.
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.socketFactory.port: 465
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
datasource:
url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: mokeegateway
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: redis.db1.zgitm.com
port: 6379
password: mokee2016.
database: 1
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
logging:
level:
com.mokee: INFO
org.springframework.web.servlet.DispatcherServlet: DEBUG

View File

@@ -0,0 +1,39 @@
app:
env-prefix: "[QA]"
spring:
mail:
host: smtp.qiye.aliyun.com
port: 465
username: admin@zgitm.com
password: mokee2016.
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.socketFactory.port: 465
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
datasource:
url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: mokeegatewayqa
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: redis.db2.zgitm.com
port: 6379
password: mokee2016.
database: 2
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.mokee: INFO

View File

@@ -0,0 +1,30 @@
server:
port: 9001
spring:
application:
name: mokee-gateway-auth
profiles:
active: @spring.profiles.active@
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.mokee.common.entity
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
jwt:
secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!!
expiration: 7200
knife4j:
enable: true
openapi:
title: 认证用户服务
description: 登录/登出/Token/用户/角色/菜单
version: 1.0.0