Commit cd2d8d080d3b88c77f1f7e4c0741032773d1350f

Authored by zichun
2 parents 237a97e4 baafdfbc

Merge branch 'module-module_usr'

Showing 60 changed files with 5158 additions and 5 deletions

Too many changes to show.

To preserve performance only 58 of 60 files are displayed.

backend/pom.xml
@@ -82,6 +82,25 @@ @@ -82,6 +82,25 @@
82 <version>${hutool.version}</version> 82 <version>${hutool.version}</version>
83 </dependency> 83 </dependency>
84 84
  85 + <!-- REQ-USR-004 JWT (jjwt 0.12.x) -->
  86 + <dependency>
  87 + <groupId>io.jsonwebtoken</groupId>
  88 + <artifactId>jjwt-api</artifactId>
  89 + <version>0.12.6</version>
  90 + </dependency>
  91 + <dependency>
  92 + <groupId>io.jsonwebtoken</groupId>
  93 + <artifactId>jjwt-impl</artifactId>
  94 + <version>0.12.6</version>
  95 + <scope>runtime</scope>
  96 + </dependency>
  97 + <dependency>
  98 + <groupId>io.jsonwebtoken</groupId>
  99 + <artifactId>jjwt-jackson</artifactId>
  100 + <version>0.12.6</version>
  101 + <scope>runtime</scope>
  102 + </dependency>
  103 +
85 <dependency> 104 <dependency>
86 <groupId>org.springframework.boot</groupId> 105 <groupId>org.springframework.boot</groupId>
87 <artifactId>spring-boot-starter-test</artifactId> 106 <artifactId>spring-boot-starter-test</artifactId>
backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import lombok.Getter;
  5 +
  6 +/**
  7 + * REQ-USR-004 账号被临时锁定时抛出。携带剩余 cooldownSeconds,
  8 + * GlobalExceptionHandler 会把它转换成 ApiResponse.data 的 cooldownSeconds 字段。
  9 + */
  10 +@Getter
  11 +public class AccountLockedException extends BizException {
  12 +
  13 + private final long cooldownSeconds;
  14 +
  15 + public AccountLockedException(long cooldownSeconds) {
  16 + super(ErrorCode.LOGIN_ACCOUNT_LOCKED);
  17 + this.cooldownSeconds = cooldownSeconds;
  18 + }
  19 +}
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
@@ -12,6 +12,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -12,6 +12,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
12 @RestControllerAdvice 12 @RestControllerAdvice
13 public class GlobalExceptionHandler { 13 public class GlobalExceptionHandler {
14 14
  15 + /** REQ-USR-004 账号锁定专用:把 cooldownSeconds 暴露在 data.cooldownSeconds */
  16 + @ExceptionHandler(AccountLockedException.class)
  17 + public ApiResponse<java.util.Map<String, Object>> handleAccountLocked(AccountLockedException e) {
  18 + log.warn("AccountLocked code={} cooldown={}s", e.getCode(), e.getCooldownSeconds());
  19 + java.util.Map<String, Object> data = java.util.Map.of("cooldownSeconds", e.getCooldownSeconds());
  20 + return new ApiResponse<>(e.getCode(), e.getMessage(), data, System.currentTimeMillis());
  21 + }
  22 +
15 @ExceptionHandler(BizException.class) 23 @ExceptionHandler(BizException.class)
16 public ApiResponse<Void> handleBiz(BizException e) { 24 public ApiResponse<Void> handleBiz(BizException e) {
17 log.warn("BizException code={} message={}", e.getCode(), e.getMessage()); 25 log.warn("BizException code={} message={}", e.getCode(), e.getMessage());
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
@@ -6,11 +6,17 @@ import lombok.Getter; @@ -6,11 +6,17 @@ import lombok.Getter;
6 public enum ErrorCode { 6 public enum ErrorCode {
7 SUCCESS(200, "操作成功"), 7 SUCCESS(200, "操作成功"),
8 PARAM_INVALID(40010, "参数错误"), 8 PARAM_INVALID(40010, "参数错误"),
  9 + LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误"),
  10 + LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定"),
9 MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"), 11 MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"),
10 MOD_NOT_FOUND(40421, "模块不存在或已删除"), 12 MOD_NOT_FOUND(40421, "模块不存在或已删除"),
  13 + STAFF_NOT_FOUND(40421, "职员不存在或已删除"),
  14 + PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除"),
  15 + USR_NOT_FOUND(40431, "用户不存在或已删除"),
11 MOD_PROC_NAME_DUP(40911, "存储过程名称已存在"), 16 MOD_PROC_NAME_DUP(40911, "存储过程名称已存在"),
12 MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除"), 17 MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除"),
13 MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代"), 18 MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代"),
  19 + USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在"),
14 INTERNAL_ERROR(50000, "服务器内部错误"); 20 INTERNAL_ERROR(50000, "服务器内部错误");
15 21
16 private final int code; 22 private final int code;
backend/src/main/java/com/xly/erp/common/response/PageResult.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +import java.util.ArrayList;
  9 +import java.util.List;
  10 +
  11 +/** REQ-USR-003 引入的通用分页 VO。`data` 字段嵌套此结构。 */
  12 +@Data
  13 +@NoArgsConstructor
  14 +@AllArgsConstructor
  15 +public class PageResult<T> {
  16 + private long total;
  17 + private List<T> list = new ArrayList<>();
  18 + private long pageNum;
  19 + private long pageSize;
  20 +
  21 + public static <T> PageResult<T> of(IPage<T> page) {
  22 + return new PageResult<>(page.getTotal(), page.getRecords(), page.getCurrent(), page.getSize());
  23 + }
  24 +}
backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java 0 → 100644
  1 +package com.xly.erp.config;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.DbType;
  4 +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
  5 +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
  6 +import org.springframework.context.annotation.Bean;
  7 +import org.springframework.context.annotation.Configuration;
  8 +
  9 +/** REQ-USR-003 引入:注册 MyBatis-Plus 分页拦截器,让 `Page<T>` 自动追加 LIMIT 子句。 */
  10 +@Configuration
  11 +public class MybatisPlusConfig {
  12 +
  13 + @Bean
  14 + public MybatisPlusInterceptor mybatisPlusInterceptor() {
  15 + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
  16 + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
  17 + return interceptor;
  18 + }
  19 +}
backend/src/main/java/com/xly/erp/config/PasswordConfig.java 0 → 100644
  1 +package com.xly.erp.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  6 +import org.springframework.security.crypto.password.PasswordEncoder;
  7 +
  8 +/**
  9 + * REQ-USR-001 引入:BCryptPasswordEncoder 注册为 Spring bean,
  10 + * 供 UserService.create / REQ-USR-004 登录校验复用。strength 用 BCrypt 默认(10)。
  11 + */
  12 +@Configuration
  13 +public class PasswordConfig {
  14 +
  15 + @Bean
  16 + public PasswordEncoder passwordEncoder() {
  17 + return new BCryptPasswordEncoder();
  18 + }
  19 +}
backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.ApiResponse;
  4 +import com.xly.erp.module.usr.dto.LoginDTO;
  5 +import com.xly.erp.module.usr.service.LoginService;
  6 +import com.xly.erp.module.usr.vo.LoginResultVO;
  7 +import jakarta.validation.Valid;
  8 +import lombok.RequiredArgsConstructor;
  9 +import org.springframework.web.bind.annotation.PostMapping;
  10 +import org.springframework.web.bind.annotation.RequestBody;
  11 +import org.springframework.web.bind.annotation.RequestMapping;
  12 +import org.springframework.web.bind.annotation.RestController;
  13 +
  14 +/** REQ-USR-004 用户登录入口 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成。 */
  15 +@RestController
  16 +@RequestMapping("/api/auth")
  17 +@RequiredArgsConstructor
  18 +public class LoginController {
  19 +
  20 + private final LoginService loginService;
  21 +
  22 + @PostMapping("/login")
  23 + public ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto) {
  24 + return ApiResponse.ok(loginService.login(dto));
  25 + }
  26 +}
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.ApiResponse;
  4 +import com.xly.erp.common.response.PageResult;
  5 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  6 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  7 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  8 +import com.xly.erp.module.usr.service.UserService;
  9 +import com.xly.erp.module.usr.vo.UserListItemVO;
  10 +import com.xly.erp.module.usr.vo.UserVO;
  11 +import jakarta.validation.Valid;
  12 +import lombok.RequiredArgsConstructor;
  13 +import org.springframework.web.bind.annotation.GetMapping;
  14 +import org.springframework.web.bind.annotation.PathVariable;
  15 +import org.springframework.web.bind.annotation.PostMapping;
  16 +import org.springframework.web.bind.annotation.PutMapping;
  17 +import org.springframework.web.bind.annotation.RequestBody;
  18 +import org.springframework.web.bind.annotation.RequestMapping;
  19 +import org.springframework.web.bind.annotation.RestController;
  20 +
  21 +@RestController
  22 +@RequestMapping("/api/users")
  23 +@RequiredArgsConstructor
  24 +public class UserController {
  25 +
  26 + private final UserService userService;
  27 +
  28 + /** REQ-USR-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')") */
  29 + @PostMapping
  30 + public ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto) {
  31 + return ApiResponse.ok(userService.create(dto));
  32 + }
  33 +
  34 + /** REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')") */
  35 + @PutMapping("/{id}")
  36 + public ApiResponse<UserVO> update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto) {
  37 + return ApiResponse.ok(userService.update(id, dto));
  38 + }
  39 +
  40 + /** REQ-USR-003 用户列表查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')") */
  41 + @GetMapping
  42 + public ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query) {
  43 + return ApiResponse.ok(userService.search(query));
  44 + }
  45 +}
backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import jakarta.validation.constraints.Size;
  6 +import lombok.Data;
  7 +
  8 +/** REQ-USR-004 用户登录入参 */
  9 +@Data
  10 +public class LoginDTO {
  11 +
  12 + @NotBlank
  13 + @Size(max = 50)
  14 + private String sUserName;
  15 +
  16 + @NotBlank
  17 + @Size(max = 100)
  18 + private String sPassword;
  19 +
  20 + @NotBlank
  21 + @Pattern(regexp = "^standard$", message = "sVersion 仅支持 standard")
  22 + private String sVersion;
  23 +}
backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import jakarta.validation.constraints.Size;
  6 +import lombok.Data;
  7 +
  8 +import java.util.List;
  9 +
  10 +/** REQ-USR-001 用户新增入参(密码不在 DTO 里,service 层固定哈希 666666 落库)。 */
  11 +@Data
  12 +public class UserCreateDTO {
  13 +
  14 + @NotBlank
  15 + @Size(max = 50)
  16 + private String sUserNo;
  17 +
  18 + @NotBlank
  19 + @Size(max = 50)
  20 + private String sUserName;
  21 +
  22 + /** 可空:关联职员 id */
  23 + private Integer iStaffId;
  24 +
  25 + @NotBlank
  26 + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一")
  27 + private String sUserType;
  28 +
  29 + @NotBlank
  30 + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一")
  31 + private String sLanguage;
  32 +
  33 + /** 可空:默认 false */
  34 + private Boolean bCanModifyDocs;
  35 +
  36 + /** 可空:每个 id 必须指向未删除的 tPermissionCategory.iIncrement */
  37 + private List<Integer> permissionCategoryIds;
  38 +}
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.Max;
  4 +import jakarta.validation.constraints.Min;
  5 +import jakarta.validation.constraints.Pattern;
  6 +import jakarta.validation.constraints.Size;
  7 +import lombok.Data;
  8 +
  9 +/** REQ-USR-003 用户查询参数 DTO(query string 绑定)。 */
  10 +@Data
  11 +public class UserQueryDTO {
  12 +
  13 + @Min(1)
  14 + private Integer pageNum = 1;
  15 +
  16 + @Min(1)
  17 + @Max(100)
  18 + private Integer pageSize = 20;
  19 +
  20 + /** 可空:缺省视为不过滤;服务层白名单映射为 SQL 列名后通过 mapper @Param 单独传入 */
  21 + @Pattern(regexp = "^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$",
  22 + message = "queryField 非法")
  23 + private String queryField;
  24 +
  25 + /** 可空:默认 contains */
  26 + @Pattern(regexp = "^(contains|notContains|equals)?$", message = "matchType 非法")
  27 + private String matchType;
  28 +
  29 + /** 可空:缺省视为不过滤 */
  30 + @Size(max = 100)
  31 + private String queryValue;
  32 +}
backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import lombok.Data;
  6 +
  7 +import java.util.List;
  8 +
  9 +/**
  10 + * REQ-USR-002 用户修改入参。
  11 + * 与 {@link UserCreateDTO} 相比剥除 sUserNo / sUserName(登录身份不可改);
  12 + * 密码不通过本接口修改,亦不在 DTO 里。
  13 + */
  14 +@Data
  15 +public class UserUpdateDTO {
  16 +
  17 + /** 可空:null 表示清空员工关联(service 层借 iStaffId.IGNORED 策略写入 NULL) */
  18 + private Integer iStaffId;
  19 +
  20 + @NotBlank
  21 + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一")
  22 + private String sUserType;
  23 +
  24 + @NotBlank
  25 + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一")
  26 + private String sLanguage;
  27 +
  28 + /** 可空:null 表示保持原值;显式覆盖 */
  29 + private Boolean bCanModifyDocs;
  30 +
  31 + /** 可空:每元素须存在且未软删除;空数组 / null 都视为清空全部授权关联 */
  32 + private List<Integer> permissionCategoryIds;
  33 +}
backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Data;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +/** REQ-USR-001 引入。表 tPermissionCategory(详见 docs/03 § tPermissionCategory)。 */
  12 +@Data
  13 +@TableName("tPermissionCategory")
  14 +public class PermissionCategoryEntity {
  15 +
  16 + @TableId(value = "iIncrement", type = IdType.AUTO)
  17 + private Integer iIncrement;
  18 +
  19 + @TableField("sId")
  20 + private String sId;
  21 +
  22 + @TableField("sBrandsId")
  23 + private String sBrandsId;
  24 +
  25 + @TableField("sSubsidiaryId")
  26 + private String sSubsidiaryId;
  27 +
  28 + @TableField("tCreateDate")
  29 + private LocalDateTime tCreateDate;
  30 +
  31 + @TableField("sCategoryCode")
  32 + private String sCategoryCode;
  33 +
  34 + @TableField("sCategoryName")
  35 + private String sCategoryName;
  36 +
  37 + @TableField("iParentId")
  38 + private Integer iParentId;
  39 +
  40 + @TableField("iSortOrder")
  41 + private Integer iSortOrder;
  42 +
  43 + @TableField("sCreatedBy")
  44 + private String sCreatedBy;
  45 +
  46 + @TableField("bDeleted")
  47 + private Boolean bDeleted;
  48 +
  49 + @TableField("tDeletedDate")
  50 + private LocalDateTime tDeletedDate;
  51 +
  52 + @TableField("sDeletedBy")
  53 + private String sDeletedBy;
  54 +}
backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Data;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +/** REQ-USR-001 引入。表 tStaff(详见 docs/03 § tStaff)。 */
  12 +@Data
  13 +@TableName("tStaff")
  14 +public class StaffEntity {
  15 +
  16 + @TableId(value = "iIncrement", type = IdType.AUTO)
  17 + private Integer iIncrement;
  18 +
  19 + @TableField("sId")
  20 + private String sId;
  21 +
  22 + @TableField("sBrandsId")
  23 + private String sBrandsId;
  24 +
  25 + @TableField("sSubsidiaryId")
  26 + private String sSubsidiaryId;
  27 +
  28 + @TableField("tCreateDate")
  29 + private LocalDateTime tCreateDate;
  30 +
  31 + @TableField("sStaffNo")
  32 + private String sStaffNo;
  33 +
  34 + @TableField("sStaffName")
  35 + private String sStaffName;
  36 +
  37 + @TableField("sDepartment")
  38 + private String sDepartment;
  39 +
  40 + @TableField("sCreatedBy")
  41 + private String sCreatedBy;
  42 +
  43 + @TableField("bDeleted")
  44 + private Boolean bDeleted;
  45 +
  46 + @TableField("tDeletedDate")
  47 + private LocalDateTime tDeletedDate;
  48 +
  49 + @TableField("sDeletedBy")
  50 + private String sDeletedBy;
  51 +}
backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.FieldStrategy;
  4 +import com.baomidou.mybatisplus.annotation.IdType;
  5 +import com.baomidou.mybatisplus.annotation.TableField;
  6 +import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableName;
  8 +import lombok.Data;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +/** REQ-USR-001 用户主数据。表 tUser(详见 docs/03 § tUser)。 */
  13 +@Data
  14 +@TableName("tUser")
  15 +public class UserEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sUserNo")
  33 + private String sUserNo;
  34 +
  35 + @TableField("sUserName")
  36 + private String sUserName;
  37 +
  38 + /** REQ-USR-002 允许更新为 null(清空员工关联),用 IGNORED 让 MP updateById 把 NULL 写入 SQL。
  39 + * 注意:此策略意味着任何 updateById 都会写 iStaffId;调用方必须 selectById 后再 updateById(load-then-modify)。 */
  40 + @TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)
  41 + private Integer iStaffId;
  42 +
  43 + @TableField("sUserType")
  44 + private String sUserType;
  45 +
  46 + @TableField("sLanguage")
  47 + private String sLanguage;
  48 +
  49 + @TableField("bCanModifyDocs")
  50 + private Boolean bCanModifyDocs;
  51 +
  52 + @TableField("sPasswordHash")
  53 + private String sPasswordHash;
  54 +
  55 + @TableField("tLastLoginDate")
  56 + private LocalDateTime tLastLoginDate;
  57 +
  58 + @TableField("sCreatedBy")
  59 + private String sCreatedBy;
  60 +
  61 + @TableField("bDeleted")
  62 + private Boolean bDeleted;
  63 +
  64 + @TableField("tDeletedDate")
  65 + private LocalDateTime tDeletedDate;
  66 +
  67 + @TableField("sDeletedBy")
  68 + private String sDeletedBy;
  69 +}
backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Data;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +/** REQ-USR-001 引入。表 tUserPermission(详见 docs/03 § tUserPermission)。 */
  12 +@Data
  13 +@TableName("tUserPermission")
  14 +public class UserPermissionEntity {
  15 +
  16 + @TableId(value = "iIncrement", type = IdType.AUTO)
  17 + private Integer iIncrement;
  18 +
  19 + @TableField("sId")
  20 + private String sId;
  21 +
  22 + @TableField("sBrandsId")
  23 + private String sBrandsId;
  24 +
  25 + @TableField("sSubsidiaryId")
  26 + private String sSubsidiaryId;
  27 +
  28 + @TableField("tCreateDate")
  29 + private LocalDateTime tCreateDate;
  30 +
  31 + @TableField("iUserId")
  32 + private Integer iUserId;
  33 +
  34 + @TableField("iCategoryId")
  35 + private Integer iCategoryId;
  36 +
  37 + // docs/03 § tUserPermission 修订版无 bSelected 列——关联记录存在即「已选」,无需独立 flag。
  38 + // 早期 REQ-USR-001 spec/plan 草稿曾包含 bSelected,与 SSoT docs/03 不一致;以 docs/03 为准。
  39 +
  40 + @TableField("sCreatedBy")
  41 + private String sCreatedBy;
  42 +}
backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  5 +
  6 +public interface PermissionCategoryMapper extends BaseMapper<PermissionCategoryEntity> {
  7 +}
backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.StaffEntity;
  5 +
  6 +public interface StaffMapper extends BaseMapper<StaffEntity> {
  7 +}
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  6 +import com.xly.erp.module.usr.entity.UserEntity;
  7 +import com.xly.erp.module.usr.vo.UserListItemVO;
  8 +import org.apache.ibatis.annotations.Param;
  9 +
  10 +public interface UserMapper extends BaseMapper<UserEntity> {
  11 +
  12 + /**
  13 + * REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。
  14 + * @param column service 层白名单映射后的 SQL 列字符串(如 "u.sUserName");外部输入绝不直接走这里。
  15 + */
  16 + IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page,
  17 + @Param("query") UserQueryDTO query,
  18 + @Param("column") String column);
  19 +}
backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  5 +
  6 +public interface UserPermissionMapper extends BaseMapper<UserPermissionEntity> {
  7 +}
backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import org.springframework.stereotype.Component;
  4 +
  5 +import java.time.Duration;
  6 +import java.time.Instant;
  7 +import java.util.concurrent.ConcurrentHashMap;
  8 +import java.util.concurrent.ConcurrentMap;
  9 +
  10 +/**
  11 + * REQ-USR-004 内存版登录失败计数 / 锁定。
  12 + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。
  13 + */
  14 +@Component
  15 +public class InMemoryLoginAttemptStore implements LoginAttemptStore {
  16 +
  17 + static final int LOCK_THRESHOLD = 5;
  18 + static final Duration LOCK_DURATION = Duration.ofMinutes(15);
  19 +
  20 + private final ConcurrentMap<String, FailRecord> store = new ConcurrentHashMap<>();
  21 +
  22 + @Override
  23 + public long cooldownSeconds(String username) {
  24 + FailRecord r = store.get(username);
  25 + if (r == null || r.lockUntil == null) {
  26 + return 0L;
  27 + }
  28 + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond();
  29 + if (remaining <= 0L) {
  30 + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0)
  31 + store.remove(username);
  32 + return 0L;
  33 + }
  34 + return remaining;
  35 + }
  36 +
  37 + @Override
  38 + public void recordFailure(String username) {
  39 + Instant now = Instant.now();
  40 + store.compute(username, (k, prev) -> {
  41 + // 锁定到期 → reset 重新起算
  42 + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil))
  43 + ? new FailRecord()
  44 + : (prev == null ? new FailRecord() : prev);
  45 + r.count++;
  46 + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) {
  47 + r.lockUntil = now.plus(LOCK_DURATION);
  48 + }
  49 + return r;
  50 + });
  51 + }
  52 +
  53 + @Override
  54 + public void clear(String username) {
  55 + store.remove(username);
  56 + }
  57 +
  58 + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。
  59 + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */
  60 + public void expireLockForTest(String username) {
  61 + store.computeIfPresent(username, (k, r) -> {
  62 + r.lockUntil = Instant.now().minusSeconds(1);
  63 + return r;
  64 + });
  65 + }
  66 +
  67 + static class FailRecord {
  68 + int count;
  69 + Instant lockUntil;
  70 + }
  71 +}
backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import io.jsonwebtoken.Claims;
  4 +import io.jsonwebtoken.Jwts;
  5 +import io.jsonwebtoken.security.Keys;
  6 +import org.springframework.beans.factory.annotation.Value;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +import javax.crypto.SecretKey;
  10 +import java.nio.charset.StandardCharsets;
  11 +import java.time.Instant;
  12 +import java.util.Date;
  13 +
  14 +/** REQ-USR-004 JWT 签发 / 校验封装。HS256,secret 来自 .env.local JWT_SECRET。 */
  15 +@Component
  16 +public class JwtTokenProvider {
  17 +
  18 + private final SecretKey key;
  19 + private final long expiresInSeconds;
  20 +
  21 + public JwtTokenProvider(@Value("${erp.jwt.secret}") String secret,
  22 + @Value("${erp.jwt.expires-in-seconds}") long expiresInSeconds) {
  23 + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
  24 + this.expiresInSeconds = expiresInSeconds;
  25 + }
  26 +
  27 + public long getExpiresInSeconds() {
  28 + return expiresInSeconds;
  29 + }
  30 +
  31 + public String sign(int uid, String username, String userType) {
  32 + Instant now = Instant.now();
  33 + return Jwts.builder()
  34 + .subject(username)
  35 + .claim("uid", uid)
  36 + .claim("type", userType)
  37 + .issuedAt(Date.from(now))
  38 + .expiration(Date.from(now.plusSeconds(expiresInSeconds)))
  39 + .signWith(key)
  40 + .compact();
  41 + }
  42 +
  43 + public Claims parse(String token) {
  44 + return Jwts.parser()
  45 + .verifyWith(key)
  46 + .build()
  47 + .parseSignedClaims(token)
  48 + .getPayload();
  49 + }
  50 +}
backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */
  4 +public interface LoginAttemptStore {
  5 +
  6 + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */
  7 + long cooldownSeconds(String username);
  8 +
  9 + /** 记录一次失败:count++;count==5 触发 15min 锁定 */
  10 + void recordFailure(String username);
  11 +
  12 + /** 登录成功清空记录 */
  13 + void clear(String username);
  14 +}
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.dto.LoginDTO;
  4 +import com.xly.erp.module.usr.vo.LoginResultVO;
  5 +
  6 +public interface LoginService {
  7 + /** REQ-USR-004 用户登录 */
  8 + LoginResultVO login(LoginDTO dto);
  9 +}
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.response.PageResult;
  4 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  5 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  6 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  7 +import com.xly.erp.module.usr.vo.UserListItemVO;
  8 +import com.xly.erp.module.usr.vo.UserVO;
  9 +
  10 +public interface UserService {
  11 + /** REQ-USR-001 用户新增 */
  12 + UserVO create(UserCreateDTO dto);
  13 +
  14 + /** REQ-USR-002 用户修改 */
  15 + UserVO update(Integer id, UserUpdateDTO dto);
  16 +
  17 + /** REQ-USR-003 用户列表查询 */
  18 + PageResult<UserListItemVO> search(UserQueryDTO query);
  19 +}
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  5 +import com.xly.erp.common.exception.AccountLockedException;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.module.usr.dto.LoginDTO;
  9 +import com.xly.erp.module.usr.entity.UserEntity;
  10 +import com.xly.erp.module.usr.mapper.UserMapper;
  11 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  12 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  13 +import com.xly.erp.module.usr.service.LoginService;
  14 +import com.xly.erp.module.usr.vo.LoginResultVO;
  15 +import lombok.RequiredArgsConstructor;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.security.crypto.password.PasswordEncoder;
  18 +import org.springframework.stereotype.Service;
  19 +import org.springframework.transaction.annotation.Transactional;
  20 +
  21 +import java.time.LocalDateTime;
  22 +
  23 +/** REQ-USR-004 用户登录 service */
  24 +@Slf4j
  25 +@Service
  26 +@RequiredArgsConstructor
  27 +public class LoginServiceImpl implements LoginService {
  28 +
  29 + private final UserMapper userMapper;
  30 + private final PasswordEncoder passwordEncoder;
  31 + private final LoginAttemptStore attemptStore;
  32 + private final JwtTokenProvider jwtTokenProvider;
  33 +
  34 + @Override
  35 + @Transactional(rollbackFor = Exception.class)
  36 + public LoginResultVO login(LoginDTO dto) {
  37 + String username = dto.getSUserName();
  38 +
  39 + // 1. 锁定检查
  40 + long cooldown = attemptStore.cooldownSeconds(username);
  41 + if (cooldown > 0L) {
  42 + log.info("Login locked username={} cooldown={}s", username, cooldown);
  43 + throw new AccountLockedException(cooldown);
  44 + }
  45 +
  46 + // 2. 查用户
  47 + UserEntity user = userMapper.selectOne(
  48 + new LambdaQueryWrapper<UserEntity>()
  49 + .eq(UserEntity::getSUserName, username)
  50 + .eq(UserEntity::getBDeleted, false));
  51 + if (user == null) {
  52 + log.info("Login user-not-found username={}", username);
  53 + attemptStore.recordFailure(username);
  54 + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁)
  55 + long cd = attemptStore.cooldownSeconds(username);
  56 + if (cd > 0L) {
  57 + throw new AccountLockedException(cd);
  58 + }
  59 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  60 + }
  61 +
  62 + // 3. BCrypt 校验
  63 + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) {
  64 + log.info("Login bad-password username={}", username);
  65 + attemptStore.recordFailure(username);
  66 + long cd = attemptStore.cooldownSeconds(username);
  67 + if (cd > 0L) {
  68 + throw new AccountLockedException(cd);
  69 + }
  70 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  71 + }
  72 +
  73 + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate
  74 + attemptStore.clear(username);
  75 + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType());
  76 + userMapper.update(null,
  77 + new LambdaUpdateWrapper<UserEntity>()
  78 + .eq(UserEntity::getIIncrement, user.getIIncrement())
  79 + .set(UserEntity::getTLastLoginDate, LocalDateTime.now()));
  80 + log.info("Login success username={} uid={}", username, user.getIIncrement());
  81 +
  82 + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo(
  83 + user.getIIncrement(), user.getSUserNo(), user.getSUserName(),
  84 + user.getSUserType(), user.getSLanguage());
  85 + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info);
  86 + }
  87 +}
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.common.response.PageResult;
  9 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  10 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  11 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  12 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  13 +import com.xly.erp.module.usr.entity.StaffEntity;
  14 +import com.xly.erp.module.usr.entity.UserEntity;
  15 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  16 +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
  17 +import com.xly.erp.module.usr.mapper.StaffMapper;
  18 +import com.xly.erp.module.usr.mapper.UserMapper;
  19 +import com.xly.erp.module.usr.mapper.UserPermissionMapper;
  20 +import com.xly.erp.module.usr.service.UserService;
  21 +import com.xly.erp.module.usr.vo.UserListItemVO;
  22 +import com.xly.erp.module.usr.vo.UserVO;
  23 +import lombok.RequiredArgsConstructor;
  24 +import org.springframework.dao.DuplicateKeyException;
  25 +import org.springframework.security.crypto.password.PasswordEncoder;
  26 +import org.springframework.stereotype.Service;
  27 +import org.springframework.transaction.annotation.Transactional;
  28 +
  29 +import java.time.LocalDateTime;
  30 +import java.util.ArrayList;
  31 +import java.util.List;
  32 +import java.util.Map;
  33 +import java.util.Set;
  34 +
  35 +/** REQ-USR-001 用户新增 */
  36 +@Service
  37 +@RequiredArgsConstructor
  38 +public class UserServiceImpl implements UserService {
  39 +
  40 + private static final String INITIAL_PASSWORD = "666666";
  41 +
  42 + private final UserMapper userMapper;
  43 + private final StaffMapper staffMapper;
  44 + private final PermissionCategoryMapper permissionCategoryMapper;
  45 + private final UserPermissionMapper userPermissionMapper;
  46 + private final PasswordEncoder passwordEncoder;
  47 +
  48 + @Override
  49 + @Transactional(rollbackFor = Exception.class)
  50 + public UserVO create(UserCreateDTO dto) {
  51 + // 1. 唯一性预检:sUserName / sUserNo(bDeleted=0 范围)
  52 + Long existsByName = userMapper.selectCount(
  53 + new LambdaQueryWrapper<UserEntity>()
  54 + .eq(UserEntity::getSUserName, dto.getSUserName())
  55 + .eq(UserEntity::getBDeleted, false));
  56 + if (existsByName != null && existsByName > 0L) {
  57 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  58 + }
  59 + Long existsByNo = userMapper.selectCount(
  60 + new LambdaQueryWrapper<UserEntity>()
  61 + .eq(UserEntity::getSUserNo, dto.getSUserNo())
  62 + .eq(UserEntity::getBDeleted, false));
  63 + if (existsByNo != null && existsByNo > 0L) {
  64 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  65 + }
  66 +
  67 + // 2. iStaffId 校验
  68 + if (dto.getIStaffId() != null) {
  69 + StaffEntity staff = staffMapper.selectById(dto.getIStaffId());
  70 + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) {
  71 + throw new BizException(ErrorCode.STAFF_NOT_FOUND);
  72 + }
  73 + }
  74 +
  75 + // 3. 权限分类校验:批量查;要求每个 id 都存在且未软删除
  76 + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null
  77 + ? new ArrayList<>() : dto.getPermissionCategoryIds();
  78 + if (!categoryIds.isEmpty()) {
  79 + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds);
  80 + if (found.size() != categoryIds.size()
  81 + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) {
  82 + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND);
  83 + }
  84 + }
  85 +
  86 + // 4. 构造 UserEntity 并 insert
  87 + UserEntity user = new UserEntity();
  88 + user.setSUserNo(dto.getSUserNo());
  89 + user.setSUserName(dto.getSUserName());
  90 + user.setIStaffId(dto.getIStaffId());
  91 + user.setSUserType(dto.getSUserType());
  92 + user.setSLanguage(dto.getSLanguage());
  93 + user.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : Boolean.FALSE);
  94 + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD));
  95 + user.setTCreateDate(LocalDateTime.now());
  96 + user.setBDeleted(Boolean.FALSE);
  97 + // tLastLoginDate / sCreatedBy / sBrandsId / sSubsidiaryId / sId / tDeletedDate / sDeletedBy 留 null
  98 +
  99 + try {
  100 + userMapper.insert(user);
  101 + } catch (DuplicateKeyException dup) {
  102 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  103 + }
  104 +
  105 + // 5. 批量 insert UserPermission
  106 + for (Integer categoryId : categoryIds) {
  107 + UserPermissionEntity up = new UserPermissionEntity();
  108 + up.setIUserId(user.getIIncrement());
  109 + up.setICategoryId(categoryId);
  110 + up.setTCreateDate(LocalDateTime.now());
  111 + // sCreatedBy 留 null(REQ-USR-004 后回填)
  112 + userPermissionMapper.insert(up);
  113 + }
  114 +
  115 + return UserVO.from(user, categoryIds);
  116 + }
  117 +
  118 + /** REQ-USR-002 用户修改 */
  119 + @Override
  120 + @Transactional(rollbackFor = Exception.class)
  121 + public UserVO update(Integer id, UserUpdateDTO dto) {
  122 + // 1. 目标用户存在 + 未软删除
  123 + UserEntity target = userMapper.selectById(id);
  124 + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) {
  125 + throw new BizException(ErrorCode.USR_NOT_FOUND);
  126 + }
  127 +
  128 + // 2. iStaffId 校验(仅当非空)
  129 + if (dto.getIStaffId() != null) {
  130 + StaffEntity staff = staffMapper.selectById(dto.getIStaffId());
  131 + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) {
  132 + throw new BizException(ErrorCode.STAFF_NOT_FOUND);
  133 + }
  134 + }
  135 +
  136 + // 3. 权限分类校验(仅当非空)
  137 + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null
  138 + ? new ArrayList<>() : dto.getPermissionCategoryIds();
  139 + if (!categoryIds.isEmpty()) {
  140 + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds);
  141 + if (found.size() != categoryIds.size()
  142 + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) {
  143 + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND);
  144 + }
  145 + }
  146 +
  147 + // 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash 等保护字段)
  148 + target.setIStaffId(dto.getIStaffId()); // 含 null 清空(依赖 iStaffId.IGNORED 策略)
  149 + target.setSUserType(dto.getSUserType());
  150 + target.setSLanguage(dto.getSLanguage());
  151 + if (dto.getBCanModifyDocs() != null) {
  152 + target.setBCanModifyDocs(dto.getBCanModifyDocs());
  153 + }
  154 +
  155 + // 5. 落库 user
  156 + userMapper.updateById(target);
  157 +
  158 + // 6. 重建权限关联:先删后插(清空原有,再按 dto 插入)
  159 + userPermissionMapper.delete(
  160 + new LambdaQueryWrapper<UserPermissionEntity>()
  161 + .eq(UserPermissionEntity::getIUserId, id));
  162 + for (Integer categoryId : categoryIds) {
  163 + UserPermissionEntity up = new UserPermissionEntity();
  164 + up.setIUserId(id);
  165 + up.setICategoryId(categoryId);
  166 + up.setTCreateDate(LocalDateTime.now());
  167 + userPermissionMapper.insert(up);
  168 + }
  169 +
  170 + return UserVO.from(target, categoryIds);
  171 + }
  172 +
  173 + /** REQ-USR-003 用户列表查询 — queryField 白名单映射 + LEFT JOIN tStaff 分页 */
  174 + private static final Map<String, String> QUERY_COLUMN_MAP = Map.ofEntries(
  175 + Map.entry("username", "u.sUserName"),
  176 + Map.entry("staffname", "s.sStaffName"),
  177 + Map.entry("userno", "u.sUserNo"),
  178 + Map.entry("department", "s.sDepartment"),
  179 + Map.entry("usertype", "u.sUserType"),
  180 + Map.entry("language", "u.sLanguage"),
  181 + Map.entry("deleted", "u.bDeleted"),
  182 + Map.entry("lastLoginDate", "u.tLastLoginDate"),
  183 + Map.entry("createdBy", "u.sCreatedBy"));
  184 +
  185 + private static final Set<String> MATCH_TYPES = Set.of("contains", "notContains", "equals");
  186 +
  187 + @Override
  188 + @Transactional(readOnly = true)
  189 + public PageResult<UserListItemVO> search(UserQueryDTO query) {
  190 + // 1. queryField 白名单 + 列映射(防 SQL 注入)。
  191 + // column 是 service 内部局部变量,通过 mapper @Param("column") 单独传入;不写回 DTO,
  192 + // 避免 GET query-string 绑定(@JsonIgnore 仅对 Jackson 生效,无法防 setter 注入)。
  193 + String column = null;
  194 + if (query.getQueryField() != null && !query.getQueryField().isEmpty()) {
  195 + column = QUERY_COLUMN_MAP.get(query.getQueryField());
  196 + if (column == null) {
  197 + throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField());
  198 + }
  199 + }
  200 +
  201 + // 2. matchType 白名单
  202 + if (query.getMatchType() != null && !query.getMatchType().isEmpty()
  203 + && !MATCH_TYPES.contains(query.getMatchType())) {
  204 + throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType());
  205 + }
  206 +
  207 + // 3. spec § 业务规则 6:deleted 字段值标准化('true'/'1' → '1';'false'/'0' → '0';其它非法)
  208 + if ("deleted".equals(query.getQueryField())
  209 + && query.getQueryValue() != null && !query.getQueryValue().isEmpty()) {
  210 + String v = query.getQueryValue().trim().toLowerCase();
  211 + if ("true".equals(v) || "1".equals(v)) {
  212 + query.setQueryValue("1");
  213 + } else if ("false".equals(v) || "0".equals(v)) {
  214 + query.setQueryValue("0");
  215 + } else {
  216 + throw new BizException(ErrorCode.PARAM_INVALID, "deleted queryValue 仅支持 true/false/1/0");
  217 + }
  218 + }
  219 +
  220 + // 4. 默认值兜底
  221 + int pageNum = query.getPageNum() == null ? 1 : query.getPageNum();
  222 + int pageSize = query.getPageSize() == null ? 20 : query.getPageSize();
  223 +
  224 + // 5. MP 分页查询
  225 + IPage<UserListItemVO> page = new Page<>(pageNum, pageSize);
  226 + IPage<UserListItemVO> result = userMapper.searchUsers(page, query, column);
  227 +
  228 + return PageResult.of(result);
  229 + }
  230 +}
backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +/** REQ-USR-004 登录结果 VO(含 JWT + 用户基本信息) */
  8 +@Data
  9 +@NoArgsConstructor
  10 +@AllArgsConstructor
  11 +public class LoginResultVO {
  12 + private String accessToken;
  13 + private long expiresIn;
  14 + private LoginUserInfo user;
  15 +
  16 + @Data
  17 + @NoArgsConstructor
  18 + @AllArgsConstructor
  19 + public static class LoginUserInfo {
  20 + private Integer iIncrement;
  21 + private String sUserNo;
  22 + private String sUserName;
  23 + private String sUserType;
  24 + private String sLanguage;
  25 + }
  26 +}
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.time.LocalDateTime;
  6 +
  7 +/** REQ-USR-003 用户列表行 VO(含 LEFT JOIN tStaff 出来的 sStaffName / sDepartment)。 */
  8 +@Data
  9 +public class UserListItemVO {
  10 + private Integer iIncrement;
  11 + private String sUserName;
  12 + private String sStaffName;
  13 + private String sUserNo;
  14 + private String sDepartment;
  15 + private String sUserType;
  16 + private String sLanguage;
  17 + private Boolean bDeleted;
  18 + private LocalDateTime tLastLoginDate;
  19 + private String sCreatedBy;
  20 + private LocalDateTime tCreateDate;
  21 +}
backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.xly.erp.module.usr.entity.UserEntity;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +import java.util.ArrayList;
  8 +import java.util.List;
  9 +
  10 +/** REQ-USR-001 用户 VO(不含 sPasswordHash 等内部字段)。 */
  11 +@Data
  12 +public class UserVO {
  13 + private Integer iIncrement;
  14 + private String sUserNo;
  15 + private String sUserName;
  16 + private Integer iStaffId;
  17 + private String sUserType;
  18 + private String sLanguage;
  19 + private Boolean bCanModifyDocs;
  20 + private LocalDateTime tCreateDate;
  21 + private Boolean bDeleted;
  22 + private List<Integer> permissionCategoryIds = new ArrayList<>();
  23 +
  24 + public static UserVO from(UserEntity e, List<Integer> permissionCategoryIds) {
  25 + UserVO v = new UserVO();
  26 + v.setIIncrement(e.getIIncrement());
  27 + v.setSUserNo(e.getSUserNo());
  28 + v.setSUserName(e.getSUserName());
  29 + v.setIStaffId(e.getIStaffId());
  30 + v.setSUserType(e.getSUserType());
  31 + v.setSLanguage(e.getSLanguage());
  32 + v.setBCanModifyDocs(e.getBCanModifyDocs());
  33 + v.setTCreateDate(e.getTCreateDate());
  34 + v.setBDeleted(e.getBDeleted());
  35 + v.setPermissionCategoryIds(permissionCategoryIds == null ? new ArrayList<>() : permissionCategoryIds);
  36 + return v;
  37 + }
  38 +}
backend/src/main/resources/mapper/usr/UserMapper.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  3 +<mapper namespace="com.xly.erp.module.usr.mapper.UserMapper">
  4 +
  5 + <!-- REQ-USR-003 用户列表查询:LEFT JOIN tStaff + 动态 WHERE。
  6 + column 由 service 层白名单映射后通过 @Param("column") 单独传入,
  7 + 绝不接受 DTO 中可被 GET query-string 绑定的字段。 -->
  8 + <select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
  9 + SELECT
  10 + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
  11 + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
  12 + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  13 + FROM tUser u
  14 + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
  15 + <where>
  16 + <!-- 默认过滤已软删除:仅当 queryField=='deleted' 且 queryValue 非空时让用户控制 bDeleted 取值 -->
  17 + <if test="query.queryField != 'deleted' or query.queryValue == null or query.queryValue == ''">
  18 + u.bDeleted = 0
  19 + </if>
  20 + <if test="column != null and column != '' and query.queryValue != null and query.queryValue != ''">
  21 + AND
  22 + <choose>
  23 + <!-- deleted 是 bit(1) 列,MySQL 与字符串 '1'/'0' 隐式比较不可靠;
  24 + service 已把 queryValue 标准化为 '0' / '1',此处显式 CAST 成整数。 -->
  25 + <when test="query.queryField == 'deleted'">
  26 + ${column} = CAST(#{query.queryValue} AS UNSIGNED)
  27 + </when>
  28 + <when test="query.matchType == 'equals'">
  29 + ${column} = #{query.queryValue}
  30 + </when>
  31 + <when test="query.matchType == 'notContains'">
  32 + ${column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')
  33 + </when>
  34 + <otherwise>
  35 + ${column} LIKE CONCAT('%', #{query.queryValue}, '%')
  36 + </otherwise>
  37 + </choose>
  38 + </if>
  39 + </where>
  40 + ORDER BY u.tCreateDate DESC, u.iIncrement DESC
  41 + </select>
  42 +</mapper>
backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
@@ -51,5 +51,11 @@ class ApiResponseTest { @@ -51,5 +51,11 @@ class ApiResponseTest {
51 assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421); 51 assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);
52 assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921); 52 assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);
53 assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912); 53 assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912);
  54 + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421);
  55 + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422);
  56 + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921);
  57 + assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);
  58 + assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);
  59 + assertThat(ErrorCode.LOGIN_ACCOUNT_LOCKED.getCode()).isEqualTo(40301);
54 } 60 }
55 } 61 }
backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.erp.module.usr.dto.LoginDTO;
  5 +import com.xly.erp.module.usr.entity.UserEntity;
  6 +import com.xly.erp.module.usr.mapper.UserMapper;
  7 +import com.xly.erp.module.usr.security.InMemoryLoginAttemptStore;
  8 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  9 +import io.jsonwebtoken.Claims;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.beans.factory.annotation.Autowired;
  13 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  14 +import org.springframework.boot.test.context.SpringBootTest;
  15 +import org.springframework.http.MediaType;
  16 +import org.springframework.security.crypto.password.PasswordEncoder;
  17 +import org.springframework.test.annotation.Rollback;
  18 +import org.springframework.test.context.ActiveProfiles;
  19 +import org.springframework.test.web.servlet.MockMvc;
  20 +import org.springframework.test.web.servlet.MvcResult;
  21 +import org.springframework.transaction.annotation.Transactional;
  22 +
  23 +import java.time.LocalDateTime;
  24 +
  25 +import static org.assertj.core.api.Assertions.assertThat;
  26 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  27 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  28 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  29 +
  30 +@SpringBootTest
  31 +@AutoConfigureMockMvc
  32 +@ActiveProfiles("test")
  33 +@Transactional
  34 +@Rollback
  35 +class LoginControllerIT {
  36 +
  37 + @Autowired MockMvc mockMvc;
  38 + @Autowired ObjectMapper objectMapper;
  39 + @Autowired UserMapper userMapper;
  40 + @Autowired PasswordEncoder passwordEncoder;
  41 + @Autowired InMemoryLoginAttemptStore attemptStore;
  42 + @Autowired JwtTokenProvider jwtTokenProvider;
  43 +
  44 + private String userName;
  45 +
  46 + @BeforeEach
  47 + void setUp() {
  48 + userName = "login_" + System.nanoTime();
  49 + // 每个测试在 store 里清干净(store 是 Spring singleton 跨测试存活)
  50 + attemptStore.clear(userName);
  51 + }
  52 +
  53 + private Integer insertUser(String pw) {
  54 + UserEntity u = new UserEntity();
  55 + u.setSUserNo("uno_" + System.nanoTime());
  56 + u.setSUserName(userName);
  57 + u.setSUserType("普通用户");
  58 + u.setSLanguage("zh");
  59 + u.setBCanModifyDocs(false);
  60 + u.setSPasswordHash(passwordEncoder.encode(pw));
  61 + u.setBDeleted(false);
  62 + u.setTCreateDate(LocalDateTime.now());
  63 + userMapper.insert(u);
  64 + return u.getIIncrement();
  65 + }
  66 +
  67 + private LoginDTO loginDto(String name, String pw) {
  68 + LoginDTO d = new LoginDTO();
  69 + d.setSUserName(name);
  70 + d.setSPassword(pw);
  71 + d.setSVersion("standard");
  72 + return d;
  73 + }
  74 +
  75 + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); }
  76 +
  77 + @Test
  78 + void login_validCredentials_returns200WithToken() throws Exception {
  79 + insertUser("666666");
  80 +
  81 + mockMvc.perform(post("/api/auth/login")
  82 + .contentType(MediaType.APPLICATION_JSON)
  83 + .content(json(loginDto(userName, "666666"))))
  84 + .andExpect(status().isOk())
  85 + .andExpect(jsonPath("$.code").value(200))
  86 + .andExpect(jsonPath("$.data.accessToken").isString())
  87 + .andExpect(jsonPath("$.data.expiresIn").value(7200))
  88 + .andExpect(jsonPath("$.data.user.sUserName").value(userName))
  89 + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户"));
  90 + }
  91 +
  92 + @Test
  93 + void login_jwtClaimsAreCorrect() throws Exception {
  94 + Integer userId = insertUser("666666");
  95 +
  96 + MvcResult result = mockMvc.perform(post("/api/auth/login")
  97 + .contentType(MediaType.APPLICATION_JSON)
  98 + .content(json(loginDto(userName, "666666"))))
  99 + .andExpect(status().isOk())
  100 + .andReturn();
  101 +
  102 + String body = result.getResponse().getContentAsString();
  103 + String token = objectMapper.readTree(body).path("data").path("accessToken").asText();
  104 + assertThat(token).isNotEmpty();
  105 +
  106 + Claims claims = jwtTokenProvider.parse(token);
  107 + assertThat(claims.getSubject()).isEqualTo(userName);
  108 + assertThat(claims.get("uid", Integer.class)).isEqualTo(userId);
  109 + assertThat(claims.get("type", String.class)).isEqualTo("普通用户");
  110 + }
  111 +
  112 + @Test
  113 + void login_invalidUsername_returns40101() throws Exception {
  114 + // 不插入用户
  115 + mockMvc.perform(post("/api/auth/login")
  116 + .contentType(MediaType.APPLICATION_JSON)
  117 + .content(json(loginDto("ghost_" + System.nanoTime(), "any"))))
  118 + .andExpect(status().isOk())
  119 + .andExpect(jsonPath("$.code").value(40101));
  120 + }
  121 +
  122 + @Test
  123 + void login_wrongPassword_returns40101() throws Exception {
  124 + insertUser("666666");
  125 +
  126 + mockMvc.perform(post("/api/auth/login")
  127 + .contentType(MediaType.APPLICATION_JSON)
  128 + .content(json(loginDto(userName, "wrong_password"))))
  129 + .andExpect(status().isOk())
  130 + .andExpect(jsonPath("$.code").value(40101));
  131 + }
  132 +
  133 + @Test
  134 + void login_softDeletedUser_returns40101() throws Exception {
  135 + Integer userId = insertUser("666666");
  136 + UserEntity patch = new UserEntity();
  137 + patch.setIIncrement(userId);
  138 + patch.setBDeleted(true);
  139 + userMapper.updateById(patch);
  140 +
  141 + mockMvc.perform(post("/api/auth/login")
  142 + .contentType(MediaType.APPLICATION_JSON)
  143 + .content(json(loginDto(userName, "666666"))))
  144 + .andExpect(status().isOk())
  145 + .andExpect(jsonPath("$.code").value(40101));
  146 + }
  147 +
  148 + @Test
  149 + void login_missingPassword_returns40010() throws Exception {
  150 + LoginDTO dto = loginDto(userName, "any");
  151 + dto.setSPassword(null);
  152 +
  153 + mockMvc.perform(post("/api/auth/login")
  154 + .contentType(MediaType.APPLICATION_JSON)
  155 + .content(json(dto)))
  156 + .andExpect(status().isOk())
  157 + .andExpect(jsonPath("$.code").value(40010));
  158 + }
  159 +
  160 + @Test
  161 + void login_invalidVersion_returns40010() throws Exception {
  162 + LoginDTO dto = loginDto(userName, "any");
  163 + dto.setSVersion("experimental");
  164 +
  165 + mockMvc.perform(post("/api/auth/login")
  166 + .contentType(MediaType.APPLICATION_JSON)
  167 + .content(json(dto)))
  168 + .andExpect(status().isOk())
  169 + .andExpect(jsonPath("$.code").value(40010));
  170 + }
  171 +
  172 + @Test
  173 + void login_5thFailureLocks_returns40301() throws Exception {
  174 + insertUser("666666");
  175 +
  176 + // 4 次错误密码(不锁定)
  177 + for (int i = 0; i < 4; i++) {
  178 + mockMvc.perform(post("/api/auth/login")
  179 + .contentType(MediaType.APPLICATION_JSON)
  180 + .content(json(loginDto(userName, "wrong_" + i))))
  181 + .andExpect(jsonPath("$.code").value(40101));
  182 + }
  183 +
  184 + // 第 5 次错误密码:触发锁定,返回 40301 + cooldownSeconds
  185 + mockMvc.perform(post("/api/auth/login")
  186 + .contentType(MediaType.APPLICATION_JSON)
  187 + .content(json(loginDto(userName, "wrong_5"))))
  188 + .andExpect(status().isOk())
  189 + .andExpect(jsonPath("$.code").value(40301))
  190 + .andExpect(jsonPath("$.data.cooldownSeconds").isNumber());
  191 +
  192 + // 锁定后正确密码也 40301
  193 + mockMvc.perform(post("/api/auth/login")
  194 + .contentType(MediaType.APPLICATION_JSON)
  195 + .content(json(loginDto(userName, "666666"))))
  196 + .andExpect(jsonPath("$.code").value(40301));
  197 + }
  198 +
  199 + @Test
  200 + void login_responseExcludesSPasswordHash() throws Exception {
  201 + insertUser("666666");
  202 +
  203 + mockMvc.perform(post("/api/auth/login")
  204 + .contentType(MediaType.APPLICATION_JSON)
  205 + .content(json(loginDto(userName, "666666"))))
  206 + .andExpect(status().isOk())
  207 + .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist());
  208 + }
  209 +
  210 + @Test
  211 + void login_afterLockExpiry_returns200() throws Exception {
  212 + insertUser("666666");
  213 +
  214 + // 5 次错误密码 → 锁定
  215 + for (int i = 0; i < 5; i++) {
  216 + mockMvc.perform(post("/api/auth/login")
  217 + .contentType(MediaType.APPLICATION_JSON)
  218 + .content(json(loginDto(userName, "wrong_" + i))))
  219 + .andReturn();
  220 + }
  221 +
  222 + // 验证当前确实锁定
  223 + mockMvc.perform(post("/api/auth/login")
  224 + .contentType(MediaType.APPLICATION_JSON)
  225 + .content(json(loginDto(userName, "666666"))))
  226 + .andExpect(jsonPath("$.code").value(40301));
  227 +
  228 + // 把 lockUntil 拨到过去模拟锁定到期
  229 + attemptStore.expireLockForTest(userName);
  230 +
  231 + // 锁定到期 + 正确密码 → 200
  232 + mockMvc.perform(post("/api/auth/login")
  233 + .contentType(MediaType.APPLICATION_JSON)
  234 + .content(json(loginDto(userName, "666666"))))
  235 + .andExpect(status().isOk())
  236 + .andExpect(jsonPath("$.code").value(200))
  237 + .andExpect(jsonPath("$.data.accessToken").isString());
  238 + }
  239 +}
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  5 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  6 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  7 +import com.xly.erp.module.usr.entity.StaffEntity;
  8 +import com.xly.erp.module.usr.entity.UserEntity;
  9 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  10 +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
  11 +import com.xly.erp.module.usr.mapper.StaffMapper;
  12 +import com.xly.erp.module.usr.mapper.UserMapper;
  13 +import com.xly.erp.module.usr.mapper.UserPermissionMapper;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.springframework.beans.factory.annotation.Autowired;
  16 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  17 +import org.springframework.boot.test.context.SpringBootTest;
  18 +import org.springframework.http.MediaType;
  19 +import org.springframework.test.annotation.Rollback;
  20 +import org.springframework.test.context.ActiveProfiles;
  21 +import org.springframework.test.web.servlet.MockMvc;
  22 +import org.springframework.transaction.annotation.Transactional;
  23 +
  24 +import java.time.LocalDateTime;
  25 +import java.util.List;
  26 +
  27 +import static com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery;
  28 +import static org.assertj.core.api.Assertions.assertThat;
  29 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  30 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  31 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
  32 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  33 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  34 +
  35 +@SpringBootTest
  36 +@AutoConfigureMockMvc
  37 +@ActiveProfiles("test")
  38 +@Transactional
  39 +@Rollback
  40 +class UserControllerIT {
  41 +
  42 + @Autowired MockMvc mockMvc;
  43 + @Autowired ObjectMapper objectMapper;
  44 + @Autowired UserMapper userMapper;
  45 + @Autowired StaffMapper staffMapper;
  46 + @Autowired PermissionCategoryMapper permissionCategoryMapper;
  47 + @Autowired UserPermissionMapper userPermissionMapper;
  48 +
  49 + private UserCreateDTO baseDto(String userName) {
  50 + UserCreateDTO d = new UserCreateDTO();
  51 + d.setSUserNo("uno_" + System.nanoTime());
  52 + d.setSUserName(userName);
  53 + d.setSUserType("普通用户");
  54 + d.setSLanguage("zh");
  55 + return d;
  56 + }
  57 +
  58 + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); }
  59 +
  60 + private Integer insertStaff() {
  61 + StaffEntity s = new StaffEntity();
  62 + s.setSStaffNo("st_" + System.nanoTime());
  63 + s.setSStaffName("员工A");
  64 + s.setBDeleted(false);
  65 + s.setTCreateDate(LocalDateTime.now());
  66 + staffMapper.insert(s);
  67 + return s.getIIncrement();
  68 + }
  69 +
  70 + private Integer insertCategory() {
  71 + PermissionCategoryEntity p = new PermissionCategoryEntity();
  72 + p.setSCategoryCode("c_" + System.nanoTime());
  73 + p.setSCategoryName("分类");
  74 + p.setISortOrder(0);
  75 + p.setBDeleted(false);
  76 + p.setTCreateDate(LocalDateTime.now());
  77 + permissionCategoryMapper.insert(p);
  78 + return p.getIIncrement();
  79 + }
  80 +
  81 + @Test
  82 + void post_minimalFields_returns200() throws Exception {
  83 + UserCreateDTO dto = baseDto("alice_" + System.nanoTime());
  84 +
  85 + mockMvc.perform(post("/api/users")
  86 + .contentType(MediaType.APPLICATION_JSON)
  87 + .content(json(dto)))
  88 + .andExpect(status().isOk())
  89 + .andExpect(jsonPath("$.code").value(200))
  90 + .andExpect(jsonPath("$.data.iIncrement").isNumber())
  91 + .andExpect(jsonPath("$.data.sUserName").value(dto.getSUserName()))
  92 + .andExpect(jsonPath("$.data.bCanModifyDocs").value(false))
  93 + .andExpect(jsonPath("$.data.permissionCategoryIds").isArray())
  94 + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0));
  95 + }
  96 +
  97 + @Test
  98 + void post_withStaffAndPermissions_returns200_andDbAssociated() throws Exception {
  99 + Integer staffId = insertStaff();
  100 + Integer cat1 = insertCategory();
  101 + Integer cat2 = insertCategory();
  102 + Integer cat3 = insertCategory();
  103 +
  104 + UserCreateDTO dto = baseDto("bob_" + System.nanoTime());
  105 + dto.setIStaffId(staffId);
  106 + dto.setPermissionCategoryIds(List.of(cat1, cat2, cat3));
  107 +
  108 + mockMvc.perform(post("/api/users")
  109 + .contentType(MediaType.APPLICATION_JSON)
  110 + .content(json(dto)))
  111 + .andExpect(status().isOk())
  112 + .andExpect(jsonPath("$.code").value(200))
  113 + .andExpect(jsonPath("$.data.iStaffId").value(staffId));
  114 +
  115 + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class)
  116 + .eq(UserEntity::getSUserName, dto.getSUserName()));
  117 + assertThat(u).isNotNull();
  118 + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class)
  119 + .eq(UserPermissionEntity::getIUserId, u.getIIncrement()));
  120 + assertThat(upCount).isEqualTo(3L);
  121 + }
  122 +
  123 + @Test
  124 + void post_duplicateUserName_returns40921() throws Exception {
  125 + String userName = "dup_" + System.nanoTime();
  126 + UserCreateDTO first = baseDto(userName);
  127 + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(first)))
  128 + .andExpect(status().isOk());
  129 +
  130 + UserCreateDTO second = baseDto(userName);
  131 + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(second)))
  132 + .andExpect(status().isOk())
  133 + .andExpect(jsonPath("$.code").value(40921));
  134 + }
  135 +
  136 + @Test
  137 + void post_staffNotFound_returns40421() throws Exception {
  138 + UserCreateDTO dto = baseDto("noStaff_" + System.nanoTime());
  139 + dto.setIStaffId(999999);
  140 + mockMvc.perform(post("/api/users")
  141 + .contentType(MediaType.APPLICATION_JSON)
  142 + .content(json(dto)))
  143 + .andExpect(status().isOk())
  144 + .andExpect(jsonPath("$.code").value(40421));
  145 + }
  146 +
  147 + @Test
  148 + void post_permissionCategoryNotFound_returns40422() throws Exception {
  149 + UserCreateDTO dto = baseDto("noCat_" + System.nanoTime());
  150 + dto.setPermissionCategoryIds(List.of(999999));
  151 + mockMvc.perform(post("/api/users")
  152 + .contentType(MediaType.APPLICATION_JSON)
  153 + .content(json(dto)))
  154 + .andExpect(status().isOk())
  155 + .andExpect(jsonPath("$.code").value(40422));
  156 + }
  157 +
  158 + @Test
  159 + void post_passwordHashedInDb_notPlaintext() throws Exception {
  160 + UserCreateDTO dto = baseDto("pw_" + System.nanoTime());
  161 +
  162 + mockMvc.perform(post("/api/users")
  163 + .contentType(MediaType.APPLICATION_JSON)
  164 + .content(json(dto)))
  165 + .andExpect(status().isOk());
  166 +
  167 + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class)
  168 + .eq(UserEntity::getSUserName, dto.getSUserName()));
  169 + assertThat(u.getSPasswordHash())
  170 + .satisfiesAnyOf(
  171 + h -> assertThat(h).startsWith("$2a$"),
  172 + h -> assertThat(h).startsWith("$2b$"),
  173 + h -> assertThat(h).startsWith("$2y$"))
  174 + .doesNotContain("666666");
  175 + }
  176 +
  177 + @Test
  178 + void post_responseExcludesSPasswordHash() throws Exception {
  179 + UserCreateDTO dto = baseDto("priv_" + System.nanoTime());
  180 +
  181 + mockMvc.perform(post("/api/users")
  182 + .contentType(MediaType.APPLICATION_JSON)
  183 + .content(json(dto)))
  184 + .andExpect(status().isOk())
  185 + .andExpect(jsonPath("$.data.sPasswordHash").doesNotExist());
  186 + }
  187 +
  188 + // ============================================================
  189 + // REQ-USR-002 PUT 系列
  190 + // ============================================================
  191 +
  192 + private Integer insertUser(String userName, Integer staffId, List<Integer> categoryIds) {
  193 + UserEntity u = new UserEntity();
  194 + u.setSUserNo("uno_" + System.nanoTime());
  195 + u.setSUserName(userName);
  196 + u.setIStaffId(staffId);
  197 + u.setSUserType("普通用户");
  198 + u.setSLanguage("zh");
  199 + u.setBCanModifyDocs(false);
  200 + u.setSPasswordHash("$2a$10$origUser");
  201 + u.setBDeleted(false);
  202 + u.setTCreateDate(LocalDateTime.now());
  203 + userMapper.insert(u);
  204 + for (Integer cid : categoryIds) {
  205 + UserPermissionEntity up = new UserPermissionEntity();
  206 + up.setIUserId(u.getIIncrement());
  207 + up.setICategoryId(cid);
  208 + up.setTCreateDate(LocalDateTime.now());
  209 + userPermissionMapper.insert(up);
  210 + }
  211 + return u.getIIncrement();
  212 + }
  213 +
  214 + private UserUpdateDTO updateDto(Integer staffId, List<Integer> permissionIds) {
  215 + UserUpdateDTO d = new UserUpdateDTO();
  216 + d.setIStaffId(staffId);
  217 + d.setSUserType("超级管理员");
  218 + d.setSLanguage("en");
  219 + d.setBCanModifyDocs(true);
  220 + d.setPermissionCategoryIds(permissionIds);
  221 + return d;
  222 + }
  223 +
  224 + @Test
  225 + void put_validUpdate_returns200_andDbReflects() throws Exception {
  226 + Integer staff1 = insertStaff();
  227 + Integer staff2 = insertStaff();
  228 + Integer cat1 = insertCategory();
  229 + Integer cat2 = insertCategory();
  230 + Integer cat3 = insertCategory();
  231 + Integer userId = insertUser("upd_" + System.nanoTime(), staff1, List.of(cat1, cat2, cat3));
  232 +
  233 + Integer catNew1 = insertCategory();
  234 + Integer catNew2 = insertCategory();
  235 +
  236 + UserUpdateDTO dto = updateDto(staff2, List.of(catNew1, catNew2));
  237 +
  238 + mockMvc.perform(put("/api/users/" + userId)
  239 + .contentType(MediaType.APPLICATION_JSON)
  240 + .content(json(dto)))
  241 + .andExpect(status().isOk())
  242 + .andExpect(jsonPath("$.code").value(200))
  243 + .andExpect(jsonPath("$.data.iStaffId").value(staff2))
  244 + .andExpect(jsonPath("$.data.sUserType").value("超级管理员"))
  245 + .andExpect(jsonPath("$.data.sLanguage").value("en"));
  246 +
  247 + UserEntity reloaded = userMapper.selectById(userId);
  248 + assertThat(reloaded.getIStaffId()).isEqualTo(staff2);
  249 + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员");
  250 + assertThat(reloaded.getBCanModifyDocs()).isTrue();
  251 + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class)
  252 + .eq(UserPermissionEntity::getIUserId, userId));
  253 + assertThat(upCount).isEqualTo(2L); // 原 3 删 + 新 2 插
  254 + }
  255 +
  256 + @Test
  257 + void put_clearStaffId_setsNull() throws Exception {
  258 + Integer staffId = insertStaff();
  259 + Integer userId = insertUser("clr_" + System.nanoTime(), staffId, List.of());
  260 +
  261 + UserUpdateDTO dto = updateDto(null, List.of());
  262 +
  263 + mockMvc.perform(put("/api/users/" + userId)
  264 + .contentType(MediaType.APPLICATION_JSON)
  265 + .content(json(dto)))
  266 + .andExpect(status().isOk())
  267 + .andExpect(jsonPath("$.code").value(200));
  268 +
  269 + assertThat(userMapper.selectById(userId).getIStaffId()).isNull();
  270 + }
  271 +
  272 + @Test
  273 + void put_emptyPermissionCategoryIds_clearsAssociations() throws Exception {
  274 + Integer cat1 = insertCategory();
  275 + Integer cat2 = insertCategory();
  276 + Integer userId = insertUser("emp_" + System.nanoTime(), null, List.of(cat1, cat2));
  277 +
  278 + UserUpdateDTO dto = updateDto(null, List.of());
  279 +
  280 + mockMvc.perform(put("/api/users/" + userId)
  281 + .contentType(MediaType.APPLICATION_JSON)
  282 + .content(json(dto)))
  283 + .andExpect(status().isOk())
  284 + .andExpect(jsonPath("$.code").value(200));
  285 +
  286 + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class)
  287 + .eq(UserPermissionEntity::getIUserId, userId));
  288 + assertThat(upCount).isZero();
  289 + }
  290 +
  291 + @Test
  292 + void put_targetNotFound_returns40431() throws Exception {
  293 + UserUpdateDTO dto = updateDto(null, List.of());
  294 + mockMvc.perform(put("/api/users/999999")
  295 + .contentType(MediaType.APPLICATION_JSON)
  296 + .content(json(dto)))
  297 + .andExpect(status().isOk())
  298 + .andExpect(jsonPath("$.code").value(40431));
  299 + }
  300 +
  301 + @Test
  302 + void put_staffNotFound_returns40421() throws Exception {
  303 + Integer userId = insertUser("nost_" + System.nanoTime(), null, List.of());
  304 + UserUpdateDTO dto = updateDto(999999, List.of());
  305 + mockMvc.perform(put("/api/users/" + userId)
  306 + .contentType(MediaType.APPLICATION_JSON)
  307 + .content(json(dto)))
  308 + .andExpect(status().isOk())
  309 + .andExpect(jsonPath("$.code").value(40421));
  310 + }
  311 +
  312 + @Test
  313 + void put_permissionCategoryNotFound_returns40422() throws Exception {
  314 + Integer userId = insertUser("noc_" + System.nanoTime(), null, List.of());
  315 + UserUpdateDTO dto = updateDto(null, List.of(999999));
  316 + mockMvc.perform(put("/api/users/" + userId)
  317 + .contentType(MediaType.APPLICATION_JSON)
  318 + .content(json(dto)))
  319 + .andExpect(status().isOk())
  320 + .andExpect(jsonPath("$.code").value(40422));
  321 + }
  322 +
  323 + @Test
  324 + void put_missingRequired_returns40010() throws Exception {
  325 + Integer userId = insertUser("miss_" + System.nanoTime(), null, List.of());
  326 + UserUpdateDTO dto = updateDto(null, List.of());
  327 + dto.setSUserType(null); // 必填缺失
  328 + mockMvc.perform(put("/api/users/" + userId)
  329 + .contentType(MediaType.APPLICATION_JSON)
  330 + .content(json(dto)))
  331 + .andExpect(status().isOk())
  332 + .andExpect(jsonPath("$.code").value(40010));
  333 + }
  334 +
  335 + @Test
  336 + void put_ignoresProtectedFields_doesNotChangeUserNoOrName() throws Exception {
  337 + String origName = "prot_" + System.nanoTime();
  338 + Integer userId = insertUser(origName, null, List.of());
  339 + String origNo = userMapper.selectById(userId).getSUserNo();
  340 + String origHash = userMapper.selectById(userId).getSPasswordHash();
  341 +
  342 + // 手工拼 body 含保护字段
  343 + String body = """
  344 + {
  345 + "sUserNo": "hijack",
  346 + "sUserName": "hijack",
  347 + "sPasswordHash": "$2a$10$hijacked",
  348 + "iStaffId": null,
  349 + "sUserType": "超级管理员",
  350 + "sLanguage": "zh-TW",
  351 + "bCanModifyDocs": true,
  352 + "permissionCategoryIds": []
  353 + }
  354 + """;
  355 + mockMvc.perform(put("/api/users/" + userId)
  356 + .contentType(MediaType.APPLICATION_JSON)
  357 + .content(body))
  358 + .andExpect(status().isOk())
  359 + .andExpect(jsonPath("$.code").value(200));
  360 +
  361 + UserEntity reloaded = userMapper.selectById(userId);
  362 + assertThat(reloaded.getSUserNo()).isEqualTo(origNo);
  363 + assertThat(reloaded.getSUserName()).isEqualTo(origName);
  364 + assertThat(reloaded.getSPasswordHash()).isEqualTo(origHash);
  365 + // 但其他字段已修改
  366 + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员");
  367 + assertThat(reloaded.getSLanguage()).isEqualTo("zh-TW");
  368 + }
  369 +
  370 + // ============================================================
  371 + // REQ-USR-003 GET 系列
  372 + // ============================================================
  373 +
  374 + @Test
  375 + void get_emptyKeyword_returnsAllUndeleted() throws Exception {
  376 + Integer staffId = insertStaff();
  377 + insertUser("getall_a_" + System.nanoTime(), staffId, List.of());
  378 + insertUser("getall_b_" + System.nanoTime(), null, List.of());
  379 +
  380 + mockMvc.perform(get("/api/users"))
  381 + .andExpect(status().isOk())
  382 + .andExpect(jsonPath("$.code").value(200))
  383 + .andExpect(jsonPath("$.data.list").isArray())
  384 + .andExpect(jsonPath("$.data.total").isNumber());
  385 + }
  386 +
  387 + @Test
  388 + void get_filterByUsernameContains_returnsMatchedSubset() throws Exception {
  389 + String alicePrefix = "filt_ali_" + System.nanoTime();
  390 + insertUser(alicePrefix + "_alice", null, List.of());
  391 + insertUser("filt_bob_" + System.nanoTime(), null, List.of());
  392 +
  393 + mockMvc.perform(get("/api/users")
  394 + .param("queryField", "username")
  395 + .param("matchType", "contains")
  396 + .param("queryValue", alicePrefix))
  397 + .andExpect(status().isOk())
  398 + .andExpect(jsonPath("$.code").value(200))
  399 + .andExpect(jsonPath("$.data.total").value(1))
  400 + .andExpect(jsonPath("$.data.list[0].sUserName", org.hamcrest.Matchers.startsWith(alicePrefix)));
  401 + }
  402 +
  403 + @Test
  404 + void get_filterByStaffnameContains_returnsJoinedResults() throws Exception {
  405 + // staff with unique sStaffName, then user referencing it
  406 + StaffEntity s = new StaffEntity();
  407 + String staffName = "joined_staff_" + System.nanoTime();
  408 + s.setSStaffNo("st_" + System.nanoTime());
  409 + s.setSStaffName(staffName);
  410 + s.setSDepartment("研发部");
  411 + s.setBDeleted(false);
  412 + s.setTCreateDate(LocalDateTime.now());
  413 + staffMapper.insert(s);
  414 + insertUser("for_staff_" + System.nanoTime(), s.getIIncrement(), List.of());
  415 +
  416 + mockMvc.perform(get("/api/users")
  417 + .param("queryField", "staffname")
  418 + .param("matchType", "contains")
  419 + .param("queryValue", staffName))
  420 + .andExpect(status().isOk())
  421 + .andExpect(jsonPath("$.data.total").value(1))
  422 + .andExpect(jsonPath("$.data.list[0].sStaffName").value(staffName));
  423 + }
  424 +
  425 + @Test
  426 + void get_filterByDeletedTrue_returnsOnlyDeleted() throws Exception {
  427 + // 插一个用户后软删
  428 + Integer userId = insertUser("del_" + System.nanoTime(), null, List.of());
  429 + UserEntity patch = new UserEntity();
  430 + patch.setIIncrement(userId);
  431 + patch.setBDeleted(true);
  432 + userMapper.updateById(patch);
  433 +
  434 + mockMvc.perform(get("/api/users")
  435 + .param("queryField", "deleted")
  436 + .param("matchType", "equals")
  437 + .param("queryValue", "true"))
  438 + .andExpect(status().isOk())
  439 + .andExpect(jsonPath("$.code").value(200))
  440 + .andExpect(jsonPath("$.data.list[?(@.iIncrement==" + userId + ")]").exists());
  441 + }
  442 +
  443 + @Test
  444 + void get_pagination_returnsCorrectSlice() throws Exception {
  445 + for (int i = 0; i < 3; i++) {
  446 + insertUser("page_" + i + "_" + System.nanoTime(), null, List.of());
  447 + }
  448 +
  449 + mockMvc.perform(get("/api/users")
  450 + .param("pageNum", "1")
  451 + .param("pageSize", "2"))
  452 + .andExpect(status().isOk())
  453 + .andExpect(jsonPath("$.data.pageNum").value(1))
  454 + .andExpect(jsonPath("$.data.pageSize").value(2))
  455 + .andExpect(jsonPath("$.data.list.length()").value(2));
  456 + }
  457 +
  458 + @Test
  459 + void get_responseExcludesInternalFields() throws Exception {
  460 + insertUser("priv_" + System.nanoTime(), null, List.of());
  461 +
  462 + mockMvc.perform(get("/api/users").param("pageSize", "5"))
  463 + .andExpect(status().isOk())
  464 + .andExpect(jsonPath("$.data.list[0].sPasswordHash").doesNotExist())
  465 + .andExpect(jsonPath("$.data.list[0].sId").doesNotExist())
  466 + .andExpect(jsonPath("$.data.list[0].iStaffId").doesNotExist())
  467 + .andExpect(jsonPath("$.data.list[0].sBrandsId").doesNotExist());
  468 + }
  469 +
  470 + @Test
  471 + void get_pageSizeTooLarge_returns40010() throws Exception {
  472 + mockMvc.perform(get("/api/users").param("pageSize", "101"))
  473 + .andExpect(status().isOk())
  474 + .andExpect(jsonPath("$.code").value(40010));
  475 + }
  476 +
  477 + @Test
  478 + void get_invalidQueryField_returns40010() throws Exception {
  479 + mockMvc.perform(get("/api/users").param("queryField", "invalid_xyz"))
  480 + .andExpect(status().isOk())
  481 + .andExpect(jsonPath("$.code").value(40010));
  482 + }
  483 +
  484 + @Test
  485 + void get_userWithoutStaff_listItemHasNullStaffFields() throws Exception {
  486 + String userName = "nostaff_" + System.nanoTime();
  487 + insertUser(userName, null, List.of());
  488 +
  489 + mockMvc.perform(get("/api/users")
  490 + .param("queryField", "username")
  491 + .param("matchType", "equals")
  492 + .param("queryValue", userName))
  493 + .andExpect(status().isOk())
  494 + .andExpect(jsonPath("$.data.list[0].sUserName").value(userName))
  495 + .andExpect(jsonPath("$.data.list[0].sStaffName").doesNotExist())
  496 + .andExpect(jsonPath("$.data.list[0].sDepartment").doesNotExist());
  497 + }
  498 +}
backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import jakarta.validation.ValidatorFactory;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.Set;
  10 +
  11 +import static org.assertj.core.api.Assertions.assertThat;
  12 +
  13 +class LoginDTOValidationTest {
  14 +
  15 + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
  16 + private final Validator validator = FACTORY.getValidator();
  17 +
  18 + private LoginDTO valid() {
  19 + LoginDTO d = new LoginDTO();
  20 + d.setSUserName("alice");
  21 + d.setSPassword("666666");
  22 + d.setSVersion("standard");
  23 + return d;
  24 + }
  25 +
  26 + @Test
  27 + void allValid_yieldsNoViolations() {
  28 + Set<ConstraintViolation<LoginDTO>> v = validator.validate(valid());
  29 + assertThat(v).isEmpty();
  30 + }
  31 +
  32 + @Test
  33 + void blankRequiredFields_yieldsViolations() {
  34 + LoginDTO d = new LoginDTO();
  35 + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d);
  36 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
  37 + .contains("sUserName", "sPassword", "sVersion");
  38 + }
  39 +
  40 + @Test
  41 + void invalidVersion_yieldsViolation() {
  42 + LoginDTO d = valid();
  43 + d.setSVersion("experimental");
  44 + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d);
  45 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sVersion");
  46 + }
  47 +
  48 + @Test
  49 + void overSized_yieldsViolation() {
  50 + LoginDTO d = valid();
  51 + d.setSUserName("a".repeat(51));
  52 + d.setSPassword("p".repeat(101));
  53 + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d);
  54 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
  55 + .contains("sUserName", "sPassword");
  56 + }
  57 +}
backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import jakarta.validation.ValidatorFactory;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.List;
  10 +import java.util.Set;
  11 +
  12 +import static org.assertj.core.api.Assertions.assertThat;
  13 +
  14 +class UserCreateDTOValidationTest {
  15 +
  16 + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
  17 + private final Validator validator = FACTORY.getValidator();
  18 +
  19 + private UserCreateDTO valid() {
  20 + UserCreateDTO d = new UserCreateDTO();
  21 + d.setSUserNo("u001");
  22 + d.setSUserName("alice");
  23 + d.setSUserType("普通用户");
  24 + d.setSLanguage("zh");
  25 + d.setBCanModifyDocs(false);
  26 + d.setPermissionCategoryIds(List.of());
  27 + return d;
  28 + }
  29 +
  30 + @Test
  31 + void allValidFields_yieldsNoViolations() {
  32 + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(valid());
  33 + assertThat(v).isEmpty();
  34 + }
  35 +
  36 + @Test
  37 + void blankRequiredFields_yieldsViolations() {
  38 + UserCreateDTO d = new UserCreateDTO();
  39 + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d);
  40 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
  41 + .contains("sUserNo", "sUserName", "sUserType", "sLanguage");
  42 + }
  43 +
  44 + @Test
  45 + void invalidUserTypeEnum_yieldsViolation() {
  46 + UserCreateDTO d = valid();
  47 + d.setSUserType("非法值");
  48 + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d);
  49 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType");
  50 + }
  51 +
  52 + @Test
  53 + void invalidLanguageEnum_yieldsViolation() {
  54 + UserCreateDTO d = valid();
  55 + d.setSLanguage("fr");
  56 + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d);
  57 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage");
  58 + }
  59 +
  60 + @Test
  61 + void overSizedFields_yieldsViolations() {
  62 + UserCreateDTO d = valid();
  63 + d.setSUserNo("a".repeat(51));
  64 + d.setSUserName("a".repeat(51));
  65 + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d);
  66 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
  67 + .contains("sUserNo", "sUserName");
  68 + }
  69 +}
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import jakarta.validation.ValidatorFactory;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.Set;
  10 +
  11 +import static org.assertj.core.api.Assertions.assertThat;
  12 +
  13 +class UserQueryDTOValidationTest {
  14 +
  15 + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
  16 + private final Validator validator = FACTORY.getValidator();
  17 +
  18 + private UserQueryDTO valid() {
  19 + UserQueryDTO d = new UserQueryDTO();
  20 + d.setPageNum(1);
  21 + d.setPageSize(20);
  22 + d.setQueryField("username");
  23 + d.setMatchType("contains");
  24 + d.setQueryValue("alice");
  25 + return d;
  26 + }
  27 +
  28 + @Test
  29 + void allValid_yieldsNoViolations() {
  30 + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(valid());
  31 + assertThat(v).isEmpty();
  32 + }
  33 +
  34 + @Test
  35 + void pageSizeTooLarge_yieldsViolation() {
  36 + UserQueryDTO d = valid();
  37 + d.setPageSize(101);
  38 + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d);
  39 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize");
  40 + }
  41 +
  42 + @Test
  43 + void pageSizeTooSmall_yieldsViolation() {
  44 + UserQueryDTO d = valid();
  45 + d.setPageSize(0);
  46 + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d);
  47 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize");
  48 + }
  49 +
  50 + @Test
  51 + void queryFieldInvalidEnum_yieldsViolation() {
  52 + UserQueryDTO d = valid();
  53 + d.setQueryField("invalid_field");
  54 + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d);
  55 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryField");
  56 + }
  57 +
  58 + @Test
  59 + void queryValueOverSized_yieldsViolation() {
  60 + UserQueryDTO d = valid();
  61 + d.setQueryValue("a".repeat(101));
  62 + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d);
  63 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryValue");
  64 + }
  65 +}
backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import jakarta.validation.ValidatorFactory;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.List;
  10 +import java.util.Set;
  11 +
  12 +import static org.assertj.core.api.Assertions.assertThat;
  13 +
  14 +class UserUpdateDTOValidationTest {
  15 +
  16 + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory();
  17 + private final Validator validator = FACTORY.getValidator();
  18 +
  19 + private UserUpdateDTO valid() {
  20 + UserUpdateDTO d = new UserUpdateDTO();
  21 + d.setSUserType("超级管理员");
  22 + d.setSLanguage("en");
  23 + d.setBCanModifyDocs(true);
  24 + d.setIStaffId(7);
  25 + d.setPermissionCategoryIds(List.of(1, 2));
  26 + return d;
  27 + }
  28 +
  29 + @Test
  30 + void allValidFields_yieldsNoViolations() {
  31 + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(valid());
  32 + assertThat(v).isEmpty();
  33 + }
  34 +
  35 + @Test
  36 + void blankRequiredFields_yieldsViolations() {
  37 + UserUpdateDTO d = new UserUpdateDTO();
  38 + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d);
  39 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString())
  40 + .contains("sUserType", "sLanguage");
  41 + }
  42 +
  43 + @Test
  44 + void invalidUserTypeEnum_yieldsViolation() {
  45 + UserUpdateDTO d = valid();
  46 + d.setSUserType("非法值");
  47 + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d);
  48 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType");
  49 + }
  50 +
  51 + @Test
  52 + void invalidLanguageEnum_yieldsViolation() {
  53 + UserUpdateDTO d = valid();
  54 + d.setSLanguage("fr");
  55 + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d);
  56 + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage");
  57 + }
  58 +}
backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
  4 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  5 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  6 +import com.xly.erp.module.usr.entity.StaffEntity;
  7 +import com.xly.erp.module.usr.entity.UserEntity;
  8 +import com.xly.erp.module.usr.vo.UserListItemVO;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.springframework.beans.factory.annotation.Autowired;
  11 +import org.springframework.boot.test.context.SpringBootTest;
  12 +import org.springframework.test.annotation.Rollback;
  13 +import org.springframework.test.context.ActiveProfiles;
  14 +import org.springframework.transaction.annotation.Transactional;
  15 +
  16 +import java.time.LocalDateTime;
  17 +
  18 +import static org.assertj.core.api.Assertions.assertThat;
  19 +
  20 +@SpringBootTest
  21 +@ActiveProfiles("test")
  22 +@Transactional
  23 +@Rollback
  24 +class UserMapperSearchIT {
  25 +
  26 + @Autowired UserMapper userMapper;
  27 + @Autowired StaffMapper staffMapper;
  28 +
  29 + private Integer insertStaff(String name) {
  30 + StaffEntity s = new StaffEntity();
  31 + s.setSStaffNo("st_" + System.nanoTime());
  32 + s.setSStaffName(name);
  33 + s.setSDepartment("研发部");
  34 + s.setBDeleted(false);
  35 + s.setTCreateDate(LocalDateTime.now());
  36 + staffMapper.insert(s);
  37 + return s.getIIncrement();
  38 + }
  39 +
  40 + private Integer insertUser(String userName, Integer staffId) {
  41 + UserEntity u = new UserEntity();
  42 + u.setSUserNo("uno_" + System.nanoTime());
  43 + u.setSUserName(userName);
  44 + u.setIStaffId(staffId);
  45 + u.setSUserType("普通用户");
  46 + u.setSLanguage("zh");
  47 + u.setBCanModifyDocs(false);
  48 + u.setSPasswordHash("$2a$10$x");
  49 + u.setBDeleted(false);
  50 + u.setTCreateDate(LocalDateTime.now());
  51 + userMapper.insert(u);
  52 + return u.getIIncrement();
  53 + }
  54 +
  55 + @Test
  56 + void searchUsers_emptyFilter_returnsAllUndeletedAsPage() {
  57 + Integer staffId = insertStaff("张三");
  58 + insertUser("alice_" + System.nanoTime(), staffId);
  59 + insertUser("bob_" + System.nanoTime(), null);
  60 +
  61 + UserQueryDTO query = new UserQueryDTO();
  62 + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, null);
  63 +
  64 + assertThat(result.getTotal()).isGreaterThanOrEqualTo(2L);
  65 + assertThat(result.getRecords()).extracting(UserListItemVO::getSUserName)
  66 + .anyMatch(n -> n.startsWith("alice_") || n.startsWith("bob_"));
  67 + }
  68 +
  69 + @Test
  70 + void searchUsers_filterByUserName_filtersCorrectly() {
  71 + String alicePrefix = "ali_" + System.nanoTime();
  72 + insertUser(alicePrefix + "_alice", null);
  73 + insertUser("bob_unmatch_" + System.nanoTime(), null);
  74 +
  75 + UserQueryDTO query = new UserQueryDTO();
  76 + query.setQueryField("username");
  77 + query.setMatchType("contains");
  78 + query.setQueryValue(alicePrefix);
  79 +
  80 + // column 由 service 层映射;mapper IT 直接传 "u.sUserName"
  81 + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, "u.sUserName");
  82 +
  83 + assertThat(result.getRecords()).hasSize(1);
  84 + assertThat(result.getRecords().get(0).getSUserName()).startsWith(alicePrefix);
  85 + }
  86 +}
backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  4 +import com.xly.erp.module.usr.entity.StaffEntity;
  5 +import com.xly.erp.module.usr.entity.UserEntity;
  6 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  7 +import org.junit.jupiter.api.Test;
  8 +import org.springframework.beans.factory.annotation.Autowired;
  9 +import org.springframework.boot.test.context.SpringBootTest;
  10 +import org.springframework.test.annotation.Rollback;
  11 +import org.springframework.test.context.ActiveProfiles;
  12 +import org.springframework.transaction.annotation.Transactional;
  13 +
  14 +import java.time.LocalDateTime;
  15 +
  16 +import static org.assertj.core.api.Assertions.assertThat;
  17 +
  18 +@SpringBootTest
  19 +@ActiveProfiles("test")
  20 +@Transactional
  21 +@Rollback
  22 +class UsrMappersIT {
  23 +
  24 + @Autowired UserMapper userMapper;
  25 + @Autowired StaffMapper staffMapper;
  26 + @Autowired PermissionCategoryMapper permissionCategoryMapper;
  27 + @Autowired UserPermissionMapper userPermissionMapper;
  28 +
  29 + @Test
  30 + void staff_insertAndSelect() {
  31 + StaffEntity s = new StaffEntity();
  32 + s.setSStaffNo("staff_" + System.nanoTime());
  33 + s.setSStaffName("张三");
  34 + s.setSDepartment("研发部");
  35 + s.setBDeleted(false);
  36 + s.setTCreateDate(LocalDateTime.now());
  37 + assertThat(staffMapper.insert(s)).isEqualTo(1);
  38 + assertThat(s.getIIncrement()).isPositive();
  39 + StaffEntity loaded = staffMapper.selectById(s.getIIncrement());
  40 + assertThat(loaded.getSStaffName()).isEqualTo("张三");
  41 + assertThat(loaded.getSDepartment()).isEqualTo("研发部");
  42 + }
  43 +
  44 + @Test
  45 + void permissionCategory_insertAndSelect() {
  46 + PermissionCategoryEntity p = new PermissionCategoryEntity();
  47 + p.setSCategoryCode("cat_" + System.nanoTime());
  48 + p.setSCategoryName("基础权限");
  49 + p.setISortOrder(0);
  50 + p.setBDeleted(false);
  51 + p.setTCreateDate(LocalDateTime.now());
  52 + assertThat(permissionCategoryMapper.insert(p)).isEqualTo(1);
  53 + PermissionCategoryEntity loaded = permissionCategoryMapper.selectById(p.getIIncrement());
  54 + assertThat(loaded.getSCategoryName()).isEqualTo("基础权限");
  55 + }
  56 +
  57 + @Test
  58 + void user_insertAndSelect() {
  59 + UserEntity u = new UserEntity();
  60 + u.setSUserNo("u_" + System.nanoTime());
  61 + u.setSUserName("alice_" + System.nanoTime());
  62 + u.setSUserType("普通用户");
  63 + u.setSLanguage("zh");
  64 + u.setBCanModifyDocs(false);
  65 + u.setSPasswordHash("$2a$10$abcdefghijklmnopqrstuv");
  66 + u.setBDeleted(false);
  67 + u.setTCreateDate(LocalDateTime.now());
  68 + assertThat(userMapper.insert(u)).isEqualTo(1);
  69 + UserEntity loaded = userMapper.selectById(u.getIIncrement());
  70 + assertThat(loaded.getSUserName()).startsWith("alice_");
  71 + assertThat(loaded.getSUserType()).isEqualTo("普通用户");
  72 + assertThat(loaded.getSLanguage()).isEqualTo("zh");
  73 + }
  74 +
  75 + @Test
  76 + void userPermission_insertAndSelect_requiresValidUserAndCategory() {
  77 + // 先建合法 user + category(FK 兜底)
  78 + UserEntity u = new UserEntity();
  79 + u.setSUserNo("upu_" + System.nanoTime());
  80 + u.setSUserName("upa_" + System.nanoTime());
  81 + u.setSUserType("普通用户");
  82 + u.setSLanguage("zh");
  83 + u.setBCanModifyDocs(false);
  84 + u.setSPasswordHash("$2a$10$x");
  85 + u.setBDeleted(false);
  86 + u.setTCreateDate(LocalDateTime.now());
  87 + userMapper.insert(u);
  88 +
  89 + PermissionCategoryEntity p = new PermissionCategoryEntity();
  90 + p.setSCategoryCode("upc_" + System.nanoTime());
  91 + p.setSCategoryName("upcat");
  92 + p.setISortOrder(0);
  93 + p.setBDeleted(false);
  94 + p.setTCreateDate(LocalDateTime.now());
  95 + permissionCategoryMapper.insert(p);
  96 +
  97 + UserPermissionEntity up = new UserPermissionEntity();
  98 + up.setIUserId(u.getIIncrement());
  99 + up.setICategoryId(p.getIIncrement());
  100 + up.setTCreateDate(LocalDateTime.now());
  101 + assertThat(userPermissionMapper.insert(up)).isEqualTo(1);
  102 + UserPermissionEntity loaded = userPermissionMapper.selectById(up.getIIncrement());
  103 + assertThat(loaded.getIUserId()).isEqualTo(u.getIIncrement());
  104 + assertThat(loaded.getICategoryId()).isEqualTo(p.getIIncrement());
  105 + }
  106 +}
backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import org.junit.jupiter.api.BeforeEach;
  4 +import org.junit.jupiter.api.Test;
  5 +
  6 +import static org.assertj.core.api.Assertions.assertThat;
  7 +
  8 +class InMemoryLoginAttemptStoreTest {
  9 +
  10 + private InMemoryLoginAttemptStore store;
  11 +
  12 + @BeforeEach
  13 + void setUp() {
  14 + store = new InMemoryLoginAttemptStore();
  15 + }
  16 +
  17 + @Test
  18 + void cooldown_initial_returnsZero() {
  19 + assertThat(store.cooldownSeconds("alice")).isZero();
  20 + }
  21 +
  22 + @Test
  23 + void recordFailure_under5_doesNotLock() {
  24 + for (int i = 0; i < 4; i++) {
  25 + store.recordFailure("alice");
  26 + }
  27 + assertThat(store.cooldownSeconds("alice")).isZero();
  28 + }
  29 +
  30 + @Test
  31 + void recordFailure_at5_triggersLock_cooldownPositive() {
  32 + for (int i = 0; i < 5; i++) {
  33 + store.recordFailure("alice");
  34 + }
  35 + long cd = store.cooldownSeconds("alice");
  36 + assertThat(cd).isGreaterThan(0L);
  37 + assertThat(cd).isLessThanOrEqualTo(15L * 60L); // 锁定 15 min
  38 + }
  39 +
  40 + @Test
  41 + void clear_resetsCount() {
  42 + for (int i = 0; i < 5; i++) {
  43 + store.recordFailure("alice");
  44 + }
  45 + store.clear("alice");
  46 + assertThat(store.cooldownSeconds("alice")).isZero();
  47 + }
  48 +
  49 + @Test
  50 + void cooldown_afterExpiry_resetsCount() {
  51 + for (int i = 0; i < 5; i++) {
  52 + store.recordFailure("alice");
  53 + }
  54 + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L);
  55 +
  56 + // 把锁定时间拨到过去 → 模拟到期
  57 + store.expireLockForTest("alice");
  58 +
  59 + // 到期后 cooldownSeconds 应返回 0 + record 清空
  60 + assertThat(store.cooldownSeconds("alice")).isZero();
  61 +
  62 + // 验证 reset 真的清空了 count——再 recordFailure 4 次仍未锁定
  63 + for (int i = 0; i < 4; i++) {
  64 + store.recordFailure("alice");
  65 + }
  66 + assertThat(store.cooldownSeconds("alice")).isZero();
  67 +
  68 + // 第 5 次 record 后再次锁定(业务规则 4 完整重启)
  69 + store.recordFailure("alice");
  70 + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L);
  71 + }
  72 +}
backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import io.jsonwebtoken.Claims;
  4 +import io.jsonwebtoken.ExpiredJwtException;
  5 +import io.jsonwebtoken.Jwts;
  6 +import io.jsonwebtoken.security.Keys;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import javax.crypto.SecretKey;
  10 +import java.nio.charset.StandardCharsets;
  11 +import java.time.Instant;
  12 +import java.util.Date;
  13 +
  14 +import static org.assertj.core.api.Assertions.assertThat;
  15 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  16 +
  17 +class JwtTokenProviderTest {
  18 +
  19 + // 测试专用 fake secret——与 .env.local 生产 JWT_SECRET 无关。
  20 + // 32 字节(256 bit)随机 hex,仅用于单元测试隔离。
  21 + private static final String SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
  22 + private static final long EXPIRES_IN = 7200L;
  23 +
  24 + @Test
  25 + void signAndParse_returnsClaims() {
  26 + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN);
  27 +
  28 + String token = provider.sign(42, "alice", "普通用户");
  29 +
  30 + Claims claims = provider.parse(token);
  31 + assertThat(claims.getSubject()).isEqualTo("alice");
  32 + assertThat(claims.get("uid", Integer.class)).isEqualTo(42);
  33 + assertThat(claims.get("type", String.class)).isEqualTo("普通用户");
  34 + assertThat(claims.getExpiration().getTime() - claims.getIssuedAt().getTime())
  35 + .isEqualTo(EXPIRES_IN * 1000L);
  36 + }
  37 +
  38 + @Test
  39 + void parseExpiredToken_throwsExpiredJwtException() {
  40 + // 用同一 secret 手工签一个已过期 token
  41 + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
  42 + Instant now = Instant.now();
  43 + String expired = Jwts.builder()
  44 + .subject("alice")
  45 + .issuedAt(Date.from(now.minusSeconds(7200)))
  46 + .expiration(Date.from(now.minusSeconds(60)))
  47 + .signWith(key)
  48 + .compact();
  49 +
  50 + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN);
  51 +
  52 + assertThatThrownBy(() -> provider.parse(expired))
  53 + .isInstanceOf(ExpiredJwtException.class);
  54 + }
  55 +}
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.Wrapper;
  4 +import com.xly.erp.common.exception.AccountLockedException;
  5 +import com.xly.erp.common.exception.BizException;
  6 +import com.xly.erp.common.response.ErrorCode;
  7 +import com.xly.erp.module.usr.dto.LoginDTO;
  8 +import com.xly.erp.module.usr.entity.UserEntity;
  9 +import com.xly.erp.module.usr.mapper.UserMapper;
  10 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  11 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  12 +import com.xly.erp.module.usr.service.impl.LoginServiceImpl;
  13 +import com.xly.erp.module.usr.vo.LoginResultVO;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.junit.jupiter.api.extension.ExtendWith;
  16 +import org.mockito.ArgumentCaptor;
  17 +import org.mockito.InjectMocks;
  18 +import org.mockito.Mock;
  19 +import org.mockito.junit.jupiter.MockitoExtension;
  20 +import org.springframework.security.crypto.password.PasswordEncoder;
  21 +
  22 +import static org.assertj.core.api.Assertions.assertThat;
  23 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  24 +import static org.mockito.ArgumentMatchers.any;
  25 +import static org.mockito.ArgumentMatchers.anyInt;
  26 +import static org.mockito.ArgumentMatchers.anyString;
  27 +import static org.mockito.ArgumentMatchers.eq;
  28 +import static org.mockito.ArgumentMatchers.isNull;
  29 +import static org.mockito.Mockito.never;
  30 +import static org.mockito.Mockito.times;
  31 +import static org.mockito.Mockito.verify;
  32 +import static org.mockito.Mockito.when;
  33 +
  34 +@ExtendWith(MockitoExtension.class)
  35 +class LoginServiceImplTest {
  36 +
  37 + @Mock UserMapper userMapper;
  38 + @Mock PasswordEncoder passwordEncoder;
  39 + @Mock LoginAttemptStore attemptStore;
  40 + @Mock JwtTokenProvider jwtTokenProvider;
  41 +
  42 + @InjectMocks LoginServiceImpl service;
  43 +
  44 + private LoginDTO dto() {
  45 + LoginDTO d = new LoginDTO();
  46 + d.setSUserName("alice");
  47 + d.setSPassword("666666");
  48 + d.setSVersion("standard");
  49 + return d;
  50 + }
  51 +
  52 + private UserEntity userEntity() {
  53 + UserEntity u = new UserEntity();
  54 + u.setIIncrement(42);
  55 + u.setSUserNo("u001");
  56 + u.setSUserName("alice");
  57 + u.setSUserType("普通用户");
  58 + u.setSLanguage("zh");
  59 + u.setSPasswordHash("$2a$10$hash");
  60 + u.setBDeleted(false);
  61 + return u;
  62 + }
  63 +
  64 + @Test
  65 + void login_validCredentials_returnsTokenAndClearsFailCount() {
  66 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  67 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  68 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(true);
  69 + when(jwtTokenProvider.sign(42, "alice", "普通用户")).thenReturn("jwt.token.value");
  70 + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L);
  71 +
  72 + LoginResultVO vo = service.login(dto());
  73 +
  74 + assertThat(vo.getAccessToken()).isEqualTo("jwt.token.value");
  75 + assertThat(vo.getExpiresIn()).isEqualTo(7200L);
  76 + assertThat(vo.getUser().getIIncrement()).isEqualTo(42);
  77 + assertThat(vo.getUser().getSUserName()).isEqualTo("alice");
  78 + assertThat(vo.getUser().getSUserType()).isEqualTo("普通用户");
  79 + verify(attemptStore).clear("alice");
  80 + verify(userMapper).update(isNull(), any(Wrapper.class));
  81 + }
  82 +
  83 + @Test
  84 + void login_userNotFound_returns40101_recordsFailure() {
  85 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  86 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
  87 +
  88 + assertThatThrownBy(() -> service.login(dto()))
  89 + .isInstanceOf(BizException.class)
  90 + .extracting(e -> ((BizException) e).getCode())
  91 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  92 +
  93 + verify(attemptStore).recordFailure("alice");
  94 + verify(userMapper, never()).update(any(), any(Wrapper.class));
  95 + }
  96 +
  97 + @Test
  98 + void login_passwordMismatch_returns40101_recordsFailure() {
  99 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  100 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  101 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false);
  102 +
  103 + assertThatThrownBy(() -> service.login(dto()))
  104 + .isInstanceOf(BizException.class)
  105 + .extracting(e -> ((BizException) e).getCode())
  106 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  107 +
  108 + verify(attemptStore).recordFailure("alice");
  109 + verify(userMapper, never()).update(any(), any(Wrapper.class));
  110 + }
  111 +
  112 + @Test
  113 + void login_accountLocked_throwsAccountLockedException_withCooldown() {
  114 + when(attemptStore.cooldownSeconds("alice")).thenReturn(540L);
  115 +
  116 + assertThatThrownBy(() -> service.login(dto()))
  117 + .isInstanceOf(AccountLockedException.class)
  118 + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds())
  119 + .isEqualTo(540L);
  120 +
  121 + verify(userMapper, never()).selectOne(any(Wrapper.class));
  122 + }
  123 +
  124 + @Test
  125 + void login_5thFailureTriggersLock_throwsAccountLockedException() {
  126 + when(attemptStore.cooldownSeconds("alice"))
  127 + .thenReturn(0L) // 入口检查通过
  128 + .thenReturn(900L); // recordFailure 后再查时已锁定
  129 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  130 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false);
  131 +
  132 + assertThatThrownBy(() -> service.login(dto()))
  133 + .isInstanceOf(AccountLockedException.class)
  134 + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds())
  135 + .isEqualTo(900L);
  136 +
  137 + verify(attemptStore).recordFailure("alice");
  138 + }
  139 +
  140 + @Test
  141 + void login_successUpdatesTLastLoginDate_viaSetClause() {
  142 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  143 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  144 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
  145 + when(jwtTokenProvider.sign(anyInt(), anyString(), anyString())).thenReturn("token");
  146 + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L);
  147 +
  148 + service.login(dto());
  149 +
  150 + // 验证 update(entity=null, wrapper=non-null),对应 LambdaUpdateWrapper.set(tLastLoginDate, now)
  151 + verify(userMapper, times(1)).update(isNull(), any(Wrapper.class));
  152 + }
  153 +
  154 + @Test
  155 + void login_userSoftDeleted_returns40101() {
  156 + // selectOne 已过滤 bDeleted=0 → 软删用户返回 null(与 not found 等效路径)
  157 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  158 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
  159 +
  160 + assertThatThrownBy(() -> service.login(dto()))
  161 + .isInstanceOf(BizException.class)
  162 + .extracting(e -> ((BizException) e).getCode())
  163 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  164 + }
  165 +}
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.Wrapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.common.response.PageResult;
  9 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  10 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  11 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  12 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  13 +import com.xly.erp.module.usr.entity.StaffEntity;
  14 +import com.xly.erp.module.usr.entity.UserEntity;
  15 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  16 +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
  17 +import com.xly.erp.module.usr.mapper.StaffMapper;
  18 +import com.xly.erp.module.usr.mapper.UserMapper;
  19 +import com.xly.erp.module.usr.mapper.UserPermissionMapper;
  20 +import com.xly.erp.module.usr.service.impl.UserServiceImpl;
  21 +import com.xly.erp.module.usr.vo.UserListItemVO;
  22 +import com.xly.erp.module.usr.vo.UserVO;
  23 +import org.junit.jupiter.api.Test;
  24 +import org.junit.jupiter.api.extension.ExtendWith;
  25 +import org.mockito.ArgumentCaptor;
  26 +import org.mockito.InjectMocks;
  27 +import org.mockito.Mock;
  28 +import org.mockito.junit.jupiter.MockitoExtension;
  29 +import org.springframework.dao.DuplicateKeyException;
  30 +import org.springframework.security.crypto.password.PasswordEncoder;
  31 +
  32 +import java.util.List;
  33 +
  34 +import static org.assertj.core.api.Assertions.assertThat;
  35 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  36 +import static org.mockito.ArgumentMatchers.any;
  37 +import static org.mockito.ArgumentMatchers.anyList;
  38 +import static org.mockito.Mockito.never;
  39 +import static org.mockito.Mockito.times;
  40 +import static org.mockito.Mockito.verify;
  41 +import static org.mockito.Mockito.when;
  42 +
  43 +@ExtendWith(MockitoExtension.class)
  44 +class UserServiceImplTest {
  45 +
  46 + @Mock UserMapper userMapper;
  47 + @Mock StaffMapper staffMapper;
  48 + @Mock PermissionCategoryMapper permissionCategoryMapper;
  49 + @Mock UserPermissionMapper userPermissionMapper;
  50 + @Mock PasswordEncoder passwordEncoder;
  51 +
  52 + @InjectMocks UserServiceImpl service;
  53 +
  54 + private UserCreateDTO baseDto() {
  55 + UserCreateDTO d = new UserCreateDTO();
  56 + d.setSUserNo("u001");
  57 + d.setSUserName("alice");
  58 + d.setSUserType("普通用户");
  59 + d.setSLanguage("zh");
  60 + return d;
  61 + }
  62 +
  63 + @Test
  64 + void create_minimalFields_returnsVOWithBCryptHash() {
  65 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  66 + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$mockhash");
  67 + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> {
  68 + UserEntity u = inv.getArgument(0);
  69 + u.setIIncrement(101);
  70 + return 1;
  71 + });
  72 +
  73 + UserVO vo = service.create(baseDto());
  74 +
  75 + assertThat(vo.getIIncrement()).isEqualTo(101);
  76 + assertThat(vo.getSUserName()).isEqualTo("alice");
  77 + assertThat(vo.getBCanModifyDocs()).isFalse();
  78 + assertThat(vo.getPermissionCategoryIds()).isEmpty();
  79 +
  80 + ArgumentCaptor<UserEntity> cap = ArgumentCaptor.forClass(UserEntity.class);
  81 + verify(userMapper).insert(cap.capture());
  82 + UserEntity saved = cap.getValue();
  83 + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$mockhash");
  84 + assertThat(saved.getBDeleted()).isFalse();
  85 + assertThat(saved.getTCreateDate()).isNotNull();
  86 + assertThat(saved.getSCreatedBy()).isNull();
  87 + assertThat(saved.getSBrandsId()).isNull();
  88 + }
  89 +
  90 + @Test
  91 + void create_withStaffAndPermissions_writesAssociation() {
  92 + UserCreateDTO d = baseDto();
  93 + d.setIStaffId(7);
  94 + d.setPermissionCategoryIds(List.of(1, 2, 3));
  95 +
  96 + StaffEntity staff = new StaffEntity();
  97 + staff.setIIncrement(7);
  98 + staff.setBDeleted(false);
  99 + when(staffMapper.selectById(7)).thenReturn(staff);
  100 +
  101 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  102 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(
  103 + cat(1), cat(2), cat(3)
  104 + ));
  105 + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h");
  106 + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> {
  107 + UserEntity u = inv.getArgument(0);
  108 + u.setIIncrement(202);
  109 + return 1;
  110 + });
  111 +
  112 + UserVO vo = service.create(d);
  113 +
  114 + assertThat(vo.getIIncrement()).isEqualTo(202);
  115 + assertThat(vo.getIStaffId()).isEqualTo(7);
  116 + assertThat(vo.getPermissionCategoryIds()).containsExactly(1, 2, 3);
  117 +
  118 + ArgumentCaptor<UserPermissionEntity> upCap = ArgumentCaptor.forClass(UserPermissionEntity.class);
  119 + verify(userPermissionMapper, times(3)).insert(upCap.capture());
  120 + List<UserPermissionEntity> ups = upCap.getAllValues();
  121 + assertThat(ups).extracting(UserPermissionEntity::getIUserId).containsOnly(202);
  122 + assertThat(ups).extracting(UserPermissionEntity::getICategoryId).containsExactly(1, 2, 3);
  123 + }
  124 +
  125 + @Test
  126 + void create_duplicateUserName_throws40921() {
  127 + // 第一次 selectCount(sUserName) 返回 1
  128 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
  129 +
  130 + assertThatThrownBy(() -> service.create(baseDto()))
  131 + .isInstanceOf(BizException.class)
  132 + .extracting(e -> ((BizException) e).getCode())
  133 + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode());
  134 + verify(userMapper, never()).insert((UserEntity) any());
  135 + }
  136 +
  137 + @Test
  138 + void create_duplicateUserNo_throws40921() {
  139 + // 第一次 (sUserName) 返回 0;第二次 (sUserNo) 返回 1
  140 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L);
  141 +
  142 + assertThatThrownBy(() -> service.create(baseDto()))
  143 + .isInstanceOf(BizException.class)
  144 + .extracting(e -> ((BizException) e).getCode())
  145 + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode());
  146 + verify(userMapper, never()).insert((UserEntity) any());
  147 + }
  148 +
  149 + @Test
  150 + void create_staffNotFound_throws40421() {
  151 + UserCreateDTO d = baseDto();
  152 + d.setIStaffId(999999);
  153 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  154 + when(staffMapper.selectById(999999)).thenReturn(null);
  155 +
  156 + assertThatThrownBy(() -> service.create(d))
  157 + .isInstanceOf(BizException.class)
  158 + .extracting(e -> ((BizException) e).getCode())
  159 + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode());
  160 + }
  161 +
  162 + @Test
  163 + void create_staffSoftDeleted_throws40421() {
  164 + UserCreateDTO d = baseDto();
  165 + d.setIStaffId(5);
  166 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  167 + StaffEntity deleted = new StaffEntity();
  168 + deleted.setIIncrement(5);
  169 + deleted.setBDeleted(true);
  170 + when(staffMapper.selectById(5)).thenReturn(deleted);
  171 +
  172 + assertThatThrownBy(() -> service.create(d))
  173 + .isInstanceOf(BizException.class)
  174 + .extracting(e -> ((BizException) e).getCode())
  175 + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode());
  176 + }
  177 +
  178 + @Test
  179 + void create_permissionCategoryNotFound_throws40422() {
  180 + UserCreateDTO d = baseDto();
  181 + d.setPermissionCategoryIds(List.of(1, 999999));
  182 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  183 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(1))); // 只返回 1 条,缺 999999
  184 +
  185 + assertThatThrownBy(() -> service.create(d))
  186 + .isInstanceOf(BizException.class)
  187 + .extracting(e -> ((BizException) e).getCode())
  188 + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode());
  189 + verify(userMapper, never()).insert((UserEntity) any());
  190 + }
  191 +
  192 + @Test
  193 + void create_emptyPermissionCategoryIds_doesNotInsertAssociation() {
  194 + UserCreateDTO d = baseDto();
  195 + d.setPermissionCategoryIds(List.of());
  196 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  197 + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h");
  198 + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> {
  199 + ((UserEntity) inv.getArgument(0)).setIIncrement(303);
  200 + return 1;
  201 + });
  202 +
  203 + service.create(d);
  204 +
  205 + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any());
  206 + }
  207 +
  208 + @Test
  209 + void create_concurrentDuplicate_dupKeyException_mappedTo40921() {
  210 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  211 + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h");
  212 + when(userMapper.insert((UserEntity) any()))
  213 + .thenThrow(new DuplicateKeyException("uk_user_name"));
  214 +
  215 + assertThatThrownBy(() -> service.create(baseDto()))
  216 + .isInstanceOf(BizException.class)
  217 + .extracting(e -> ((BizException) e).getCode())
  218 + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode());
  219 + }
  220 +
  221 + private static PermissionCategoryEntity cat(int id) {
  222 + PermissionCategoryEntity p = new PermissionCategoryEntity();
  223 + p.setIIncrement(id);
  224 + p.setBDeleted(false);
  225 + return p;
  226 + }
  227 +
  228 + // ============================================================
  229 + // REQ-USR-002 update 系列
  230 + // ============================================================
  231 +
  232 + private UserUpdateDTO updateDto() {
  233 + UserUpdateDTO d = new UserUpdateDTO();
  234 + d.setIStaffId(7);
  235 + d.setSUserType("超级管理员");
  236 + d.setSLanguage("en");
  237 + d.setBCanModifyDocs(true);
  238 + d.setPermissionCategoryIds(List.of(10, 20));
  239 + return d;
  240 + }
  241 +
  242 + private UserEntity existingTargetUser(int id) {
  243 + UserEntity u = new UserEntity();
  244 + u.setIIncrement(id);
  245 + u.setSUserNo("u_orig");
  246 + u.setSUserName("alice_orig");
  247 + u.setIStaffId(3);
  248 + u.setSUserType("普通用户");
  249 + u.setSLanguage("zh");
  250 + u.setBCanModifyDocs(false);
  251 + u.setSPasswordHash("$2a$10$origHash");
  252 + u.setBDeleted(false);
  253 + u.setSCreatedBy("system");
  254 + return u;
  255 + }
  256 +
  257 + @Test
  258 + void update_targetNotFound_throws40431() {
  259 + when(userMapper.selectById(99)).thenReturn(null);
  260 +
  261 + assertThatThrownBy(() -> service.update(99, updateDto()))
  262 + .isInstanceOf(BizException.class)
  263 + .extracting(e -> ((BizException) e).getCode())
  264 + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode());
  265 + }
  266 +
  267 + @Test
  268 + void update_targetSoftDeleted_throws40431() {
  269 + UserEntity target = existingTargetUser(100);
  270 + target.setBDeleted(true);
  271 + when(userMapper.selectById(100)).thenReturn(target);
  272 +
  273 + assertThatThrownBy(() -> service.update(100, updateDto()))
  274 + .isInstanceOf(BizException.class)
  275 + .extracting(e -> ((BizException) e).getCode())
  276 + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode());
  277 + }
  278 +
  279 + @Test
  280 + void update_staffNotFound_throws40421() {
  281 + UserEntity target = existingTargetUser(101);
  282 + when(userMapper.selectById(101)).thenReturn(target);
  283 + UserUpdateDTO d = updateDto();
  284 + d.setIStaffId(999999);
  285 + when(staffMapper.selectById(999999)).thenReturn(null);
  286 +
  287 + assertThatThrownBy(() -> service.update(101, d))
  288 + .isInstanceOf(BizException.class)
  289 + .extracting(e -> ((BizException) e).getCode())
  290 + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode());
  291 + }
  292 +
  293 + @Test
  294 + void update_staffSoftDeleted_throws40421() {
  295 + UserEntity target = existingTargetUser(102);
  296 + when(userMapper.selectById(102)).thenReturn(target);
  297 + UserUpdateDTO d = updateDto();
  298 + d.setIStaffId(8);
  299 + StaffEntity staff = new StaffEntity();
  300 + staff.setIIncrement(8);
  301 + staff.setBDeleted(true);
  302 + when(staffMapper.selectById(8)).thenReturn(staff);
  303 +
  304 + assertThatThrownBy(() -> service.update(102, d))
  305 + .isInstanceOf(BizException.class)
  306 + .extracting(e -> ((BizException) e).getCode())
  307 + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode());
  308 + }
  309 +
  310 + @Test
  311 + void update_permissionCategoryNotFound_throws40422() {
  312 + UserEntity target = existingTargetUser(103);
  313 + when(userMapper.selectById(103)).thenReturn(target);
  314 + UserUpdateDTO d = updateDto();
  315 + d.setIStaffId(null); // 跳过 staff 校验
  316 + d.setPermissionCategoryIds(List.of(10, 999999));
  317 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10)));
  318 +
  319 + assertThatThrownBy(() -> service.update(103, d))
  320 + .isInstanceOf(BizException.class)
  321 + .extracting(e -> ((BizException) e).getCode())
  322 + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode());
  323 + }
  324 +
  325 + @Test
  326 + void update_full_returnsVOWithUpdatedFields_andRebuildsPermissions() {
  327 + UserEntity target = existingTargetUser(104);
  328 + when(userMapper.selectById(104)).thenReturn(target);
  329 + StaffEntity staff = new StaffEntity();
  330 + staff.setIIncrement(7);
  331 + staff.setBDeleted(false);
  332 + when(staffMapper.selectById(7)).thenReturn(staff);
  333 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20)));
  334 +
  335 + UserVO vo = service.update(104, updateDto());
  336 +
  337 + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class);
  338 + verify(userMapper).updateById(userCap.capture());
  339 + UserEntity saved = userCap.getValue();
  340 + // 已修改字段
  341 + assertThat(saved.getIStaffId()).isEqualTo(7);
  342 + assertThat(saved.getSUserType()).isEqualTo("超级管理员");
  343 + assertThat(saved.getSLanguage()).isEqualTo("en");
  344 + assertThat(saved.getBCanModifyDocs()).isTrue();
  345 + // 保留字段
  346 + assertThat(saved.getSUserNo()).isEqualTo("u_orig");
  347 + assertThat(saved.getSUserName()).isEqualTo("alice_orig");
  348 + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$origHash");
  349 + assertThat(saved.getSCreatedBy()).isEqualTo("system");
  350 +
  351 + // 关联表先删后插
  352 + verify(userPermissionMapper).delete(any(Wrapper.class));
  353 + ArgumentCaptor<UserPermissionEntity> upCap = ArgumentCaptor.forClass(UserPermissionEntity.class);
  354 + verify(userPermissionMapper, times(2)).insert(upCap.capture());
  355 + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getICategoryId)
  356 + .containsExactly(10, 20);
  357 + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getIUserId)
  358 + .containsOnly(104);
  359 +
  360 + assertThat(vo.getPermissionCategoryIds()).containsExactly(10, 20);
  361 + }
  362 +
  363 + @Test
  364 + void update_partialNullBCanModifyDocs_keepsOriginal() {
  365 + UserEntity target = existingTargetUser(105);
  366 + target.setBCanModifyDocs(true); // 原值 true
  367 + when(userMapper.selectById(105)).thenReturn(target);
  368 + StaffEntity staff = new StaffEntity();
  369 + staff.setIIncrement(7);
  370 + staff.setBDeleted(false);
  371 + when(staffMapper.selectById(7)).thenReturn(staff);
  372 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20)));
  373 +
  374 + UserUpdateDTO d = updateDto();
  375 + d.setBCanModifyDocs(null); // 期望保留原值
  376 +
  377 + service.update(105, d);
  378 +
  379 + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class);
  380 + verify(userMapper).updateById(userCap.capture());
  381 + assertThat(userCap.getValue().getBCanModifyDocs()).isTrue();
  382 + }
  383 +
  384 + @Test
  385 + void update_clearStaffId_setsToNull() {
  386 + UserEntity target = existingTargetUser(106);
  387 + target.setIStaffId(3); // 原本有
  388 + when(userMapper.selectById(106)).thenReturn(target);
  389 + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20)));
  390 +
  391 + UserUpdateDTO d = updateDto();
  392 + d.setIStaffId(null); // 显式清空
  393 +
  394 + service.update(106, d);
  395 +
  396 + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class);
  397 + verify(userMapper).updateById(userCap.capture());
  398 + assertThat(userCap.getValue().getIStaffId()).isNull();
  399 + }
  400 +
  401 + @Test
  402 + void update_emptyPermissionCategoryIds_clearsAllAssociations() {
  403 + UserEntity target = existingTargetUser(107);
  404 + when(userMapper.selectById(107)).thenReturn(target);
  405 + StaffEntity staff = new StaffEntity();
  406 + staff.setIIncrement(7);
  407 + staff.setBDeleted(false);
  408 + when(staffMapper.selectById(7)).thenReturn(staff);
  409 +
  410 + UserUpdateDTO d = updateDto();
  411 + d.setPermissionCategoryIds(List.of()); // 清空全部
  412 +
  413 + service.update(107, d);
  414 +
  415 + verify(userPermissionMapper).delete(any(Wrapper.class));
  416 + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any());
  417 + }
  418 +
  419 + // ============================================================
  420 + // REQ-USR-003 search 系列
  421 + // ============================================================
  422 +
  423 + @Test
  424 + void search_emptyDb_returnsEmptyPage() {
  425 + UserQueryDTO query = new UserQueryDTO();
  426 + IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
  427 + emptyPage.setTotal(0L);
  428 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
  429 +
  430 + PageResult<UserListItemVO> result = service.search(query);
  431 + assertThat(result.getTotal()).isZero();
  432 + assertThat(result.getList()).isEmpty();
  433 + }
  434 +
  435 + @Test
  436 + void search_invalidQueryField_throws40010() {
  437 + UserQueryDTO query = new UserQueryDTO();
  438 + query.setQueryField("invalid_field");
  439 +
  440 + assertThatThrownBy(() -> service.search(query))
  441 + .isInstanceOf(BizException.class)
  442 + .extracting(e -> ((BizException) e).getCode())
  443 + .isEqualTo(ErrorCode.PARAM_INVALID.getCode());
  444 + }
  445 +
  446 + @Test
  447 + void search_invalidMatchType_throws40010() {
  448 + UserQueryDTO query = new UserQueryDTO();
  449 + query.setMatchType("like"); // 非白名单
  450 +
  451 + assertThatThrownBy(() -> service.search(query))
  452 + .isInstanceOf(BizException.class)
  453 + .extracting(e -> ((BizException) e).getCode())
  454 + .isEqualTo(ErrorCode.PARAM_INVALID.getCode());
  455 + }
  456 +
  457 + @Test
  458 + void search_passesMappedColumnToMapper() {
  459 + UserQueryDTO query = new UserQueryDTO();
  460 + query.setQueryField("username");
  461 + query.setQueryValue("alice");
  462 + IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
  463 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
  464 +
  465 + service.search(query);
  466 +
  467 + ArgumentCaptor<String> colCap = ArgumentCaptor.forClass(String.class);
  468 + verify(userMapper).searchUsers(any(IPage.class), any(UserQueryDTO.class), colCap.capture());
  469 + assertThat(colCap.getValue()).isEqualTo("u.sUserName");
  470 + }
  471 +
  472 + @Test
  473 + void search_appliesDefaultPagination_whenNullPageNumOrSize() {
  474 + UserQueryDTO query = new UserQueryDTO();
  475 + query.setPageNum(null);
  476 + query.setPageSize(null);
  477 + IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
  478 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
  479 +
  480 + service.search(query);
  481 +
  482 + ArgumentCaptor<IPage> pageCap = ArgumentCaptor.forClass(IPage.class);
  483 + verify(userMapper).searchUsers(pageCap.capture(), any(UserQueryDTO.class), any());
  484 + IPage<?> page = pageCap.getValue();
  485 + assertThat(page.getCurrent()).isEqualTo(1L);
  486 + assertThat(page.getSize()).isEqualTo(20L);
  487 + }
  488 +
  489 + @Test
  490 + void search_deletedQueryValueTrue_normalizedToOne() {
  491 + UserQueryDTO query = new UserQueryDTO();
  492 + query.setQueryField("deleted");
  493 + query.setQueryValue("true");
  494 + query.setMatchType("equals");
  495 + IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
  496 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
  497 +
  498 + service.search(query);
  499 +
  500 + ArgumentCaptor<UserQueryDTO> cap = ArgumentCaptor.forClass(UserQueryDTO.class);
  501 + verify(userMapper).searchUsers(any(IPage.class), cap.capture(), any());
  502 + assertThat(cap.getValue().getQueryValue()).isEqualTo("1");
  503 + }
  504 +}
docs/08-模块任务管理.md
@@ -68,9 +68,9 @@ @@ -68,9 +68,9 @@
68 - module_usr 用户管理 68 - module_usr 用户管理
69 - 依赖: — 69 - 依赖: —
70 - 路径: backend/src/main/java/com/xly/erp/module/usr/, frontend/src/pages/usr/ 70 - 路径: backend/src/main/java/com/xly/erp/module/usr/, frontend/src/pages/usr/
71 - - MR: 71 + - MR: !2
72 - 功能: 72 - 功能:
73 - - [ ] REQ-USR-001 用户新增  
74 - - [ ] REQ-USR-002 用户修改  
75 - - [ ] REQ-USR-003 用户查询  
76 - - [ ] REQ-USR-004 用户登录 73 + - [x] REQ-USR-001 用户新增
  74 + - [x] REQ-USR-002 用户修改
  75 + - [x] REQ-USR-003 用户查询
  76 + - [x] REQ-USR-004 用户登录
docs/superpowers/module-reports/2026-05-07-module_usr.md 0 → 100644
  1 +---
  2 +module_id: module_usr
  3 +date: 2026-05-07
  4 +git_range: 237a97e..6e0c0e7 (27 commits)
  5 +---
  6 +
  7 +# 模块完成报告 — module_usr 用户管理
  8 +
  9 +## ① 模块信息
  10 +- 模块 ID: module_usr
  11 +- 模块名: 用户管理(账户主数据 / 权限关联 / 列表查询 / 登录认证)
  12 +- 开发区间: 237a97e(master,含 module_mod merge)→ 6e0c0e7(test-gate evidence),共 27 个 commits
  13 +
  14 +## ② REQ 完成清单
  15 +
  16 +- [x] REQ-USR-001 — 用户新增(`POST /api/users`)
  17 + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-001.md
  18 + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-001.md
  19 + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-001.md(round 2 approve)
  20 +- [x] REQ-USR-002 — 用户修改(`PUT /api/users/{id}`)
  21 + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-002.md
  22 + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-002.md
  23 + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-002.md(round 1 approve)
  24 +- [x] REQ-USR-003 — 用户查询(`GET /api/users`)
  25 + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-003.md
  26 + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-003.md
  27 + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-003.md(round 2 approve)
  28 +- [x] REQ-USR-004 — 用户登录(`POST /api/auth/login`)
  29 + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-004.md
  30 + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-004.md
  31 + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-004.md(round 2 approve)
  32 +
  33 +## ③ 文件变更表
  34 +
  35 +| 文件 | 操作 | 说明 |
  36 +|---|---|---|
  37 +| backend/pom.xml | M | 追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6(REQ-USR-004 JWT 实现) |
  38 +| backend/src/main/java/com/xly/erp/common/response/ErrorCode.java | M | 追加 6 个常量:STAFF_NOT_FOUND / PERM_CATEGORY_NOT_FOUND / USR_NOT_FOUND / USR_USER_NAME_OR_NO_DUP / LOGIN_INVALID_CREDENTIALS / LOGIN_ACCOUNT_LOCKED |
  39 +| backend/src/main/java/com/xly/erp/common/response/PageResult.java | A | 通用分页 VO(REQ-USR-003 引入) |
  40 +| backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java | A | 携带 cooldownSeconds 的账号锁定异常(REQ-USR-004) |
  41 +| backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | M | 追加 AccountLockedException 专用 handler 把 cooldownSeconds 写入 ApiResponse.data |
  42 +| backend/src/main/java/com/xly/erp/config/PasswordConfig.java | A | BCryptPasswordEncoder bean(REQ-USR-001 引入) |
  43 +| backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java | A | PaginationInnerInterceptor 注册(REQ-USR-003 引入) |
  44 +| backend/src/main/java/com/xly/erp/module/usr/entity/{User,Staff,PermissionCategory,UserPermission}Entity.java | A | 4 张表实体;UserEntity.iStaffId 加 FieldStrategy.IGNORED(REQ-USR-002)|
  45 +| backend/src/main/java/com/xly/erp/module/usr/mapper/{User,Staff,PermissionCategory,UserPermission}Mapper.java | A | BaseMapper 子接口;UserMapper 追加 searchUsers 自定义 SQL 方法 |
  46 +| backend/src/main/resources/mapper/usr/UserMapper.xml | A | 跨表 JOIN tStaff + 动态 WHERE + CAST 处理 bit(1) deleted(REQ-USR-003)|
  47 +| backend/src/main/java/com/xly/erp/module/usr/dto/{UserCreate,UserUpdate,UserQuery,Login}DTO.java | A | 4 个 DTO(POST/PUT/GET/Login) |
  48 +| backend/src/main/java/com/xly/erp/module/usr/vo/{User,UserListItem,LoginResult}VO.java | A | 3 个 VO(创建/修改返回 / 列表行 / 登录结果含嵌套 LoginUserInfo) |
  49 +| backend/src/main/java/com/xly/erp/module/usr/service/{User,Login}Service.java + impl/{User,Login}ServiceImpl.java | A | 5 业务方法:UserService.create/update/search + LoginService.login |
  50 +| backend/src/main/java/com/xly/erp/module/usr/controller/{User,Login}Controller.java | A | 4 端点:POST /api/users / PUT /api/users/{id} / GET /api/users / POST /api/auth/login |
  51 +| backend/src/main/java/com/xly/erp/module/usr/security/{LoginAttemptStore,InMemoryLoginAttemptStore,JwtTokenProvider}.java | A | JWT 签发 + 5 次失败 15 min 内存锁定(REQ-USR-004) |
  52 +| backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java | M | 追加 6 个错误码断言 |
  53 +| backend/src/test/java/com/xly/erp/module/usr/dto/{UserCreate,UserUpdate,UserQuery,Login}DTOValidationTest.java | A | 4 套 Bean Validation 单测(共 18 个用例)|
  54 +| backend/src/test/java/com/xly/erp/module/usr/mapper/{UsrMappersIT,UserMapperSearchIT}.java | A | 4 表 insert/select smoke + searchUsers SQL IT |
  55 +| backend/src/test/java/com/xly/erp/module/usr/service/{User,Login}ServiceImplTest.java | A | Mockito 单测:30 个用例(9 create + 9 update + 5 search + 7 login)|
  56 +| backend/src/test/java/com/xly/erp/module/usr/controller/{User,Login}ControllerIT.java | A | MockMvc 集成:32 用例(7 POST + 8 PUT + 8 GET + 9 login) |
  57 +| backend/src/test/java/com/xly/erp/module/usr/security/{InMemoryLoginAttemptStore,JwtTokenProvider}Test.java | A | 7 单测(5 store + 2 jwt)|
  58 +| docs/08-模块任务管理.md | M | § 二 module_usr 4 个 REQ 全部勾选 |
  59 +| docs/superpowers/specs/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份功能规格 |
  60 +| docs/superpowers/plans/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份任务级实现计划 |
  61 +| docs/superpowers/reviews/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份 AI 审阅报告(含 round 2 修复闭环) |
  62 +| docs/superpowers/module-reports/module_usr-test-gate.md | A | 本模块 test-gate 闸门证据 |
  63 +
  64 +## ④ 数据库使用表
  65 +
  66 +- 读: `tUser`(4 个 REQ 都读)/ `tStaff`(USR-001 staff 校验 + USR-003 LEFT JOIN 列表 + USR-002 父校验)/ `tPermissionCategory`(USR-001 / USR-002 关联校验)/ `tUserPermission`(USR-002 重建关联前的 select)
  67 +- 写: `tUser`(USR-001 insert / USR-002 update / USR-004 update tLastLoginDate)/ `tUserPermission`(USR-001 insert 关联 / USR-002 重建关联 delete + insert)
  68 +
  69 +本模块**不写** `tStaff` 和 `tPermissionCategory`——这两张表当前是只读字典,spec 已注明若需增删改请新建独立模块。
  70 +
  71 +## ⑤ 测试结果
  72 +
  73 +- `scripts/test.sh` 最终:green
  74 +- 通过: 172 / 失败: 0 / 跳过: 0
  75 +- 覆盖率: 未启用 JaCoCo(与 module_mod 一致)
  76 +
  77 +测试分布:
  78 +- 单元测试约 81 个:DTO Validation 18 + ApiResponseTest 7 + GlobalExceptionHandlerTest 4 + ModuleServiceImplTest 26 + ModuleCreateDTOValidationTest 5 + UserServiceImplTest 21 + LoginServiceImplTest 7 + 其他(store / jwt 等)8
  79 +- 集成测试约 91 个:ApplicationTest 1 + SecurityConfigTest 1 + ModuleMapperIT 2 + ModuleControllerIT 28 + UsrMappersIT 4 + UserMapperSearchIT 2 + UserControllerIT 25 + LoginControllerIT 10 + 其他
  80 +
  81 +`./scripts/test.sh` 流程:setup-test-db.sh DROP+CREATE → mvn build → mvn lint(compile) → mvn test → frontend skip → e2e 略 → reset DB。耗时 ~25s。
  82 +
  83 +## ⑥ 本模块新增 Migration
  84 +
  85 +—(本模块未引入 schema 改动;tUser / tStaff / tPermissionCategory / tUserPermission 由 V1__initial_schema.sql 在 A4 阶段创建)
  86 +
  87 +## ⑦ 跨模块改动清单(软规则 S2)
  88 +
  89 +本模块改动了 `common/` 包下的横切组件,按 CLAUDE.md § 🟡 S2 登记:
  90 +
  91 +| 文件 | 改动 | 原因 | 影响评估 |
  92 +|---|---|---|---|
  93 +| common/response/ErrorCode.java | 追加 6 个常量(STAFF_NOT_FOUND / PERM_CATEGORY_NOT_FOUND / USR_NOT_FOUND / USR_USER_NAME_OR_NO_DUP / LOGIN_INVALID_CREDENTIALS / LOGIN_ACCOUNT_LOCKED) | USR 模块业务异常分类 | 仅追加,不修改既有常量;与 module_mod 共享 ErrorCode 段位(如 STAFF_NOT_FOUND(40421) 与 MOD_NOT_FOUND(40421) 数值相同语义不同),由枚举名 + message 区分。docs/05 全局错误码表后续 sweep 同步登记 |
  94 +| common/response/PageResult.java | 新建 | REQ-USR-003 引入通用分页 VO;下一模块需要分页时直接复用 | 横向组件;本模块单点引入,未来其他模块共享 |
  95 +| common/exception/AccountLockedException.java | 新建 | REQ-USR-004 携带 cooldownSeconds 的专用异常 | 仅 LoginService 抛;GlobalExceptionHandler 专用 handler 不影响其他 BizException 路径 |
  96 +| common/exception/GlobalExceptionHandler.java | 追加 `@ExceptionHandler(AccountLockedException.class)` | 把 cooldownSeconds 写入 ApiResponse.data | 既有 BizException / Validation / Exception handler 路径 0 改动;前端契约 100% 兼容 |
  97 +| config/PasswordConfig.java | 新建 | REQ-USR-001 引入 BCryptPasswordEncoder bean | 横向组件,REQ-USR-004 复用;无副作用 |
  98 +| config/MybatisPlusConfig.java | 新建 | REQ-USR-003 引入 PaginationInnerInterceptor | 影响所有 MP `Page<T>` 调用路径,但 module_mod 现有 mapper 未用 Page,无回归 |
  99 +
  100 +> **未自动生成 cross-module-log 存根**:log-cross-module.sh hook 未触发(可能因为 common/ 不在 hook 监控的"其他业务模块"路径中);本节内容由 module-report 直接登记。
  101 +
  102 +## ⑧ 偏离 spec 清单
  103 +
  104 +- **REQ-USR-001**:spec/plan 早期草稿要求 `tUserPermission.bSelected=1` 字段,与 docs/03 修订版(已删该列)不一致。fix commit 520c01f 把 spec/plan 中 bSelected 提及改为"无该列"注解;UserPermissionEntity 不含此字段。
  105 +- **REQ-USR-002**:iStaffId 加 `FieldStrategy.IGNORED` 让 NULL 写入生效——同 module_mod ModuleEntity.iParentId 的全局副作用;本期所有 update 路径走 load-then-modify 安全,但**未来 partial update 路径必须 selectById 后再 updateById**,否则 iStaffId 会被静默清空。
  106 +- **REQ-USR-003**:spec § 业务规则 6 deleted 字段过滤 round 1 review 发现 SQL 注入 + 实现缺陷;fix commit f53689c 改成 `LambdaUpdateWrapper` 的 `column` 通过 `@Param` 单独传入(防 GET query-string 绕过白名单)+ XML deleted 分支用 `CAST(#{queryValue} AS UNSIGNED)` 兼容 bit(1)。
  107 +- **REQ-USR-004 (CRITICAL)**:JwtTokenProviderTest round 1 提交时硬编码了与 .env.local 完全相同的生产 JWT_SECRET(commit b7ed804 已入 git history)。fix commit d439c0d 把测试 SECRET 改成与生产无关的 fake 值,**.env.local JWT_SECRET 已本地旋转为新随机值;旧值仍在 git history**——所有部署环境必须运维侧同步轮换 JWT_SECRET。
  108 +- **REQ-USR-004**:InMemoryLoginAttemptStore round 1 锁定到期不重置 count(business rule #4 不达成),fix commit d439c0d 修复 cooldownSeconds + recordFailure 双路径处理过期 reset。
  109 +- **REQ-USR-004 (范围说明已声明)**:本期**仅签发 JWT**,不切换其他端点为 authenticated;module_mod 4 端点 + module_usr 3 端点(POST/PUT/GET)仍 permitAll。这是已知技术债(spec § 范围说明 + § ⑩ 已知问题登记)。
  110 +- **REQ-USR-004**:spec § 业务规则 7 客户端 IP 审计未实施(log.info 没拿 HttpServletRequest.getRemoteAddr());登记为已知 gap。
  111 +- **跨 REQ — 多租户字段 / sCreatedBy 留 NULL**:所有写入路径中 `sBrandsId / sSubsidiaryId / sCreatedBy` 都落 NULL;同 module_mod 一致,等 REQ-USR-XXX 引入登录上下文 / 多租户拦截器后回填。
  112 +
  113 +## ⑨ AI reviewer 报告汇总
  114 +
  115 +- REQ-USR-001: round 1 — request-changes(spec/plan 残留 bSelected)→ round 2 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-USR-001.md,修复 commit 520c01f)
  116 +- REQ-USR-002: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-USR-002.md)
  117 +- REQ-USR-003: round 1 — request-changes(HIGH SQL 注入 column 字段绕过 + spec § 6 deleted 未实现 + IT 静默移除 + XML 边界)→ round 2 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-USR-003.md,修复 commit f53689c)
  118 +- REQ-USR-004: round 1 — request-changes(CRITICAL 测试 SECRET 与生产一致 + HIGH 锁定到期不重置 + MEDIUM 验收 #9 无测试)→ round 2 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-USR-004.md,修复 commit d439c0d)
  119 +
  120 +## ⑩ 已知问题
  121 +
  122 +1. **JWT_SECRET 已永久污染 git history**:commit b7ed804(JwtTokenProviderTest 早期版本)含与 .env.local 相同的 32 字节 JWT_SECRET。.env.local 已旋转为新随机值。**所有部署环境必须由运维同步轮换 JWT_SECRET**——任何生产 / 测试 / 演示环境若仍用旧 secret 视同已泄露,必须签发新 secret 并强制所有用户重新登录。建议下一 sweep 评估 BFG / git-filter-repo 重写 history 移除该 secret。
  123 +2. **鉴权延期未清算**:本 REQ 落地登录 + JWT 签发,但 module_mod 4 端点 + module_usr 3 端点(POST /api/users / PUT /api/users/{id} / GET /api/users)仍 permitAll,没有 @PreAuthorize 也没 JwtAuthenticationFilter 拦截 token。技术债:(a) 切 SecurityFilterChain 为 authenticated();(b) 写 JwtAuthenticationFilter 校验 token 并装 SecurityContext;(c) 给所有 controller 方法加 @PreAuthorize;(d) 改造既有 IT 携带 token。这是中等量工作(约 1-2 个 sweep day),独立工时排期。
  124 +3. **多租户字段 / sCreatedBy NULL**:与 module_mod 一致;REQ-USR-XXX 引入登录上下文后回填 + 必要时 V_n migration 给历史数据补默认值 + 视业务决策收紧 schema 为 NOT NULL。
  125 +4. **iStaffId IGNORED 全局副作用**:UserEntity.iStaffId 与 ModuleEntity.iParentId 同类风险——任何 partial updateById 路径都会静默清空该列。本期所有路径走 load-then-modify 安全;未来贡献者新增 partial path 必须复用 LambdaUpdateWrapper.set(...) 模式。
  126 +5. **InMemoryLoginAttemptStore 单机限制**:5 次失败锁定在多实例部署下不工作;spec 已声明 Redis 替换留作后续 REQ。`expireLockForTest` 包私有调试入口暴露在生产 jar 中——下一 sweep 移到 src/test 的 testutil。
  127 +6. **客户端 IP 审计未实施**:spec § 业务规则 7 要求 log.info 含客户端 IP;当前 LoginService 只记 sUserName。建议后续在 LoginController 注入 HttpServletRequest.getRemoteAddr() 传给 service。
  128 +7. **Redis 凭据 / 部署**:docs/04 § 零 列了 Redis;本仓库 .env.local 未配 Redis;REQ-USR-004 内存锁定 + 后续可能的会话 / 缓存特性都待 Redis 接入后才能演进。
  129 +8. **REQ-USR-002 spec 验收 #11 IT 回滚证据**:受 IT @Transactional+@Rollback 包裹,service 层回滚无法在 IT 中观测;service 单测已覆盖语义,IT 留 nice-to-have。
  130 +9. **REQ-USR-003 nice-to-have 6 条 IT 缺失**:department equals / deleted=false 显式 / notContains / 排序 / matchType 非枚举 / 空结果 IT;service 单测已覆盖核心。
  131 +10. **docs/05 错误码段位与实际实现偏差**:docs/05 § REQ-USR-001 写 40020 段位;实现统一用 40010 PARAM_INVALID。docs sweep 时对齐。
  132 +11. **JacksonConfig 字段访问可见性配置全局生效**:影响所有 DTO/VO 的 JSON 序列化;当前所有字段都用 @Data 暴露,没有 @JsonIgnore 跳过敏感字段的需求;sPasswordHash 不暴露是因为 UserVO / LoginResultVO.LoginUserInfo / UserListItemVO 都不含该字段而非 @JsonIgnore。后续若有需要隐藏字段的 VO,需重新评估全局策略。
  133 +
  134 +## ⑪ 下一模块预览
  135 +
  136 +按 docs/02 § 二 顺序,**module_usr 是最后一个模块**——module_mod 已 merge,本 module_usr 是项目计划中的第二个也是最终模块。MR merge 后即视为「全部 REQ 完成」。
  137 +
  138 +后续工作(不属于 docs/02 计划清单的 REQ):
  139 +- **鉴权清算 sweep**:切 SecurityFilterChain 到 authenticated + JwtAuthenticationFilter + 所有 controller @PreAuthorize + 既有 IT 携带 token 改造(见 § ⑩ #2)。
  140 +- **JWT_SECRET history 清理**:评估是否需要 BFG / git-filter-repo 重写 history(见 § ⑩ #1)。
  141 +- **多租户上下文 + Redis 引入**:补完 sBrandsId / sSubsidiaryId / sCreatedBy 写入;用 Redis 替换 InMemoryLoginAttemptStore(见 § ⑩ #3, #5, #7)。
  142 +- **前端**:本期完全无 frontend 实现;docs/06 § 五 已规划用户管理页面 + 登录页,待前端工程接入后实现。
  143 +
  144 +## ⑫ MR 链接
  145 +
  146 +http://git.xlyprint.cn/zhuzc/test2/merge_requests/2 (!2)
docs/superpowers/module-reports/module_usr-test-gate.md 0 → 100644
  1 +## Local test gate — module_usr
  2 +
  3 +执行时间: 2026-05-07T09:33:12+08:00
  4 +
  5 +### scripts/test.sh (subagent)
  6 +- 子会话: a04f12f4a6f932a7d
  7 +- 命令: `./scripts/test.sh`(setup-test-db.sh DROP+CREATE → mvn build/lint/test → frontend skip → e2e 略 → setup-test-db.sh reset)
  8 +- 退出码: 0
  9 +- 通过: 172 / 失败: 0
  10 +- 关键 stdout (≤30 行):
  11 +
  12 +```
  13 +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest
  14 +[INFO]
  15 +[INFO] Results:
  16 +[INFO]
  17 +[INFO] Tests run: 172, Failures: 0, Errors: 0, Skipped: 0
  18 +[INFO]
  19 +[INFO] ------------------------------------------------------------------------
  20 +[INFO] BUILD SUCCESS
  21 +[INFO] ------------------------------------------------------------------------
  22 +[INFO] Total time: 25.464 s
  23 +[INFO] Finished at: 2026-05-07T09:33:12+08:00
  24 +[INFO] ------------------------------------------------------------------------
  25 +[test.sh] skip frontend test
  26 +[test.sh] 5/6 E2E
  27 +[test.sh] e2e 略
  28 +[test.sh] 6/6 reset test db
  29 +[setup-test-db] 即将 DROP + CREATE `xlyweberp_vibe_erp_test` on 118.178.19.35:3318
  30 +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts
  31 +[test.sh] GREEN
  32 +```
  33 +
  34 +结论: green
docs/superpowers/plans/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-001.md
  5 +---
  6 +
  7 +# REQ-USR-001 用户新增 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。
  12 +
  13 +**Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。
  14 +
  15 +**Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(V1 已建 5 张表 + FK + UNIQUE 索引)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量
  26 +- 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java`
  28 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java`
  29 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java`
  30 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java`
  31 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java`
  32 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java`
  33 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java`
  34 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  35 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  36 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  37 +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言
  38 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java`
  39 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test
  40 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测
  41 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试
  42 +
  43 +---
  44 +
  45 +## 任务步骤
  46 +
  47 +### Task 1: 错误码 + PasswordConfig
  48 +
  49 +**Files:**
  50 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  51 +- Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java`
  52 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
  53 +
  54 +**API shape:**
  55 +- 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同):
  56 + - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")`
  57 + - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")`
  58 + - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")`
  59 +
  60 + > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。
  61 +- `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }`
  62 +
  63 +- [ ] **Step 1.1 写失败断言**
  64 + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行:
  65 + ```
  66 + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421);
  67 + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422);
  68 + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921);
  69 + ```
  70 + - 子会话: FAIL
  71 +
  72 +- [ ] **Step 1.2 实现 ErrorCode + PasswordConfig**
  73 + - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证)
  74 +
  75 +- [ ] **Step 1.3 提交**
  76 + - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"`
  77 +
  78 +---
  79 +
  80 +### Task 2: 4 张表 entity + mapper + Mapper smoke IT
  81 +
  82 +**Files:**
  83 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java`
  84 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java`
  85 +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java`
  86 +
  87 +**API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式):
  88 +
  89 +| Entity | 表 | 关键字段(不含 5 个标准列) |
  90 +|---|---|---|
  91 +| `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  92 +| `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  93 +| `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  94 +| `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) |
  95 +
  96 +每个 mapper `extends BaseMapper<XxxEntity>`,无自定义 SQL。
  97 +
  98 +> **注意**:spec § Service 实现依赖 `tUser.iStaffId` 设置为 NULL 时不应被 IGNORED 策略影响(参考 REQ-MOD-002 经验)。本期 UserEntity 字段 **不**给 iStaffId 加 `FieldStrategy.IGNORED`——因为 user create 走 insert(默认 NOT_NULL 策略对 insert 没问题:null 字段被跳过,DB 列默认 NULL),不会触发 update path 上的副作用。如未来 REQ-USR-002 需要 update 中清空 iStaffId,再按 REQ-MOD-003 经验用 `LambdaUpdateWrapper.set(...)` 处理。
  99 +
  100 +- [ ] **Step 2.1 写失败 IT**
  101 + - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返)
  102 + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback`
  103 + - 子会话: FAIL(entity / mapper 不存在)
  104 +
  105 +- [ ] **Step 2.2 实现 4 entity + 4 mapper**
  106 + - 子会话: PASS
  107 +
  108 +- [ ] **Step 2.3 提交**
  109 + - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"`
  110 +
  111 +---
  112 +
  113 +### Task 3: UserCreateDTO + UserVO + Validation
  114 +
  115 +**Files:**
  116 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java`
  117 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java`
  118 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java`
  119 +
  120 +**API shape:**
  121 +
  122 +`UserCreateDTO`:
  123 +- `@NotBlank @Size(max=50) String sUserNo`
  124 +- `@NotBlank @Size(max=50) String sUserName`
  125 +- `Integer iStaffId`(可空)
  126 +- `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType`
  127 +- `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage`
  128 +- `Boolean bCanModifyDocs`(可空,service 层 default false)
  129 +- `List<Integer> permissionCategoryIds`(可空 / 空数组)
  130 +
  131 +`UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List<Integer> permissionCategoryIds)`。
  132 +
  133 +- [ ] **Step 3.1 写失败测试(5 个)**
  134 + - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations`
  135 + - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank)
  136 + - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation`
  137 + - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation`
  138 + - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations`
  139 + - 子会话: FAIL
  140 +
  141 +- [ ] **Step 3.2 实现 DTO + VO**
  142 + - 子会话: PASS
  143 +
  144 +- [ ] **Step 3.3 提交**
  145 + - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"`
  146 +
  147 +---
  148 +
  149 +### Task 4: UserService.create + Mockito 单元测试
  150 +
  151 +**Files:**
  152 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  153 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  154 +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  155 +
  156 +**API shape:**
  157 +- `interface UserService { UserVO create(UserCreateDTO dto); }`
  158 +- `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。
  159 +- `create(dto)` 步骤(plan 锁定):
  160 + 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。
  161 + 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。
  162 + 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。
  163 + 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。
  164 + 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。
  165 + 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。
  166 + 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。
  167 +- 标 `@Transactional(rollbackFor = Exception.class)`
  168 +
  169 +**初始密码常量**(写在 plan 锁定):
  170 +```
  171 +private static final String INITIAL_PASSWORD = "666666";
  172 +```
  173 +后续若策略变化,service 单点修改。
  174 +
  175 +- [ ] **Step 4.1 写失败测试(9 个)**
  176 + - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值
  177 + - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段
  178 + - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0
  179 + - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0
  180 + - `create_staffNotFound_throws40421`:staffMapper.selectById → null
  181 + - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true
  182 + - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短
  183 + - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调
  184 + - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException
  185 + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<UserEntity>` / `ArgumentCaptor<UserPermissionEntity>`
  186 + - 子会话: FAIL
  187 +
  188 +- [ ] **Step 4.2 实现 UserService + Impl**
  189 + - 子会话: PASS
  190 +
  191 +- [ ] **Step 4.3 提交**
  192 + - `git commit -m "feat(usr): create user service REQ-USR-001"`
  193 +
  194 +---
  195 +
  196 +### Task 5: UserController + 端到端 IT
  197 +
  198 +**Files:**
  199 +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  200 +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  201 +
  202 +**API shape:**
  203 +- `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController`
  204 +- `@PostMapping ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto)`
  205 +- Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`
  206 +
  207 +- [ ] **Step 5.1 写失败测试(7 个)**
  208 + - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[]
  209 + - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配
  210 + - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName
  211 + - `post_staffNotFound_returns40421`:iStaffId=999999
  212 + - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999]
  213 + - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666"
  214 + - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist
  215 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper`
  216 + - 子会话: FAIL(端点不存在)
  217 +
  218 +- [ ] **Step 5.2 实现 UserController**
  219 + - 子会话: PASS
  220 +
  221 +- [ ] **Step 5.3 跑全量 backend 测试**
  222 + - `cd backend && mvn -B test`
  223 + - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。
  224 +
  225 +- [ ] **Step 5.4 提交**
  226 + - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"`
  227 +
  228 +---
  229 +
  230 +## 提交计划
  231 +
  232 +- `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1)
  233 +- `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2)
  234 +- `feat(usr): user create DTO and VO REQ-USR-001`(Task 3)
  235 +- `feat(usr): create user service REQ-USR-001`(Task 4)
  236 +- `feat(usr): POST /api/users controller REQ-USR-001`(Task 5)
docs/superpowers/plans/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-002.md
  5 +---
  6 +
  7 +# REQ-USR-002 用户修改 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 实现 `PUT /api/users/{id}`:除 sUserNo / sUserName / sPasswordHash 外的字段全量替换 + 权限组重建(先删后插),返回 UserVO。
  12 +
  13 +**Architecture:** 复用 REQ-USR-001 的 entity/mapper/service/exception/Jackson 体系。Service load-then-modify:`selectById` → 校验 + 字段合并 → `updateById`(user)→ `delete`(关联) + 循环 `insert`(关联)。`iStaffId` 字段加 `FieldStrategy.IGNORED` 让 NULL 写入生效。
  14 +
  15 +**Tech Stack:** 沿用前序 REQ。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `USR_NOT_FOUND(40431, "用户不存在或已删除")`
  26 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` — `iStaffId` 加 `updateStrategy = FieldStrategy.IGNORED`
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java`
  28 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `update(Integer id, UserUpdateDTO dto): UserVO`
  29 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update
  30 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@PutMapping("/{id}")`
  31 +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言
  32 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java`
  33 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 9 个 update 单测
  34 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 8 个 PUT 集成测试
  35 +
  36 +---
  37 +
  38 +## 任务步骤
  39 +
  40 +### Task 1: 错误码追加 + UserEntity.iStaffId IGNORED
  41 +
  42 +**Files:**
  43 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  44 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java`
  45 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
  46 +
  47 +**API shape:**
  48 +- `USR_NOT_FOUND(40431, "用户不存在或已删除")` 追加到 ErrorCode 枚举
  49 +- `UserEntity#iStaffId` 字段注解改为 `@TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)`,并加注释说明(与 REQ-MOD-002 / module_mod 同样的副作用警告)
  50 +
  51 +- [ ] **Step 1.1 写失败断言**
  52 + - `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 追加 `assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);`
  53 + - 子会话: FAIL
  54 +
  55 +- [ ] **Step 1.2 实现错误码 + Entity 注解**
  56 + - 子会话验证:`mvn -B test`(全量;让 SpringBootTest 预热 lambda cache)应仍 PASS(USR-001 现有用例 + 新断言)
  57 +
  58 +- [ ] **Step 1.3 提交**
  59 + - `git commit -m "feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002"`
  60 +
  61 +---
  62 +
  63 +### Task 2: UserUpdateDTO + 校验单测
  64 +
  65 +**Files:**
  66 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java`
  67 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java`
  68 +
  69 +**API shape:**
  70 +- 字段(与 UserCreateDTO 相比剥除 `sUserNo` / `sUserName`):
  71 + - `Integer iStaffId`(可空)
  72 + - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType`
  73 + - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage`
  74 + - `Boolean bCanModifyDocs`(可空,service 层语义:null 保留原值)
  75 + - `List<Integer> permissionCategoryIds`(可空,空数组 / null 都视为清空)
  76 +
  77 +- [ ] **Step 2.1 写失败测试(4 个)**
  78 + - `UserUpdateDTOValidationTest#allValidFields_yieldsNoViolations`
  79 + - `UserUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(2 个 @NotBlank)
  80 + - `UserUpdateDTOValidationTest#invalidUserTypeEnum_yieldsViolation`
  81 + - `UserUpdateDTOValidationTest#invalidLanguageEnum_yieldsViolation`
  82 + - 子会话: FAIL
  83 +
  84 +- [ ] **Step 2.2 实现 DTO**
  85 + - 子会话: PASS
  86 +
  87 +- [ ] **Step 2.3 提交**
  88 + - `git commit -m "feat(usr): user update DTO REQ-USR-002"`
  89 +
  90 +---
  91 +
  92 +### Task 3: UserService.update + Mockito 单测
  93 +
  94 +**Files:**
  95 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`(追加方法签名)
  96 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  97 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`(追加 9 个测试)
  98 +
  99 +**API shape:**
  100 +- `UserService.update(Integer id, UserUpdateDTO dto): UserVO`
  101 +- 实现步骤(plan 锁定):
  102 + 1. `target = userMapper.selectById(id)`;`null` 或 `bDeleted=true` → `BizException(USR_NOT_FOUND)`
  103 + 2. iStaffId 校验(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` null / bDeleted → `BizException(STAFF_NOT_FOUND)`
  104 + 3. 权限分类校验(仅当 dto.permissionCategoryIds 非空):`selectBatchIds` 长度 / bDeleted 检查 → `BizException(PERM_CATEGORY_NOT_FOUND)`
  105 + 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy / tLastLoginDate / 多租户 / sId / bDeleted 三件套):
  106 + - `target.setIStaffId(dto.getIStaffId())`(含 null 清空)
  107 + - `target.setSUserType(dto.getSUserType())`
  108 + - `target.setSLanguage(dto.getSLanguage())`
  109 + - `if (dto.getBCanModifyDocs() != null) target.setBCanModifyDocs(...)`(部分更新)
  110 + 5. `userMapper.updateById(target)`
  111 + 6. 重建权限关联:
  112 + - `userPermissionMapper.delete(LambdaQueryWrapper.eq(iUserId, id))` 清空所有
  113 + - 若 dto.permissionCategoryIds 非空 → 循环 insert(每条 iUserId / iCategoryId / tCreateDate=now)
  114 + 7. 返回 `UserVO.from(target, dto.permissionCategoryIds 或 [])`
  115 +- 标 `@Transactional(rollbackFor = Exception.class)`
  116 +
  117 +- [ ] **Step 3.1 写失败测试(9 个)**
  118 + - `update_targetNotFound_throws40431`
  119 + - `update_targetSoftDeleted_throws40431`
  120 + - `update_staffNotFound_throws40421`
  121 + - `update_staffSoftDeleted_throws40421`
  122 + - `update_permissionCategoryNotFound_throws40422`
  123 + - `update_full_returnsVOWithUpdatedFields_andRebuildsPermissions`:mock target;ArgumentCaptor 验
  124 + - user 已修改字段:iStaffId / sUserType / sLanguage / bCanModifyDocs
  125 + - user 保留字段:sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy
  126 + - userPermissionMapper.delete 调一次(wrapper 含 eq iUserId);insert 调 N 次(按 dto 的 ids)
  127 + - `update_partialNullBCanModifyDocs_keepsOriginal`
  128 + - `update_clearStaffId_setsToNull`:dto.iStaffId=null,断言 captor entity.iStaffId == null
  129 + - `update_emptyPermissionCategoryIds_clearsAllAssociations`:dto.permissionCategoryIds=[],verify userPermissionMapper.delete 被调一次 + insert never
  130 + - 子会话: FAIL
  131 +
  132 +- [ ] **Step 3.2 实现 service.update**
  133 + - 子会话: PASS(含原 9 个 USR-001 单测 + 9 个 USR-002 单测共 18 个)
  134 +
  135 +- [ ] **Step 3.3 提交**
  136 + - `git commit -m "feat(usr): update user service REQ-USR-002"`
  137 +
  138 +---
  139 +
  140 +### Task 4: UserController PUT + 端到端 IT
  141 +
  142 +**Files:**
  143 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  144 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`(追加 8 个 PUT IT)
  145 +
  146 +**API shape:**
  147 +- `@PutMapping("/{id}") ApiResponse<UserVO> update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto)`
  148 +- Javadoc:`REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`
  149 +
  150 +- [ ] **Step 4.1 写失败测试(8 个)**
  151 + - `put_validUpdate_returns200_andDbReflects`:先 mapper.insert 一个 user + staff + 3 个 cat + 3 个 userPermission;PUT 改 staff(另一个) + sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新 cat);断言 200 + reload 验证字段 + 关联表替换为 2 条
  152 + - `put_clearStaffId_setsNull`:原 user.iStaffId=staffId,PUT 时 iStaffId=null,断言 DB user.iStaffId IS NULL
  153 + - `put_emptyPermissionCategoryIds_clearsAssociations`:原有 3 条关联,PUT 传 [],断言 DB tUserPermission count = 0
  154 + - `put_targetNotFound_returns40431`
  155 + - `put_staffNotFound_returns40421`
  156 + - `put_permissionCategoryNotFound_returns40422`
  157 + - `put_missingRequired_returns40010`:缺 sUserType
  158 + - `put_ignoresProtectedFields_doesNotChangeUserNoOrName`:手工拼 body 含 sUserNo / sUserName / sPasswordHash 字段,PUT 后 reload;断言 sUserNo / sUserName / sPasswordHash 与原值相同
  159 + - 子会话: FAIL
  160 +
  161 +- [ ] **Step 4.2 实现 PUT 端点**
  162 + - 子会话: PASS
  163 +
  164 +- [ ] **Step 4.3 跑全量 backend 测试**
  165 + - `cd backend && mvn -B test`
  166 + - 期望 101 + 1(新错误码)+ 4(DTO valid)+ 9(service unit)+ 8(controller IT)= 123 测试,全绿
  167 +
  168 +- [ ] **Step 4.4 提交**
  169 + - `git commit -m "feat(usr): PUT /api/users/{id} controller REQ-USR-002"`
  170 +
  171 +---
  172 +
  173 +## 提交计划
  174 +
  175 +- `feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002`(Task 1)
  176 +- `feat(usr): user update DTO REQ-USR-002`(Task 2)
  177 +- `feat(usr): update user service REQ-USR-002`(Task 3)
  178 +- `feat(usr): PUT /api/users/{id} controller REQ-USR-002`(Task 4)
docs/superpowers/plans/2026-05-06-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-003.md
  5 +---
  6 +
  7 +# REQ-USR-003 用户查询 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 实现 `GET /api/users` 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult<UserListItemVO>。
  12 +
  13 +**Architecture:** 引入 MP `PaginationInnerInterceptor` + 通用 `PageResult<T>`。Mapper.xml 自定义 `searchUsers` SQL(LEFT JOIN tStaff + dynamic WHERE)。Service 层做 queryField 白名单校验防 SQL 注入,把白名单 column 字符串放入 query 对象传给 mapper。
  14 +
  15 +**Tech Stack:** 沿用前序 REQ;首次启用 MP 分页插件 + XML mapper。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 创建: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` — 通用分页 VO
  26 +- 创建: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — `PaginationInnerInterceptor` bean
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java`
  28 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java`
  29 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)` 方法签名
  30 +- 创建: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL
  31 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `search(UserQueryDTO query): PageResult<UserListItemVO>`
  32 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 search + 白名单
  33 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@GetMapping`
  34 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java`
  35 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 search 单测
  36 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 GET IT
  37 +
  38 +---
  39 +
  40 +## 任务步骤
  41 +
  42 +### Task 1: PageResult<T> + MybatisPlusConfig(横切骨架)
  43 +
  44 +**Files:**
  45 +- Create: `backend/src/main/java/com/xly/erp/common/response/PageResult.java`
  46 +- Create: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java`
  47 +
  48 +**API shape:**
  49 +- `PageResult<T>`:字段 `long total` + `List<T> list` + `long pageNum` + `long pageSize`;@Data + 静态工厂 `of(IPage<T> mpPage)`(从 MP IPage 构造)
  50 +- `MybatisPlusConfig`:`@Bean MybatisPlusInterceptor mybatisPlusInterceptor()` 注册 `PaginationInnerInterceptor(DbType.MYSQL)`
  51 +
  52 +- [ ] **Step 1.1 实现两个文件(无独立单测,由 Task 4 的 Mapper IT 验证分页)**
  53 +
  54 +- [ ] **Step 1.2 子会话 mvn 全量测试**(验证 SpringBoot context 启动 + 122 现有测试不回归)
  55 +
  56 +- [ ] **Step 1.3 提交**
  57 + - `git commit -m "feat(common): PageResult + MP pagination config REQ-USR-003"`
  58 +
  59 +---
  60 +
  61 +### Task 2: UserQueryDTO + UserListItemVO + Validation
  62 +
  63 +**Files:**
  64 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java`
  65 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java`
  66 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java`
  67 +
  68 +**API shape:**
  69 +
  70 +`UserQueryDTO`:
  71 +- `@Min(1) Integer pageNum = 1`(默认)
  72 +- `@Min(1) @Max(100) Integer pageSize = 20`
  73 +- `@Pattern(regexp="^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$") String queryField`(可空)
  74 +- `@Pattern(regexp="^(contains|notContains|equals)?$") String matchType`(可空)
  75 +- `@Size(max=100) String queryValue`(可空)
  76 +
  77 +`UserListItemVO`:11 字段(spec § 输出)。Lombok `@Data`,无静态工厂(mapper 直接通过 ResultMap / autoMap 映射)。
  78 +
  79 +- [ ] **Step 2.1 写失败测试(5 个)**
  80 + - `UserQueryDTOValidationTest#allValid_yieldsNoViolations`(含 default 值)
  81 + - `UserQueryDTOValidationTest#pageSizeTooLarge_yieldsViolation`(>100)
  82 + - `UserQueryDTOValidationTest#pageSizeTooSmall_yieldsViolation`(<1)
  83 + - `UserQueryDTOValidationTest#queryFieldInvalidEnum_yieldsViolation`
  84 + - `UserQueryDTOValidationTest#queryValueOverSized_yieldsViolation`(101 字符)
  85 + - 子会话: FAIL
  86 +
  87 +- [ ] **Step 2.2 实现 DTO + VO**
  88 +
  89 +- [ ] **Step 2.3 提交**
  90 + - `git commit -m "feat(usr): user query DTO + list item VO REQ-USR-003"`
  91 +
  92 +---
  93 +
  94 +### Task 3: UserMapper.xml searchUsers + Mapper smoke IT
  95 +
  96 +**Files:**
  97 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`(追加 `IPage<UserListItemVO> searchUsers(...)` 方法签名)
  98 +- Create: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL(spec § 实现路径选择 已锁定 SQL 模板)
  99 +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java`(新文件,独立 IT)
  100 +
  101 +**XML SQL 锁定**(spec 已写):
  102 +
  103 +```xml
  104 +<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
  105 + SELECT
  106 + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
  107 + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
  108 + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  109 + FROM tUser u
  110 + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
  111 + <where>
  112 + <if test="query.queryField != 'deleted'">
  113 + u.bDeleted = 0
  114 + </if>
  115 + <if test="query.column != null and query.column != '' and query.queryValue != null and query.queryValue != ''">
  116 + AND
  117 + <choose>
  118 + <when test="query.matchType == 'equals'">${query.column} = #{query.queryValue}</when>
  119 + <when test="query.matchType == 'notContains'">${query.column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')</when>
  120 + <otherwise>${query.column} LIKE CONCAT('%', #{query.queryValue}, '%')</otherwise>
  121 + </choose>
  122 + </if>
  123 + </where>
  124 + ORDER BY u.tCreateDate DESC, u.iIncrement DESC
  125 +</select>
  126 +```
  127 +
  128 +> `query.column` 字段是 service 层白名单映射后的 SQL 列字符串(如 `"u.sUserName"`),由 `${...}` 渲染——**绝不**接受 DTO 原 queryField 直接拼。
  129 +
  130 +为支持 `${query.column}`,需要在 `UserQueryDTO` 加一个 transient 字段 `String column`(service 写入;前端不接受)。
  131 +
  132 +- [ ] **Step 3.1 写失败 IT**
  133 + - `UserMapperSearchIT#searchUsers_emptyFilter_returnsAllUndeletedAsPage`:插入 2 个 user(含 1 个 staff 关联),`@Autowired UserMapper`,调用 `userMapper.searchUsers(new Page<>(1,10), query)`;断言 page.getTotal() ≥ 2、page.getRecords() 含 sUserName + sStaffName 字段
  134 + - `UserMapperSearchIT#searchUsers_filterByUserName_filtersCorrectly`:插入 alice / bob;query.queryField=username, column="u.sUserName", matchType=contains, queryValue="ali";断言只返回 alice
  135 + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper`
  136 + - 子会话: FAIL(searchUsers 方法未定义)
  137 +
  138 +- [ ] **Step 3.2 实现 mapper 方法签名 + XML + UserQueryDTO 加 column 字段**
  139 + - 子会话: PASS
  140 +
  141 +- [ ] **Step 3.3 提交**
  142 + - `git commit -m "feat(usr): UserMapper.xml searchUsers REQ-USR-003"`
  143 +
  144 +---
  145 +
  146 +### Task 4: UserService.search + Mockito 单测
  147 +
  148 +**Files:**
  149 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  150 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  151 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  152 +
  153 +**API shape:**
  154 +- `UserService.search(UserQueryDTO query): PageResult<UserListItemVO>`
  155 +- 实现步骤(plan 锁定):
  156 + 1. 白名单校验 + 列映射:
  157 + ```
  158 + Map<String,String> COLUMN_MAP = Map.of(
  159 + "username", "u.sUserName", "staffname", "s.sStaffName",
  160 + "userno", "u.sUserNo", "department", "s.sDepartment",
  161 + "usertype", "u.sUserType", "language", "u.sLanguage",
  162 + "deleted", "u.bDeleted", "lastLoginDate", "u.tLastLoginDate",
  163 + "createdBy", "u.sCreatedBy");
  164 + ```
  165 + 若 `query.queryField` 非空但不在 map → `BizException(PARAM_INVALID, "queryField 非法")`;
  166 + 若 `query.matchType` 非空但不在 {contains, notContains, equals} → 同样错误。
  167 + 2. 把映射后的列字符串写到 `query.setColumn(mappedCol)`;如果 queryField 为空,column 也为空。
  168 + 3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。
  169 + 4. 构造 `Page<UserListItemVO> page = new Page<>(query.getPageNum(), query.getPageSize())`
  170 + 5. 调 `userMapper.searchUsers(page, query)`
  171 + 6. 返回 `PageResult.of(result)`
  172 +- 标 `@Transactional(readOnly = true)`
  173 +
  174 +- [ ] **Step 4.1 写失败测试(5 个)**
  175 + - `search_emptyDb_returnsEmptyPage`:mock searchUsers 返回 empty Page;service 返回 PageResult total=0
  176 + - `search_invalidQueryField_throws40010`:query.queryField="invalid"
  177 + - `search_invalidMatchType_throws40010`:query.matchType="like"
  178 + - `search_passesMappedColumnToMapper`:query.queryField="username";ArgumentCaptor 捕 query 实参,断言 query.column == "u.sUserName"
  179 + - `search_appliesDefaultPagination_whenNullPageNumOrSize`:query.pageNum=null, pageSize=null;断言 service 创建的 Page.size==20 && current==1
  180 + - 子会话: FAIL
  181 +
  182 +- [ ] **Step 4.2 实现 service.search**
  183 + - 子会话: PASS
  184 +
  185 +- [ ] **Step 4.3 提交**
  186 + - `git commit -m "feat(usr): user query service REQ-USR-003"`
  187 +
  188 +---
  189 +
  190 +### Task 5: UserController GET + 端到端 IT
  191 +
  192 +**Files:**
  193 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  194 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  195 +
  196 +**API shape:**
  197 +- `@GetMapping ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query)`
  198 +- Javadoc:`REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`
  199 +
  200 +- [ ] **Step 5.1 写失败测试(9 个)**
  201 + - `get_emptyKeyword_returnsAllUndeleted`
  202 + - `get_filterByUsernameContains_returnsMatchedSubset`
  203 + - `get_filterByStaffnameContains_returnsJoinedResults`
  204 + - `get_filterByDeletedTrue_returnsOnlyDeleted`
  205 + - `get_pagination_returnsCorrectSlice`
  206 + - `get_responseExcludesInternalFields`:断言 jsonPath `$.data.list[0].sPasswordHash` doesNotExist + sId / iStaffId / sBrandsId 都不出现
  207 + - `get_pageSizeTooLarge_returns40010`
  208 + - `get_invalidQueryField_returns40010`
  209 + - `get_userWithoutStaff_listItemHasNullStaffFields`
  210 + - `@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + insert helpers
  211 + - 子会话: FAIL
  212 +
  213 +- [ ] **Step 5.2 实现 GET 端点**
  214 + - 子会话: PASS
  215 +
  216 +- [ ] **Step 5.3 子会话跑全量 mvn test**
  217 + - 期望:122 + 5(query DTO valid) + 5(service search unit) + 2(mapper IT) + 9(controller IT) = 143 测试,全绿
  218 +
  219 +- [ ] **Step 5.4 提交**
  220 + - `git commit -m "feat(usr): GET /api/users controller REQ-USR-003"`
  221 +
  222 +---
  223 +
  224 +## 提交计划
  225 +
  226 +- `feat(common): PageResult + MP pagination config REQ-USR-003`(Task 1)
  227 +- `feat(usr): user query DTO + list item VO REQ-USR-003`(Task 2)
  228 +- `feat(usr): UserMapper.xml searchUsers REQ-USR-003`(Task 3)
  229 +- `feat(usr): user query service REQ-USR-003`(Task 4)
  230 +- `feat(usr): GET /api/users controller REQ-USR-003`(Task 5)
docs/superpowers/plans/2026-05-06-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-004.md
  5 +---
  6 +
  7 +# REQ-USR-004 用户登录 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 实现 `POST /api/auth/login`:BCrypt 校验密码 → 5 次失败锁定(内存)→ 签发 HS256 JWT → 回写 tLastLoginDate。
  12 +
  13 +**Architecture:** 引入 jjwt 0.12.x 签发 JWT;`LoginAttemptStore` 接口 + `InMemoryLoginAttemptStore` 实现(spec 注:Redis 替换为后续 REQ);`JwtTokenProvider` 封装 sign/parse;`LoginService` 协调 4 步逻辑;`SecurityConfig` 白名单 `/api/auth/login`。本 REQ 不切换其他端点为 authenticated(技术债登记)。
  14 +
  15 +**Tech Stack:** 沿用 + 新增 jjwt-api/impl/jackson 0.12.x。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 修改: `backend/pom.xml` — 追加 jjwt 三件套
  26 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 LOGIN_INVALID_CREDENTIALS(40101) / LOGIN_ACCOUNT_LOCKED(40301)
  27 +- 修改: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 白名单 `/api/auth/login`
  28 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java`
  29 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`(含嵌套 LoginUserInfo)
  30 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` — sign/parse 封装
  31 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口
  32 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现
  33 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java`
  34 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  35 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java`
  36 +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 2 个错误码断言
  37 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java`
  38 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java`
  39 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  40 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java`
  41 +
  42 +---
  43 +
  44 +## 任务步骤
  45 +
  46 +### Task 1: pom.xml + ErrorCode + DTO/VO + DTO Validation
  47 +
  48 +**Files:**
  49 +- Modify: `backend/pom.xml`(追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6 三个 dependency)
  50 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  51 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
  52 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java`
  53 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`
  54 +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java`
  55 +
  56 +**API shape:**
  57 +- `LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误")`
  58 +- `LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定")`
  59 +- `LoginDTO`:`@NotBlank @Size(max=50) String sUserName` / `@NotBlank @Size(max=100) String sPassword` / `@NotBlank @Pattern(regexp="^standard$") String sVersion`
  60 +- `LoginResultVO`:`String accessToken` / `long expiresIn` / `LoginUserInfo user`(内嵌静态类 5 字段:iIncrement / sUserNo / sUserName / sUserType / sLanguage)
  61 +
  62 +- [ ] **Step 1.1 写失败测试**
  63 + - ApiResponseTest 追加:`assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);` + LOGIN_ACCOUNT_LOCKED 40301。
  64 + - LoginDTOValidationTest 4 个用例:allValid / blankRequired / invalidVersion / overSized。
  65 + - 子会话: FAIL(class 不存在)
  66 +
  67 +- [ ] **Step 1.2 实现**
  68 + - 在 pom.xml `<dependencies>` 追加:
  69 + ```
  70 + io.jsonwebtoken:jjwt-api:0.12.6
  71 + io.jsonwebtoken:jjwt-impl:0.12.6 (runtime)
  72 + io.jsonwebtoken:jjwt-jackson:0.12.6 (runtime)
  73 + ```
  74 + - ErrorCode + DTO + VO + Validation
  75 + - 子会话: PASS
  76 +
  77 +- [ ] **Step 1.3 提交**
  78 + - `git commit -m "feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004"`
  79 +
  80 +---
  81 +
  82 +### Task 2: JwtTokenProvider + 单元测试
  83 +
  84 +**Files:**
  85 +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java`
  86 +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java`
  87 +
  88 +**API shape:**
  89 +- `JwtTokenProvider` Spring `@Component`,构造注入 `@Value("${erp.jwt.secret}") String secret` + `@Value("${erp.jwt.expires-in-seconds}") long expiresIn`。
  90 +- `String sign(int uid, String username, String userType)`:HS256,claims `sub=username` / `uid` / `type=userType` / `iat` / `exp = iat + expiresIn`。
  91 +- `Claims parse(String token)`:验证签名 + 过期,抛 `JwtException` / `ExpiredJwtException`。
  92 +
  93 +> secret 不足 256 bit 的处理:jjwt 0.12 要求 SecretKey 至少 256 bit。`JWT_SECRET` (.env.local) 当前已是 256 bit hex。Provider 用 `Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))`。如不足直接抛 `WeakKeyException`(启动期暴露)。
  94 +
  95 +- [ ] **Step 2.1 写失败测试**
  96 + - `JwtTokenProviderTest#signAndParse_returnsClaims`:注入 fake secret + expiresIn=7200,sign(uid=1, username=alice, type=普通用户) → parse → 断言 sub=alice / uid=1 / type=普通用户 / exp-iat=7200。
  97 + - `JwtTokenProviderTest#parseExpiredToken_throwsExpiredJwtException`:mock Clock 或手工构造已过期 token(用 jjwt builder 设 exp=now-1)。
  98 + - 测试方式:直接 `new JwtTokenProvider(secret, expiresIn)` 实例化,避免 SpringBootTest 开销。
  99 + - 子会话: FAIL
  100 +
  101 +- [ ] **Step 2.2 实现 JwtTokenProvider**
  102 + - 子会话: PASS
  103 +
  104 +- [ ] **Step 2.3 提交**
  105 + - `git commit -m "feat(usr): JwtTokenProvider sign/parse REQ-USR-004"`
  106 +
  107 +---
  108 +
  109 +### Task 3: LoginAttemptStore 接口 + InMemory 实现
  110 +
  111 +**Files:**
  112 +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口
  113 +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现
  114 +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java`
  115 +
  116 +**API shape:**
  117 +```
  118 +interface LoginAttemptStore {
  119 + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */
  120 + long cooldownSeconds(String username);
  121 +
  122 + /** 记录一次失败:count++;count==5 触发 15min 锁定 */
  123 + void recordFailure(String username);
  124 +
  125 + /** 登录成功清空记录 */
  126 + void clear(String username);
  127 +}
  128 +
  129 +@Component
  130 +class InMemoryLoginAttemptStore implements LoginAttemptStore {
  131 + private static final int LOCK_THRESHOLD = 5;
  132 + private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
  133 + private final Map<String, FailRecord> store = new ConcurrentHashMap<>();
  134 + // FailRecord{int count; Instant lockUntil}; 同步 access via map.compute
  135 +}
  136 +```
  137 +
  138 +- [ ] **Step 3.1 写失败测试(4 个)**
  139 + - `cooldown_initial_returnsZero`
  140 + - `recordFailure_under5_doesNotLock`
  141 + - `recordFailure_at5_triggersLock_cooldownPositive`
  142 + - `clear_resetsCount`
  143 + - (不直接测"15min 后解锁"——time-based 测试用 Duration.ZERO mock 不实际可行;spec § 6 验收"锁定到期后可登录"由 LoginServiceImplTest 模拟 lockUntil 至过去验证)
  144 + - 子会话: FAIL
  145 +
  146 +- [ ] **Step 3.2 实现接口 + InMemory**
  147 + - 子会话: PASS
  148 +
  149 +- [ ] **Step 3.3 提交**
  150 + - `git commit -m "feat(usr): in-memory login attempt store REQ-USR-004"`
  151 +
  152 +---
  153 +
  154 +### Task 4: LoginService + Mockito 单元测试
  155 +
  156 +**Files:**
  157 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java`
  158 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  159 +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  160 +
  161 +**API shape:**
  162 +- `LoginService.login(LoginDTO dto): LoginResultVO`
  163 +- 实现步骤(plan 锁定):
  164 + 1. 检查锁定:`long cd = attemptStore.cooldownSeconds(dto.sUserName)`。`cd > 0` → 抛 `BizException(LOGIN_ACCOUNT_LOCKED, message + cooldownSeconds)`;message 体内含 cooldownSeconds,由 controller 层把 BizException 转换时把 cd 放到 data.cooldownSeconds。
  165 + - **简化方案**:直接抛 `BizException(LOGIN_ACCOUNT_LOCKED)` 不带 cooldown;controller 层 `catch BizException` 包装成 `ApiResponse.fail` 时丢失 cooldown。**改进方案**:自定义 `AccountLockedException extends BizException`,含 `long cooldownSeconds`,GlobalExceptionHandler 加专门 handler 把 cooldownSeconds 放进 `ApiResponse.data`。spec § 验收 #7 要求 `data.cooldownSeconds`,故选改进方案。
  166 + 2. 查用户:`userMapper.selectOne(eq(sUserName).eq(bDeleted, false))`。null → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。
  167 + 3. BCrypt 校验:`passwordEncoder.matches(dto.sPassword, user.sPasswordHash)`。false → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。
  168 + 4. 成功:`attemptStore.clear(sUserName)`;签发 token;`userMapper.update(null, LambdaUpdateWrapper.eq(iIncrement,user.iIncrement).set(tLastLoginDate, now))`;构造 LoginResultVO 返回。
  169 +- 标 `@Transactional(rollbackFor = Exception.class)`(成功路径有 update;失败也安全)。
  170 +
  171 +**新建** `AccountLockedException`(在 module_usr/security 或 common/exception,本 plan 选 common/exception):
  172 +```
  173 +class AccountLockedException extends BizException {
  174 + private final long cooldownSeconds;
  175 + public AccountLockedException(long cd) {
  176 + super(ErrorCode.LOGIN_ACCOUNT_LOCKED);
  177 + this.cooldownSeconds = cd;
  178 + }
  179 +}
  180 +```
  181 +
  182 +GlobalExceptionHandler 追加 `@ExceptionHandler(AccountLockedException.class)` 把 cooldownSeconds 包进 `ApiResponse.data`,为此需要 `Map<String, Object>` data 形式:`Map.of("cooldownSeconds", e.getCooldownSeconds())`。
  183 +
  184 +- [ ] **Step 4.1 写失败测试(7 个)**
  185 + - `login_validCredentials_returnsTokenAndClearsFailCount`:mock attemptStore.cooldownSeconds=0 / userMapper.selectOne 返回 user / passwordEncoder.matches=true / jwt.sign 返回固定 token / userMapper.update 返回 1。断言 VO.accessToken / verify attemptStore.clear / verify userMapper.update 被调(含 LambdaUpdateWrapper)。
  186 + - `login_userNotFound_returns40101_recordsFailure`
  187 + - `login_userSoftDeleted_returns40101`:selectOne 已带 bDeleted=0 过滤,覆盖该路径等价于 userNotFound(返回 null);mock selectOne null 即可。
  188 + - `login_passwordMismatch_returns40101_recordsFailure`
  189 + - `login_accountLocked_throwsAccountLockedException_withCooldown`
  190 + - `login_5thFailureTriggersLock`(mock attemptStore 检验 recordFailure 调用次数)
  191 + - `login_successUpdatesTLastLoginDate`:ArgumentCaptor 捕 LambdaUpdateWrapper(或 Wrapper),断言 sql set 含 tLastLoginDate。
  192 + - 子会话: FAIL
  193 +
  194 +- [ ] **Step 4.2 实现 LoginService + Impl + AccountLockedException + GlobalExceptionHandler 扩展**
  195 + - 子会话: PASS
  196 +
  197 +- [ ] **Step 4.3 提交**
  198 + - `git commit -m "feat(usr): login service + account locked handling REQ-USR-004"`
  199 +
  200 +---
  201 +
  202 +### Task 5: LoginController + SecurityConfig 白名单 + 端到端 IT
  203 +
  204 +**Files:**
  205 +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java`
  206 +- Modify: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`(白名单 /api/auth/login)
  207 +- Modify: `backend/src/main/resources/application.yml`(确认 `erp.jwt.secret` / `erp.jwt.expires-in-seconds` 已存在;REQ-USR-001 引入时已设置)
  208 +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java`
  209 +
  210 +**API shape:**
  211 +- `@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor LoginController`
  212 +- `@PostMapping("/login") ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto)`
  213 +- Javadoc: `REQ-USR-004 用户登录 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成`
  214 +- SecurityConfig 修改:在 `authorizeHttpRequests` 配置上下文中显式 `requestMatchers("/api/auth/login").permitAll()`,其他保持 permitAll(REQ-USR-004 不强制收紧,留作技术债登记)
  215 +
  216 +- [ ] **Step 5.1 写失败测试(9 个)**
  217 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback`,`@Autowired UserMapper / PasswordEncoder / InMemoryLoginAttemptStore`,`@BeforeEach` 调用 `attemptStore.clear(sUserName)`(避免跨测试串扰,因为 store 是单例 ConcurrentHashMap)。
  218 + - `login_validCredentials_returns200WithToken`:先 mapper.insert 一个 user(密码 BCrypt 哈希 666666)→ POST 登录 → 断言 200 / token 非空 / expiresIn=7200 / user 嵌套。
  219 + - `login_jwtClaimsAreCorrect`:上一条 token 用 JwtTokenProvider 解析(@Autowired),断言 sub / uid / type 与 DB 一致。
  220 + - `login_invalidUsername_returns40101`
  221 + - `login_wrongPassword_returns40101`
  222 + - `login_softDeletedUser_returns40101`
  223 + - `login_missingPassword_returns40010`
  224 + - `login_invalidVersion_returns40010`:sVersion="experimental"
  225 + - `login_5thFailureLocks_returns40301`:连续 5 次 wrong password,第 5 次断言 code=40301 + `data.cooldownSeconds > 0`
  226 + - `login_responseExcludesSPasswordHash`:jsonPath `$.data.user.sPasswordHash` doesNotExist
  227 + - 子会话: FAIL
  228 +
  229 +- [ ] **Step 5.2 实现 LoginController + SecurityConfig 白名单**
  230 + - 子会话: PASS
  231 +
  232 +- [ ] **Step 5.3 跑全量 backend 测试**
  233 + - `cd backend && mvn -B test`
  234 + - 期望 144 + 4(DTO valid) + 2(JwtTokenProvider) + 4(InMemoryStore) + 7(service unit) + 9(controller IT) = 170 测试,全绿
  235 +
  236 +- [ ] **Step 5.4 提交**
  237 + - `git commit -m "feat(usr): POST /api/auth/login controller REQ-USR-004"`
  238 +
  239 +---
  240 +
  241 +## 提交计划
  242 +
  243 +- `feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004`(Task 1)
  244 +- `feat(usr): JwtTokenProvider sign/parse REQ-USR-004`(Task 2)
  245 +- `feat(usr): in-memory login attempt store REQ-USR-004`(Task 3)
  246 +- `feat(usr): login service + account locked handling REQ-USR-004`(Task 4)
  247 +- `feat(usr): POST /api/auth/login controller REQ-USR-004`(Task 5)
docs/superpowers/reviews/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-001 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +Round 1 两条 must_fix 处理结果:
  17 +1. **HIGH(reviewer 误判)**:`UserServiceImpl.java:102` 实际已含 `up.setTCreateDate(LocalDateTime.now());`,round 1 reviewer 看错。fix commit 520c01f 正确不动代码。
  18 +2. **MEDIUM(已修)**:spec/plan 中 `bSelected` 提及全部转为「无该列」注解,与 docs/03 SSoT + UserPermissionEntity 实现一致(fix commit 520c01f)。
  19 +
  20 +## Nice-to-have(待 sweep)
  21 +
  22 +- backend/src/main/java/com/xly/erp/common/response/ErrorCode.java — 3 个新常量(STAFF_NOT_FOUND / PERM_CATEGORY_NOT_FOUND / USR_USER_NAME_OR_NO_DUP)追 Javadoc,特别 `STAFF_NOT_FOUND(40421)` 与 `MOD_NOT_FOUND(40421)` 段位共享但语义不同——Javadoc 注明使用模块差异,避免 IDE 跳错枚举。
  23 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:30 — `INITIAL_PASSWORD = "666666"` 外置到 `application.yml` + `@Value("${xly.user.initial-password:666666}")`,便于环境差异化 + REQ-USR-004 登录校验复用。
  24 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — 补 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条 IT 镜像 service 单测分支。
  25 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:70 — `permissionCategoryIds` 含重复 id 时 found.size() < ids.size() 误报 40422;可在校验前 distinct。
  26 +- docs/05 § REQ-USR-001 错误码段位 40020 vs spec 落地 40010 sweep 对齐。
  27 +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — Javadoc 注释笔误 `REQ-MOD-001 用户新增`,Controller.java 内已正确为 `REQ-USR-001`;plan 同步修正即可。
  28 +
  29 +## 反例 / 测试覆盖缺口
  30 +
  31 +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测覆盖)。
  32 +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。
  33 +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。
  34 +4. 跨 round 1 / round 2:fix commit 520c01f 仅触及 3 份 docs,0 行代码变更;UserServiceImpl / UserPermissionEntity / 测试均保持原样,无新引入风险。`scripts/test.sh` 全量 101/0/0/0 BUILD SUCCESS。
  35 +
  36 +**核心结论**:round 1 must_fix #1 是 reviewer 误判(无需修),#2 已修。所有 IT + 单元测试 101 个全绿。verdict: approve。
docs/superpowers/reviews/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-002 — round 1
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +
  18 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:285 — `put_permissionCategoryNotFound_returns40422` 只断言响应 code,未断言 spec § 验收 #11 要求的 "DB user 与 tUserPermission 都不变(事务回滚)"。受 IT 类整体 `@Transactional+@Rollback` 制约,需另起 `Propagation.NOT_SUPPORTED` 测试或在 service 抛异常前后分别 reload 校验字段未变。
  19 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:235 — `put_validUpdate_returns200_andDbReflects` 仅断言 `upCount==2L`,没显式断言新 2 条关联指向 `catNew1/catNew2` 且原 3 条已不存在。建议追加 `containsExactlyInAnyOrder(catNew1, catNew2)` + `doesNotContain(cat1, cat2, cat3)`。
  20 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 缺 `put_targetSoftDeleted_returns40431` 和 `put_staffSoftDeleted_returns40421` 两条端到端用例(spec § 验收 #7 / #10 仅在 service 单测覆盖)。
  21 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:327 — `put_ignoresProtectedFields_doesNotChangeUserNoOrName` 未断言 tCreateDate 保留(spec § 业务规则 #7 / 验收 #4 列入"不被修改"范围)。
  22 +- backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java:40 — `iStaffId` 全局 `FieldStrategy.IGNORED` 是已知副作用(注释已说明)。建议在 module_usr 完成报告 § ⑦ 跨模块改动 / 风险登记追加一条:"UserEntity.iStaffId 已加 IGNORED;后续 partial-update path 必须 selectById 后再 updateById",与 module_mod ModuleEntity.iParentId 同类风险并案管理。
  23 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java — Service 中 iStaffId/sUserType/sLanguage 直接覆盖、bCanModifyDocs null 保留——建议加注释或抽 mergeUpdate 私有方法集中 null 语义。
  24 +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java — 未覆盖 `dto.permissionCategoryIds == null` 分支(spec § 业务规则 #5 "不传则只删不插")。当前所有相关测试都用 `List.of()` 而非 null。
  25 +
  26 +## 反例 / 测试覆盖缺口
  27 +
  28 +1. **AC11 回滚证据**:`put_permissionCategoryNotFound_returns40422` 受 `@Transactional+@Rollback` 制约,无法直接观测 service 层的回滚。
  29 +2. **AC1 重建关联粒度**:`upCount==2` 可被任何 insert 顺序异常掩盖,需细化断言。
  30 +3. **AC7 / AC10 端到端缺失**:目标软删除 / staff 软删除均仅在 service 单测覆盖。
  31 +4. **AC4 tCreateDate 保留**:实现走 load-then-modify 默认 NOT_NULL 策略行为正确,但缺测试佐证。
  32 +5. **业务规则 #5 null permissionCategoryIds 分支**:单测未直接覆盖。
  33 +6. **iStaffId IGNORED 全局副作用**:本期安全,未来 partial-update path 风险,需在 module_usr 完成报告中登记。
  34 +
  35 +**核心结论**:Spec/Plan 业务规则 1-9 全部实现到位,错误码 40010 / 40421 / 40422 / 40431 全部回归,UserVO 不暴露 sPasswordHash,保留字段 sUserNo / sUserName / sPasswordHash 不被改通过 IT 验证。docs/05 § REQ-USR-002 列的 40331 / 40931 是 RBAC 范畴的本期不实施项,spec 已说明,不阻塞。verdict: approve;改进项放下一 REQ 或 module-report sweep。
docs/superpowers/reviews/2026-05-06-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-06
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-003 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +Round 1 三条 must_fix 处理结果(commit f53689c):
  17 +1. **HIGH SQL 注入** — RESOLVED。UserQueryDTO 删除 `column` 字段;UserMapper.searchUsers 三参签名含 `@Param("column") String column`;UserMapper.xml 用 `${column}`;UserServiceImpl.search 用局部变量映射后通过 mapper 单独传入。
  18 +2. **HIGH spec § 6 deleted=true** — RESOLVED。Service 实现 'true'/'false' → '1'/'0' 标准化(其它抛 PARAM_INVALID);XML deleted 分支用 `CAST(#{queryValue} AS UNSIGNED)` 兼容 bit(1);恢复 `get_filterByDeletedTrue_returnsOnlyDeleted` IT;新增 `search_deletedQueryValueTrue_normalizedToOne` 单测。
  19 +3. **MEDIUM XML deleted 边界** — RESOLVED。queryField=deleted 但 queryValue 空时仍保留默认过滤 `u.bDeleted = 0`,避免返回全量含已删除。
  20 +
  21 +## Nice-to-have
  22 +
  23 +- UserServiceImpl.search:212/214 — 直接 `query.setQueryValue("1")` 改写入参 DTO,单测靠副作用断言。语义上 service 不应突变 controller 入参;可改用局部 `normalizedDeletedValue` + `@Param("deletedValue")` 传给 mapper,XML 改用 `#{deletedValue}`,更纯。
  24 +- round 1 遗留的 6 条 IT 覆盖缺口(department equals / deleted=false / notContains / 排序 / matchType 非枚举 IT / 空结果 IT)+ UserMapperSearchIT 断言强化 + PageResult javadoc + QUERY_COLUMN_MAP 位置 + Base_Column_List 抽取——本轮也不在 must_fix 范畴,留待后续 sweep。
  25 +
  26 +## 反例 / 测试覆盖缺口
  27 +
  28 +Round 1 must_fix 1 + 2 + 3 三项均已落地:
  29 +
  30 +1. 注入:UserMapper.xml 唯一 `${...}` 插值仅来自 service 白名单映射,外部输入完全经过 `#{...}` 参数化绑定。
  31 +2. deleted 标准化:单测 `search_deletedQueryValueTrue_normalizedToOne` 钉死 `query.getQueryValue() == "1"`;IT `get_filterByDeletedTrue_returnsOnlyDeleted` 端到端验证返回已删除用户。
  32 +3. XML 边界:deleted 空值分支保留默认 `u.bDeleted = 0`,与 spec § 业务规则 1 一致。
  33 +
  34 +`mvn -B test` 经 .env.local 注入后 144/144 全绿,无新高危。
  35 +
  36 +**核心结论**:round 1 high 注入风险 + spec § 6 契约缺失全部修复;其余 nice-to-have 不阻塞,留下一 sweep。verdict: approve。
docs/superpowers/reviews/2026-05-06-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-06
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-004 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +Round 1 三条 must_fix 处理结果(commit d439c0d):
  17 +1. **CRITICAL JwtTokenProviderTest 硬编码生产 JWT_SECRET** — RESOLVED。test SECRET 改为 fake 32-byte hex(line 21);`.env.local` JWT_SECRET 已旋转为新随机值。**遗留运维项**:旧值已入 commit b7ed804 git history,所有已部署环境必须同步轮换 JWT_SECRET。
  18 +2. **HIGH InMemoryLoginAttemptStore 锁定到期不重置** — RESOLVED。cooldownSeconds 过期路径 `store.remove`,recordFailure compute 入口检测 prev.lockUntil 过期重建 FailRecord;spec § 业务规则 4 第 4 条达成。
  19 +3. **MEDIUM 验收 #9 无测试** — RESOLVED。补 `cooldown_afterExpiry_resetsCount` 单测(含 reset 后再 4 次未锁、第 5 次再锁的完整往返)+ `login_afterLockExpiry_returns200` IT;`expireLockForTest` 改 public 解决跨包可见性。
  20 +
  21 +## Nice-to-have
  22 +
  23 +- backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java + service — spec § 业务规则 7 客户端 IP 审计未实施;service 未拿 `HttpServletRequest.getRemoteAddr()`。**需在模块完成报告 § ⑩ 登记技术债**。
  24 +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java — `login_successUpdatesTLastLoginDate_viaSetClause` 仍只 `verify(...).update(isNull(), any(Wrapper.class))`,没断言 LambdaUpdateWrapper 的 set 子句包含 tLastLoginDate(plan Step 4.1 要求)。
  25 +- backend/src/main/java/com/xly/erp/config/SecurityConfig.java — plan Step 5.2 要求 `requestMatchers("/api/auth/login").permitAll()` 显式白名单;当前 anyRequest().permitAll() 等价覆盖但未做 plan 偏离登记。
  26 +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:17 — `java.util.Map` fully-qualified;下次 sweep 加 import。
  27 +- backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java:51 — `expireLockForTest` 已 public 但住在生产包;下一 sweep 可挪到 src/test 的 testutil 包。
  28 +
  29 +## 反例 / 测试覆盖缺口
  30 +
  31 +Round 1 三项 must_fix 全部解决:
  32 +1. JwtTokenProviderTest SECRET 改 fake + .env.local 已旋转;运维侧需轮换部署环境。
  33 +2. cooldownSeconds + recordFailure 双路径处理过期重置;business rule #4 完整。
  34 +3. 验收 #9 单测 + IT 双层覆盖;reset 后能重新触发新一轮锁定。
  35 +
  36 +`mvn -B test` 经 .env.local 注入后 172/172 全绿;`scripts/test.sh` GREEN;无新高危。
  37 +
  38 +**核心结论**:critical + high + medium 三项关键修复已落地。剩余 5 条 nice-to-have(IP 审计 / SET 子句捕获 / SecurityConfig 显式白名单 / Map import / testutil 移植)在模块完成报告 § ⑩ 登记后留给后续 sweep。verdict: approve。
docs/superpowers/specs/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 用户新增
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`POST /api/users`,Content-Type `application/json`。
  16 +
  17 +**Request body**(`UserCreateDTO`)字段:
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 |
  20 +|---|---|---|---|---|
  21 +| `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` |
  22 +| `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` |
  23 +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` |
  24 +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` |
  25 +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` |
  26 +| `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` |
  27 +| `permissionCategoryIds` | List<Integer> | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 |
  28 +
  29 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。
  30 +
  31 +> **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。
  32 +
  33 +## 输出 / 结果
  34 +
  35 +**HTTP 200,响应体**:
  36 +
  37 +```json
  38 +{
  39 + "code": 200,
  40 + "message": "操作成功",
  41 + "data": {
  42 + "iIncrement": 12,
  43 + "sUserNo": "u001",
  44 + "sUserName": "alice",
  45 + "iStaffId": 7,
  46 + "sUserType": "普通用户",
  47 + "sLanguage": "zh",
  48 + "bCanModifyDocs": false,
  49 + "tCreateDate": "2026-05-06T10:30:00",
  50 + "bDeleted": false,
  51 + "permissionCategoryIds": [1, 2, 3]
  52 + },
  53 + "timestamp": 1746528600000
  54 +}
  55 +```
  56 +
  57 +新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。
  58 +
  59 +**不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。
  60 +
  61 +## 业务规则
  62 +
  63 +1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。
  64 +2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。
  65 +3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。
  66 +4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。
  67 +5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。
  68 +6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。
  69 +7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。
  70 +8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。
  71 +
  72 +## 边界与约束
  73 +
  74 +### 鉴权策略
  75 +
  76 +沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。
  77 +
  78 +### 错误码映射
  79 +
  80 +| 场景 | 错误码 | ErrorCode 枚举 |
  81 +|---|---|---|
  82 +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) |
  83 +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) |
  84 +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) |
  85 +| `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) |
  86 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  87 +
  88 +> docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。
  89 +
  90 +### 性能 / 并发
  91 +
  92 +- 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。
  93 +- `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。
  94 +
  95 +### 字符集 / 长度
  96 +
  97 +- utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。
  98 +- 长度超 schema 上限 → 视为参数错 40010。
  99 +
  100 +### 与 docs/04 § 1.4 / 3.5 一致性
  101 +
  102 +- 异常走 GlobalExceptionHandler。
  103 +- BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。
  104 +
  105 +### 已知技术债
  106 +
  107 +- **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。
  108 +- **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。
  109 +
  110 +## 依赖的 schema 表 / 字段
  111 +
  112 +**写表**:`tUser`、`tUserPermission`
  113 +
  114 +**读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典)
  115 +
  116 +| `tUser` 字段 | 落库逻辑 |
  117 +|---|---|
  118 +| `iIncrement` | DB AUTO_INCREMENT |
  119 +| `sUserNo` / `sUserName` | 入参(必填,唯一) |
  120 +| `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) |
  121 +| `sUserType` / `sLanguage` | 入参(必填,枚举) |
  122 +| `bCanModifyDocs` | 入参(可选,默认 false) |
  123 +| `sPasswordHash` | BCrypt("666666") |
  124 +| `tCreateDate` | LocalDateTime.now() |
  125 +| `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 |
  126 +
  127 +| `tUserPermission` 字段 | 落库逻辑 |
  128 +|---|---|
  129 +| `iIncrement` | DB AUTO_INCREMENT |
  130 +| `iUserId` | 新用户 iIncrement |
  131 +| `iCategoryId` | dto.permissionCategoryIds[i] |
  132 +| `tCreateDate` | LocalDateTime.now() |
  133 +| `sCreatedBy` | NULL(REQ-USR-004 后回填) |
  134 +| 多租户 / `sId` | NULL |
  135 +
  136 +**索引利用**:
  137 +- `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底
  138 +- `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底)
  139 +
  140 +**外键**:
  141 +- `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常
  142 +- `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心
  143 +- `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert
  144 +
  145 +## 依赖的接口
  146 +
  147 +无(本接口独立工作)。
  148 +
  149 +REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。
  150 +
  151 +## 验收标准
  152 +
  153 +### 功能正确性
  154 +
  155 +1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。
  156 +2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。
  157 +3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。
  158 +4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。
  159 +5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。
  160 +6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。
  161 +7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。
  162 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。
  163 +9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。
  164 +10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。
  165 +11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。
  166 +
  167 +### 接口契约一致性
  168 +
  169 +- 响应格式 `{code, message, data, timestamp}`。
  170 +- 不回显堆栈。
  171 +
  172 +### 测试覆盖
  173 +
  174 +- **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder
  175 + - create_minimalFields_returnsVOWithBCryptHash
  176 + - create_withStaffAndPermissions_writesAssociation
  177 + - create_duplicateUserName_throws40921
  178 + - create_duplicateUserNo_throws40921
  179 + - create_staffNotFound_throws40421
  180 + - create_staffSoftDeleted_throws40421
  181 + - create_permissionCategoryNotFound_throws40422
  182 + - create_emptyPermissionCategoryIds_doesNotInsertAssociation
  183 + - create_concurrentDuplicate_dupKeyException_mappedTo40921
  184 +- **集成测试** `UserControllerIT`:
  185 + - post_minimalFields_returns200
  186 + - post_withStaffAndPermissions_returns200_andDbAssociated
  187 + - post_duplicateUserName_returns40921
  188 + - post_staffNotFound_returns40421
  189 + - post_permissionCategoryNotFound_returns40422
  190 + - post_passwordHashedInDb_notPlaintext
  191 + - post_responseExcludesSPasswordHash
  192 +
  193 +### 代码与文档
  194 +
  195 +- `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。
  196 +- 提交按 `feat(usr): <subject> REQ-USR-001` 规范。
  197 +- 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。
docs/superpowers/specs/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-002 — 用户修改
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `PUT /api/users/{id}` 接口:在不破坏唯一性的前提下,更新已有用户的可编辑字段(含权限组关联),返回最新 UserVO(不含 sPasswordHash)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`PUT /api/users/{id}`,Content-Type `application/json`。`{id}` = `tUser.iIncrement`。
  16 +
  17 +**Request body**(`UserUpdateDTO`)字段——与 REQ-USR-001 输入相比**剥除 `sUserNo` / `sUserName`**(不可改,登录身份固定);其余字段均可修改:
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 | 行为 |
  20 +|---|---|---|---|---|
  21 +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement`;显式 `null` 表示清空员工关联 | 覆盖 |
  22 +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | 覆盖 |
  23 +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | 覆盖 |
  24 +| `bCanModifyDocs` | Boolean | 否 | `null` 保持原值;显式覆盖 | 部分更新 |
  25 +| `permissionCategoryIds` | List<Integer> | 否 | 每元素必须存在且未软删除;可空数组(清空所有授权) | 重建关联(先删后插,幂等) |
  26 +
  27 +> **不在 DTO 中**:`sUserNo`(用户号唯一不可改)、`sUserName`(登录账号唯一不可改)、`sPasswordHash`(密码不通过本接口修改)。Jackson 默认忽略未知字段。
  28 +> 前端 UI 应把这些字段渲染为只读。
  29 +
  30 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:UPDATE`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`。
  31 +
  32 +## 输出 / 结果
  33 +
  34 +**HTTP 200,响应体**复用 REQ-USR-001 的 `UserVO`(10 个字段含 `permissionCategoryIds`),不暴露 sPasswordHash。
  35 +
  36 +```json
  37 +{
  38 + "code": 200,
  39 + "message": "操作成功",
  40 + "data": {
  41 + "iIncrement": 12,
  42 + "sUserNo": "u001",
  43 + "sUserName": "alice",
  44 + "iStaffId": 7,
  45 + "sUserType": "超级管理员",
  46 + "sLanguage": "en",
  47 + "bCanModifyDocs": true,
  48 + "tCreateDate": "2026-05-06T10:30:00",
  49 + "bDeleted": false,
  50 + "permissionCategoryIds": [1, 2]
  51 + },
  52 + "timestamp": 1746528600000
  53 +}
  54 +```
  55 +
  56 +## 业务规则
  57 +
  58 +1. **目标用户必须存在且未软删除**:`selectById(id)` 返回 null 或 `bDeleted=1` → `BizException(USR_NOT_FOUND)` (40431)。
  59 +2. **`sUserNo` / `sUserName` / `sPasswordHash` 不可改**:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。
  60 +3. **iStaffId 校验**(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` 不存在或 `bDeleted=true` → `BizException(STAFF_NOT_FOUND)` (40421);显式 `null` 表示清空员工关联,不校验。
  61 +4. **权限分类校验**(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(`selectBatchIds` 一次性查);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。
  62 +5. **权限组重建语义**:service 内**先删后插**——先 `userPermissionMapper.delete(eq(iUserId, {id}))` 清空目标用户所有现有关联,再按 `permissionCategoryIds` 顺序插入。空数组 / 不传则只删不插(清空授权)。
  63 +6. **`bCanModifyDocs` / `iStaffId` 部分更新**:DTO 中 `null` 时——
  64 + - `bCanModifyDocs == null` → 保持原值;
  65 + - `iStaffId == null` → **显式清空**为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。
  66 +7. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。
  67 +8. **审计**:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。
  68 +9. **事务**:`@Transactional(rollbackFor = Exception.class)`,覆盖目标校验 → 父校验 → 权限校验 → user update → tUserPermission delete → tUserPermission insert,整体回滚。
  69 +
  70 +## 边界与约束
  71 +
  72 +### 鉴权策略
  73 +
  74 +沿用 module_mod / REQ-USR-001 SecurityConfig permitAll。
  75 +
  76 +### 错误码映射
  77 +
  78 +| 场景 | 错误码 | ErrorCode 枚举 |
  79 +|---|---|---|
  80 +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) |
  81 +| `{id}` 用户不存在或已软删除 | 40431 | `USR_NOT_FOUND`(**新增**) |
  82 +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(已存在) |
  83 +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(已存在) |
  84 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  85 +
  86 +> docs/05 § REQ-USR-002 中列的 40331(角色变更权限)涉及操作员角色权限模型,需要 SecurityContext + RBAC 才能落地,**REQ-USR-004 后再补**;本 REQ 不实施。
  87 +
  88 +### iStaffId 的 NULL 写入
  89 +
  90 +借鉴 REQ-MOD-002 经验:MyBatis-Plus 默认 update 跳过 null 字段,需让 `iStaffId` 字段加 `FieldStrategy.IGNORED` 才能把 NULL 写入 SQL。本期在 `UserEntity#iStaffId` 上加 `@TableField(value="iStaffId", updateStrategy=FieldStrategy.IGNORED)`。
  91 +
  92 +> 风险与 REQ-MOD-002 相同:未来 partial update 路径会埋雷。本 REQ 走 load-then-modify 全量回填,安全。
  93 +
  94 +### 权限组重建的并发
  95 +
  96 +- "先删后插"在事务内是原子的;`uk_user_perm` 唯一约束兜底。
  97 +- 同一用户并发修改权限:MySQL 默认行级锁 + 事务隔离 → 后写覆盖(与 REQ-MOD-002 一致)。
  98 +
  99 +### 性能
  100 +
  101 +- `selectBatchIds` 单次 round-trip 校验 N 个权限分类。
  102 +- `delete + insert` 为 N+1 次 SQL,本期不优化。
  103 +
  104 +## 依赖的 schema 表 / 字段
  105 +
  106 +**写表**:`tUser`(主体字段更新)、`tUserPermission`(先删后插)
  107 +
  108 +**读表**:`tStaff`(iStaffId 校验)、`tPermissionCategory`(权限分类校验)
  109 +
  110 +| `tUser` 字段 | 行为 |
  111 +|---|---|
  112 +| `iIncrement` / `sUserNo` / `sUserName` / `sPasswordHash` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `sId` / 多租户 / `bDeleted` 三件套 | **不修改** |
  113 +| `iStaffId` | 入参覆盖(含 null 设根;需 `FieldStrategy.IGNORED`) |
  114 +| `sUserType` / `sLanguage` | 入参覆盖(必填) |
  115 +| `bCanModifyDocs` | 入参非 null 覆盖;null 保留 |
  116 +
  117 +`tUserPermission` 操作:先 `delete(eq(iUserId, {id}))`,再按 `permissionCategoryIds` 顺序 `insert`(每条 `iUserId={id}` / `iCategoryId=...` / `tCreateDate=now`,无 bSelected)。
  118 +
  119 +**索引利用**:`uk_user_no` / `uk_user_name`(不会触发,因为本接口不改这两列);`uk_user_perm`(兜底重复授权)。
  120 +
  121 +## 依赖的接口
  122 +
  123 +无(独立接口;REQ-USR-001 建立的体系完全复用)。
  124 +
  125 +## 验收标准
  126 +
  127 +### 功能正确性
  128 +
  129 +1. **正向 — 全字段更新**:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。
  130 +2. **正向 — 清空 iStaffId**:PUT 时显式 `iStaffId=null`,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。
  131 +3. **正向 — 清空权限组**:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。
  132 +4. **正向 — 保留字段**:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。
  133 +5. **正向 — 部分字段保留原值**:DTO 中 `bCanModifyDocs=null`,DB 保留原值(验证 NOT_NULL 策略生效)。
  134 +6. **目标不存在**:`PUT /api/users/999999`,返回 40431。
  135 +7. **目标已软删除**:先建 user 后置 bDeleted=1,PUT 返回 40431。
  136 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。
  137 +9. **iStaffId 不存在**:iStaffId=999999,返回 40421。
  138 +10. **iStaffId 已软删除**:返回 40421。
  139 +11. **permissionCategoryIds 任一不存在**:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。
  140 +12. **sUserNo / sUserName / sPasswordHash 字段被忽略**:客户端误传 `sUserNo="hijack"` / `sUserName="hijack"` / `sPasswordHash="$2a$10$xxx"`,DB 中这 3 个字段保持原值。
  141 +
  142 +### 接口契约一致性
  143 +
  144 +- 响应格式 `{code, message, data, timestamp}`。
  145 +- 错误码 200 / 40010 / 40421 / 40422 / 40431 / 50000。
  146 +- 不暴露 sPasswordHash;不回显堆栈。
  147 +
  148 +### 测试覆盖
  149 +
  150 +- **单元测试** `UserServiceImplTest` 追加(mock 5 个 mapper + PasswordEncoder):
  151 + - update_targetNotFound_throws40431
  152 + - update_targetSoftDeleted_throws40431
  153 + - update_staffNotFound_throws40421
  154 + - update_staffSoftDeleted_throws40421
  155 + - update_permissionCategoryNotFound_throws40422
  156 + - update_full_returnsVOWithUpdatedFields_andRebuildsPermissions(捕 ArgumentCaptor,断言 user 字段保留 sUserNo/sUserName/sPasswordHash + 新值字段;断言 userPermissionMapper.delete 调一次 + insert 调 N 次)
  157 + - update_partialNullBCanModifyDocs_keepsOriginal
  158 + - update_clearStaffId_setsToNull
  159 + - update_emptyPermissionCategoryIds_clearsAllAssociations(delete 调一次 + insert 不调)
  160 +
  161 +- **集成测试** `UserControllerIT` 追加:
  162 + - put_validUpdate_returns200_andDbReflects
  163 + - put_clearStaffId_setsNull
  164 + - put_emptyPermissionCategoryIds_clearsAssociations
  165 + - put_targetNotFound_returns40431
  166 + - put_staffNotFound_returns40421
  167 + - put_permissionCategoryNotFound_returns40422
  168 + - put_missingRequired_returns40010
  169 + - put_ignoresProtectedFields_doesNotChangeUserNoOrName(body 含 sUserNo / sUserName / sPasswordHash 但 DB 中这三列保持原值)
  170 +
  171 +### 代码与文档
  172 +
  173 +- `// REQ-USR-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode `USR_NOT_FOUND`。
  174 +- 提交按 `feat(usr): <subject> REQ-USR-002`。