feat(phase8): US7 用户管理模块(角色变更立即生效、禁用即失效)

- RedisService:新增 hPut/sAdd/sRemove/sMembers Set 操作
- RedisKeyManager:新增 userSessionsKey(userId) = user:sessions:{userId}
- AuthService:login 后将 token 加入 user:sessions 集合;logout 时从集合移除
- UserService:createUser/updateUser/updateRole/updateStatus
  - updateRole:DB 写入后更新所有活跃 Token 的 role 字段(立即生效,无需重新登录)
  - updateStatus(DISABLED):删除所有活跃 Token(立即失效),清除 sessions 集合
- UserController:5 个端点全部 @RequiresRoles("ADMIN")
- 集成测试:角色变更同一 Token 立即生效;禁用后 Token 立即 401
This commit is contained in:
wh
2026-04-09 15:48:07 +08:00
parent 49666d1579
commit f6c3b0b4c6
6 changed files with 503 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package com.label.module.user.controller;
import com.label.common.result.PageResult;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.entity.SysUser;
import com.label.module.user.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 用户管理接口5 个端点,全部 ADMIN 权限)。
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/** GET /api/users — 分页查询用户列表 */
@GetMapping
@RequiresRoles("ADMIN")
public Result<PageResult<SysUser>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
HttpServletRequest request) {
return Result.success(userService.listUsers(page, pageSize, principal(request)));
}
/** POST /api/users — 创建用户 */
@PostMapping
@RequiresRoles("ADMIN")
public Result<SysUser> createUser(@RequestBody Map<String, String> body,
HttpServletRequest request) {
return Result.success(userService.createUser(
body.get("username"),
body.get("password"),
body.get("realName"),
body.get("role"),
principal(request)));
}
/** PUT /api/users/{id} — 更新用户基本信息 */
@PutMapping("/{id}")
@RequiresRoles("ADMIN")
public Result<SysUser> updateUser(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
return Result.success(userService.updateUser(
id,
body.get("realName"),
body.get("password"),
principal(request)));
}
/** PUT /api/users/{id}/status — 变更用户状态 */
@PutMapping("/{id}/status")
@RequiresRoles("ADMIN")
public Result<Void> updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
userService.updateStatus(id, body.get("status"), principal(request));
return Result.success(null);
}
/** PUT /api/users/{id}/role — 变更用户角色 */
@PutMapping("/{id}/role")
@RequiresRoles("ADMIN")
public Result<Void> updateRole(@PathVariable Long id,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
userService.updateRole(id, body.get("role"), principal(request));
return Result.success(null);
}
private TokenPrincipal principal(HttpServletRequest request) {
return (TokenPrincipal) request.getAttribute("__token_principal__");
}
}

View File

@@ -85,6 +85,9 @@ public class AuthService {
tokenData.put("username", user.getUsername());
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
// 将 token 加入该用户的活跃会话集合(用于角色变更时批量更新/失效)
redisService.sAdd(RedisKeyManager.userSessionsKey(user.getId()), token);
log.debug("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
}
@@ -96,7 +99,14 @@ public class AuthService {
*/
public void logout(String token) {
if (token != null && !token.isBlank()) {
// 从用户会话集合中移除(若 token 仍有效则先读取 userId
String userId = redisService.hGet(RedisKeyManager.tokenKey(token), "userId");
redisService.delete(RedisKeyManager.tokenKey(token));
if (userId != null) {
try {
redisService.sRemove(RedisKeyManager.userSessionsKey(Long.parseLong(userId)), token);
} catch (NumberFormatException ignored) {}
}
log.debug("用户退出Token 已删除: {}", token);
}
}

View File

@@ -0,0 +1,204 @@
package com.label.module.user.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.label.common.exception.BusinessException;
import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.result.PageResult;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.entity.SysUser;
import com.label.module.user.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 用户管理服务ADMIN 专属)。
*
* 关键设计:
* - 角色变更DB 写入后立即更新所有活跃 Token 中的 role 字段,无需重新登录
* - 状态禁用DB 写入后删除用户所有活跃 Token立即失效
* - 使用 user:sessions:{userId} Set 跟踪活跃会话
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
private final SysUserMapper userMapper;
private final RedisService redisService;
// ------------------------------------------------------------------ 创建用户 --
/**
* 创建新用户ADMIN 操作)。
*
* @param username 用户名
* @param password 明文密码(将以 BCrypt strength=10 哈希)
* @param realName 真实姓名(可选)
* @param role 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* @param principal 当前管理员
* @return 新建用户(不含 passwordHash
*/
@Transactional
public SysUser createUser(String username, String password,
String realName, String role,
TokenPrincipal principal) {
// 校验用户名唯一性
SysUser existing = userMapper.selectByCompanyAndUsername(principal.getCompanyId(), username);
if (existing != null) {
throw new BusinessException("DUPLICATE_USERNAME",
"用户名 '" + username + "' 已存在", HttpStatus.CONFLICT);
}
validateRole(role);
SysUser user = new SysUser();
user.setCompanyId(principal.getCompanyId());
user.setUsername(username);
user.setPasswordHash(PASSWORD_ENCODER.encode(password));
user.setRealName(realName);
user.setRole(role);
user.setStatus("ACTIVE");
userMapper.insert(user);
log.debug("用户已创建: userId={}, username={}, role={}", user.getId(), username, role);
return user;
}
// ------------------------------------------------------------------ 更新基本信息 --
/**
* 更新用户基本信息realName、password
*/
@Transactional
public SysUser updateUser(Long userId, String realName, String password,
TokenPrincipal principal) {
SysUser user = getExistingUser(userId, principal.getCompanyId());
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getCompanyId, principal.getCompanyId());
if (realName != null && !realName.isBlank()) {
wrapper.set(SysUser::getRealName, realName);
user.setRealName(realName);
}
if (password != null && !password.isBlank()) {
wrapper.set(SysUser::getPasswordHash, PASSWORD_ENCODER.encode(password));
}
userMapper.update(null, wrapper);
return user;
}
// ------------------------------------------------------------------ 变更角色 --
/**
* 变更用户角色。
*
* DB 写入后,立即更新该用户所有活跃 Token 中的 role 字段,
* 确保角色变更对下一次请求立即生效(无需重新登录)。
*
* @param userId 目标用户 ID
* @param newRole 新角色
* @param principal 当前管理员
*/
@Transactional
public void updateRole(Long userId, String newRole, TokenPrincipal principal) {
getExistingUser(userId, principal.getCompanyId());
validateRole(newRole);
// 1. DB 写入
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getCompanyId, principal.getCompanyId())
.set(SysUser::getRole, newRole));
// 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录)
Set<String> tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId));
tokens.forEach(token ->
redisService.hPut(RedisKeyManager.tokenKey(token), "role", newRole));
// 3. 删除权限缓存(如 Shiro 缓存存在)
redisService.delete(RedisKeyManager.userPermKey(userId));
log.debug("用户角色已变更: userId={}, newRole={}, 更新 {} 个活跃 Token", userId, newRole, tokens.size());
}
// ------------------------------------------------------------------ 变更状态 --
/**
* 变更用户状态(启用/禁用)。
*
* 禁用时DB 写入后立即删除该用户所有活跃 Token现有会话立即失效。
*/
@Transactional
public void updateStatus(Long userId, String newStatus, TokenPrincipal principal) {
getExistingUser(userId, principal.getCompanyId());
if (!"ACTIVE".equals(newStatus) && !"DISABLED".equals(newStatus)) {
throw new BusinessException("INVALID_STATUS",
"状态值不合法,应为 ACTIVE 或 DISABLED", HttpStatus.BAD_REQUEST);
}
// DB 写入
userMapper.update(null, new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, userId)
.eq(SysUser::getCompanyId, principal.getCompanyId())
.set(SysUser::getStatus, newStatus));
// 禁用时:删除所有活跃 Token立即失效
if ("DISABLED".equals(newStatus)) {
Set<String> tokens = redisService.sMembers(RedisKeyManager.userSessionsKey(userId));
tokens.forEach(token -> redisService.delete(RedisKeyManager.tokenKey(token)));
redisService.delete(RedisKeyManager.userSessionsKey(userId));
log.debug("账号已禁用,已删除 {} 个活跃 Token: userId={}", tokens.size(), userId);
}
// 删除权限缓存
redisService.delete(RedisKeyManager.userPermKey(userId));
}
// ------------------------------------------------------------------ 查询 --
/**
* 分页查询当前公司用户列表。
*/
public PageResult<SysUser> listUsers(int page, int pageSize, TokenPrincipal principal) {
pageSize = Math.min(pageSize, 100);
Page<SysUser> result = userMapper.selectPage(
new Page<>(page, pageSize),
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
.eq(SysUser::getCompanyId, principal.getCompanyId())
.orderByAsc(SysUser::getCreatedAt));
return PageResult.of(result.getRecords(), result.getTotal(), page, pageSize);
}
// ------------------------------------------------------------------ 私有工具 --
private SysUser getExistingUser(Long userId, Long companyId) {
SysUser user = userMapper.selectById(userId);
if (user == null || !companyId.equals(user.getCompanyId())) {
throw new BusinessException("NOT_FOUND", "用户不存在: " + userId, HttpStatus.NOT_FOUND);
}
return user;
}
private void validateRole(String role) {
if (!List.of("UPLOADER", "ANNOTATOR", "REVIEWER", "ADMIN").contains(role)) {
throw new BusinessException("INVALID_ROLE",
"角色值不合法: " + role, HttpStatus.BAD_REQUEST);
}
}
}