diff --git a/src/main/java/com/label/common/redis/RedisKeyManager.java b/src/main/java/com/label/common/redis/RedisKeyManager.java index d0286b1..80d5855 100644 --- a/src/main/java/com/label/common/redis/RedisKeyManager.java +++ b/src/main/java/com/label/common/redis/RedisKeyManager.java @@ -22,4 +22,9 @@ public final class RedisKeyManager { public static String taskClaimKey(Long taskId) { return "task:claim:" + taskId; } + + /** User active sessions set key: user:sessions:{userId} */ + public static String userSessionsKey(Long userId) { + return "user:sessions:" + userId; + } } diff --git a/src/main/java/com/label/common/redis/RedisService.java b/src/main/java/com/label/common/redis/RedisService.java index af305ae..1b2cade 100644 --- a/src/main/java/com/label/common/redis/RedisService.java +++ b/src/main/java/com/label/common/redis/RedisService.java @@ -58,4 +58,27 @@ public class RedisService { Object val = redisTemplate.opsForHash().get(key, field); return val != null ? val.toString() : null; } + + /** 更新 Hash 中的单个字段(不改变其他字段和 TTL)。 */ + public void hPut(String key, String field, String value) { + redisTemplate.opsForHash().put(key, field, value); + } + + // Set operations(用于用户会话跟踪) + + /** 向 Set 添加成员。 */ + public void sAdd(String key, String member) { + redisTemplate.opsForSet().add(key, member); + } + + /** 从 Set 移除成员。 */ + public void sRemove(String key, String member) { + redisTemplate.opsForSet().remove(key, (Object) member); + } + + /** 获取 Set 全部成员;Set 不存在时返回空集合。 */ + public java.util.Set sMembers(String key) { + java.util.Set members = redisTemplate.opsForSet().members(key); + return members != null ? members : java.util.Collections.emptySet(); + } } diff --git a/src/main/java/com/label/module/user/controller/UserController.java b/src/main/java/com/label/module/user/controller/UserController.java new file mode 100644 index 0000000..db044c3 --- /dev/null +++ b/src/main/java/com/label/module/user/controller/UserController.java @@ -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> 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 createUser(@RequestBody Map 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 updateUser(@PathVariable Long id, + @RequestBody Map 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 updateStatus(@PathVariable Long id, + @RequestBody Map 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 updateRole(@PathVariable Long id, + @RequestBody Map 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__"); + } +} diff --git a/src/main/java/com/label/module/user/service/AuthService.java b/src/main/java/com/label/module/user/service/AuthService.java index d1379f3..86b0e64 100644 --- a/src/main/java/com/label/module/user/service/AuthService.java +++ b/src/main/java/com/label/module/user/service/AuthService.java @@ -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); } } diff --git a/src/main/java/com/label/module/user/service/UserService.java b/src/main/java/com/label/module/user/service/UserService.java new file mode 100644 index 0000000..dbe9454 --- /dev/null +++ b/src/main/java/com/label/module/user/service/UserService.java @@ -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 wrapper = new LambdaUpdateWrapper() + .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() + .eq(SysUser::getId, userId) + .eq(SysUser::getCompanyId, principal.getCompanyId()) + .set(SysUser::getRole, newRole)); + + // 2. 更新所有活跃 Token 中的 role 字段(立即生效,无需重新登录) + Set 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() + .eq(SysUser::getId, userId) + .eq(SysUser::getCompanyId, principal.getCompanyId()) + .set(SysUser::getStatus, newStatus)); + + // 禁用时:删除所有活跃 Token(立即失效) + if ("DISABLED".equals(newStatus)) { + Set 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 listUsers(int page, int pageSize, TokenPrincipal principal) { + pageSize = Math.min(pageSize, 100); + Page result = userMapper.selectPage( + new Page<>(page, pageSize), + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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); + } + } +} diff --git a/src/test/java/com/label/integration/UserManagementIntegrationTest.java b/src/test/java/com/label/integration/UserManagementIntegrationTest.java new file mode 100644 index 0000000..4676b14 --- /dev/null +++ b/src/test/java/com/label/integration/UserManagementIntegrationTest.java @@ -0,0 +1,177 @@ +package com.label.integration; + +import com.label.AbstractIntegrationTest; +import com.label.module.user.dto.LoginRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 用户管理集成测试(US7)。 + * + * 测试场景: + * 1. 变更角色后权限下一次请求立即生效(无需重新登录) + * 2. 禁用账号后现有 Token 下一次请求立即返回 401 + */ +public class UserManagementIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + private String adminToken; + + @BeforeEach + void setup() { + adminToken = loginAndGetToken("DEMO", "admin", "admin123"); + assertThat(adminToken).isNotBlank(); + } + + // ------------------------------------------------------------------ 测试 1: 角色变更立即生效 -- + + @Test + @DisplayName("创建用户为 ANNOTATOR,变更为 REVIEWER 后同一 Token 立即可访问审批接口") + void updateRole_takesEffectImmediately() { + String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); + + // 1. 创建 ANNOTATOR 用户 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + adminToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity createResp = restTemplate.exchange( + baseUrl("/api/users"), + HttpMethod.POST, + new HttpEntity<>(Map.of( + "username", uniqueUsername, + "password", "test1234", + "realName", "测试用户", + "role", "ANNOTATOR" + ), headers), + Map.class); + assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + @SuppressWarnings("unchecked") + Map userData = (Map) createResp.getBody().get("data"); + Long newUserId = ((Number) userData.get("id")).longValue(); + + // 2. 新用户登录获取 Token + String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); + assertThat(userToken).isNotBlank(); + + // 3. 验证:ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403 + ResponseEntity beforeRoleChange = restTemplate.exchange( + baseUrl("/api/tasks/pending-review"), + HttpMethod.GET, + bearerRequest(userToken), + Map.class); + assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + + // 4. ADMIN 变更角色为 REVIEWER + ResponseEntity roleResp = restTemplate.exchange( + baseUrl("/api/users/" + newUserId + "/role"), + HttpMethod.PUT, + new HttpEntity<>(Map.of("role", "REVIEWER"), headers), + Map.class); + assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200 + ResponseEntity afterRoleChange = restTemplate.exchange( + baseUrl("/api/tasks/pending-review"), + HttpMethod.GET, + bearerRequest(userToken), + Map.class); + assertThat(afterRoleChange.getStatusCode()) + .as("角色变更后同一 Token 应立即具有 REVIEWER 权限") + .isEqualTo(HttpStatus.OK); + } + + // ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 -- + + @Test + @DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401") + void disableAccount_tokenInvalidatedImmediately() { + String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); + + // 1. 创建用户 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + adminToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity createResp = restTemplate.exchange( + baseUrl("/api/users"), + HttpMethod.POST, + new HttpEntity<>(Map.of( + "username", uniqueUsername, + "password", "test1234", + "realName", "测试用户", + "role", "ANNOTATOR" + ), headers), + Map.class); + assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + @SuppressWarnings("unchecked") + Map userData = (Map) createResp.getBody().get("data"); + Long newUserId = ((Number) userData.get("id")).longValue(); + + // 2. 新用户登录,获取 Token + String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); + assertThat(userToken).isNotBlank(); + + // 3. 验证 Token 有效 + ResponseEntity meResp = restTemplate.exchange( + baseUrl("/api/auth/me"), + HttpMethod.GET, + bearerRequest(userToken), + Map.class); + assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 4. ADMIN 禁用账号 + ResponseEntity disableResp = restTemplate.exchange( + baseUrl("/api/users/" + newUserId + "/status"), + HttpMethod.PUT, + new HttpEntity<>(Map.of("status", "DISABLED"), headers), + Map.class); + assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 5. 验证:禁用后,现有 Token 立即失效 → 401 + ResponseEntity meAfterDisable = restTemplate.exchange( + baseUrl("/api/auth/me"), + HttpMethod.GET, + bearerRequest(userToken), + Map.class); + assertThat(meAfterDisable.getStatusCode()) + .as("禁用账号后现有 Token 应立即失效") + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + // ------------------------------------------------------------------ 工具方法 -- + + private String loginAndGetToken(String companyCode, String username, String password) { + LoginRequest req = new LoginRequest(); + req.setCompanyCode(companyCode); + req.setUsername(username); + req.setPassword(password); + ResponseEntity response = restTemplate.postForEntity( + baseUrl("/api/auth/login"), req, Map.class); + if (!response.getStatusCode().is2xxSuccessful()) { + return null; + } + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + return (String) data.get("token"); + } + + private HttpEntity bearerRequest(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + token); + return new HttpEntity<>(headers); + } +}