diff --git a/backend/pom.xml b/backend/pom.xml index ffbee2a..f20f97e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -82,6 +82,25 @@ ${hutool.version} + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java b/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java new file mode 100644 index 0000000..6d5d099 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java @@ -0,0 +1,19 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import lombok.Getter; + +/** + * REQ-USR-004 账号被临时锁定时抛出。携带剩余 cooldownSeconds, + * GlobalExceptionHandler 会把它转换成 ApiResponse.data 的 cooldownSeconds 字段。 + */ +@Getter +public class AccountLockedException extends BizException { + + private final long cooldownSeconds; + + public AccountLockedException(long cooldownSeconds) { + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); + this.cooldownSeconds = cooldownSeconds; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java index 1281b9d..ff2fdd1 100644 --- a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -12,6 +12,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { + /** REQ-USR-004 账号锁定专用:把 cooldownSeconds 暴露在 data.cooldownSeconds */ + @ExceptionHandler(AccountLockedException.class) + public ApiResponse> handleAccountLocked(AccountLockedException e) { + log.warn("AccountLocked code={} cooldown={}s", e.getCode(), e.getCooldownSeconds()); + java.util.Map data = java.util.Map.of("cooldownSeconds", e.getCooldownSeconds()); + return new ApiResponse<>(e.getCode(), e.getMessage(), data, System.currentTimeMillis()); + } + @ExceptionHandler(BizException.class) public ApiResponse handleBiz(BizException e) { log.warn("BizException code={} message={}", e.getCode(), e.getMessage()); diff --git a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java index 54b2f9a..5e01a3f 100644 --- a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java +++ b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java @@ -6,11 +6,17 @@ import lombok.Getter; public enum ErrorCode { SUCCESS(200, "操作成功"), PARAM_INVALID(40010, "参数错误"), + LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误"), + LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定"), MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"), MOD_NOT_FOUND(40421, "模块不存在或已删除"), + STAFF_NOT_FOUND(40421, "职员不存在或已删除"), + PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除"), + USR_NOT_FOUND(40431, "用户不存在或已删除"), MOD_PROC_NAME_DUP(40911, "存储过程名称已存在"), MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除"), MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代"), + USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在"), INTERNAL_ERROR(50000, "服务器内部错误"); private final int code; diff --git a/backend/src/main/java/com/xly/erp/common/response/PageResult.java b/backend/src/main/java/com/xly/erp/common/response/PageResult.java new file mode 100644 index 0000000..d26f9a7 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/PageResult.java @@ -0,0 +1,24 @@ +package com.xly.erp.common.response; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** REQ-USR-003 引入的通用分页 VO。`data` 字段嵌套此结构。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PageResult { + private long total; + private List list = new ArrayList<>(); + private long pageNum; + private long pageSize; + + public static PageResult of(IPage page) { + return new PageResult<>(page.getTotal(), page.getRecords(), page.getCurrent(), page.getSize()); + } +} diff --git a/backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java b/backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java new file mode 100644 index 0000000..a0a3a1e --- /dev/null +++ b/backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java @@ -0,0 +1,19 @@ +package com.xly.erp.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** REQ-USR-003 引入:注册 MyBatis-Plus 分页拦截器,让 `Page` 自动追加 LIMIT 子句。 */ +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/backend/src/main/java/com/xly/erp/config/PasswordConfig.java b/backend/src/main/java/com/xly/erp/config/PasswordConfig.java new file mode 100644 index 0000000..876b404 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/config/PasswordConfig.java @@ -0,0 +1,19 @@ +package com.xly.erp.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * REQ-USR-001 引入:BCryptPasswordEncoder 注册为 Spring bean, + * 供 UserService.create / REQ-USR-004 登录校验复用。strength 用 BCrypt 默认(10)。 + */ +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java new file mode 100644 index 0000000..19756b2 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java @@ -0,0 +1,26 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.ApiResponse; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.service.LoginService; +import com.xly.erp.module.usr.vo.LoginResultVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REQ-USR-004 用户登录入口 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成。 */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class LoginController { + + private final LoginService loginService; + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginDTO dto) { + return ApiResponse.ok(loginService.login(dto)); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java new file mode 100644 index 0000000..7d69020 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java @@ -0,0 +1,45 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.ApiResponse; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserCreateDTO; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.dto.UserUpdateDTO; +import com.xly.erp.module.usr.service.UserService; +import com.xly.erp.module.usr.vo.UserListItemVO; +import com.xly.erp.module.usr.vo.UserVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + /** REQ-USR-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')") */ + @PostMapping + public ApiResponse create(@Valid @RequestBody UserCreateDTO dto) { + return ApiResponse.ok(userService.create(dto)); + } + + /** REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')") */ + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto) { + return ApiResponse.ok(userService.update(id, dto)); + } + + /** REQ-USR-003 用户列表查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')") */ + @GetMapping + public ApiResponse> search(@Valid UserQueryDTO query) { + return ApiResponse.ok(userService.search(query)); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java new file mode 100644 index 0000000..468202c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java @@ -0,0 +1,23 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** REQ-USR-004 用户登录入参 */ +@Data +public class LoginDTO { + + @NotBlank + @Size(max = 50) + private String sUserName; + + @NotBlank + @Size(max = 100) + private String sPassword; + + @NotBlank + @Pattern(regexp = "^standard$", message = "sVersion 仅支持 standard") + private String sVersion; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java new file mode 100644 index 0000000..9bf763b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java @@ -0,0 +1,38 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** REQ-USR-001 用户新增入参(密码不在 DTO 里,service 层固定哈希 666666 落库)。 */ +@Data +public class UserCreateDTO { + + @NotBlank + @Size(max = 50) + private String sUserNo; + + @NotBlank + @Size(max = 50) + private String sUserName; + + /** 可空:关联职员 id */ + private Integer iStaffId; + + @NotBlank + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一") + private String sUserType; + + @NotBlank + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一") + private String sLanguage; + + /** 可空:默认 false */ + private Boolean bCanModifyDocs; + + /** 可空:每个 id 必须指向未删除的 tPermissionCategory.iIncrement */ + private List permissionCategoryIds; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java new file mode 100644 index 0000000..41bd45d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java @@ -0,0 +1,32 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** REQ-USR-003 用户查询参数 DTO(query string 绑定)。 */ +@Data +public class UserQueryDTO { + + @Min(1) + private Integer pageNum = 1; + + @Min(1) + @Max(100) + private Integer pageSize = 20; + + /** 可空:缺省视为不过滤;服务层白名单映射为 SQL 列名后通过 mapper @Param 单独传入 */ + @Pattern(regexp = "^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$", + message = "queryField 非法") + private String queryField; + + /** 可空:默认 contains */ + @Pattern(regexp = "^(contains|notContains|equals)?$", message = "matchType 非法") + private String matchType; + + /** 可空:缺省视为不过滤 */ + @Size(max = 100) + private String queryValue; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java new file mode 100644 index 0000000..9e69d46 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java @@ -0,0 +1,33 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.util.List; + +/** + * REQ-USR-002 用户修改入参。 + * 与 {@link UserCreateDTO} 相比剥除 sUserNo / sUserName(登录身份不可改); + * 密码不通过本接口修改,亦不在 DTO 里。 + */ +@Data +public class UserUpdateDTO { + + /** 可空:null 表示清空员工关联(service 层借 iStaffId.IGNORED 策略写入 NULL) */ + private Integer iStaffId; + + @NotBlank + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一") + private String sUserType; + + @NotBlank + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一") + private String sLanguage; + + /** 可空:null 表示保持原值;显式覆盖 */ + private Boolean bCanModifyDocs; + + /** 可空:每元素须存在且未软删除;空数组 / null 都视为清空全部授权关联 */ + private List permissionCategoryIds; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java b/backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java new file mode 100644 index 0000000..4efca81 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java @@ -0,0 +1,54 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** REQ-USR-001 引入。表 tPermissionCategory(详见 docs/03 § tPermissionCategory)。 */ +@Data +@TableName("tPermissionCategory") +public class PermissionCategoryEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sCategoryCode") + private String sCategoryCode; + + @TableField("sCategoryName") + private String sCategoryName; + + @TableField("iParentId") + private Integer iParentId; + + @TableField("iSortOrder") + private Integer iSortOrder; + + @TableField("sCreatedBy") + private String sCreatedBy; + + @TableField("bDeleted") + private Boolean bDeleted; + + @TableField("tDeletedDate") + private LocalDateTime tDeletedDate; + + @TableField("sDeletedBy") + private String sDeletedBy; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java b/backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java new file mode 100644 index 0000000..7d9489d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java @@ -0,0 +1,51 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** REQ-USR-001 引入。表 tStaff(详见 docs/03 § tStaff)。 */ +@Data +@TableName("tStaff") +public class StaffEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sStaffNo") + private String sStaffNo; + + @TableField("sStaffName") + private String sStaffName; + + @TableField("sDepartment") + private String sDepartment; + + @TableField("sCreatedBy") + private String sCreatedBy; + + @TableField("bDeleted") + private Boolean bDeleted; + + @TableField("tDeletedDate") + private LocalDateTime tDeletedDate; + + @TableField("sDeletedBy") + private String sDeletedBy; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java b/backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java new file mode 100644 index 0000000..610b536 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java @@ -0,0 +1,69 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** REQ-USR-001 用户主数据。表 tUser(详见 docs/03 § tUser)。 */ +@Data +@TableName("tUser") +public class UserEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sUserNo") + private String sUserNo; + + @TableField("sUserName") + private String sUserName; + + /** REQ-USR-002 允许更新为 null(清空员工关联),用 IGNORED 让 MP updateById 把 NULL 写入 SQL。 + * 注意:此策略意味着任何 updateById 都会写 iStaffId;调用方必须 selectById 后再 updateById(load-then-modify)。 */ + @TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED) + private Integer iStaffId; + + @TableField("sUserType") + private String sUserType; + + @TableField("sLanguage") + private String sLanguage; + + @TableField("bCanModifyDocs") + private Boolean bCanModifyDocs; + + @TableField("sPasswordHash") + private String sPasswordHash; + + @TableField("tLastLoginDate") + private LocalDateTime tLastLoginDate; + + @TableField("sCreatedBy") + private String sCreatedBy; + + @TableField("bDeleted") + private Boolean bDeleted; + + @TableField("tDeletedDate") + private LocalDateTime tDeletedDate; + + @TableField("sDeletedBy") + private String sDeletedBy; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java b/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java new file mode 100644 index 0000000..4f6d5ab --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java @@ -0,0 +1,42 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** REQ-USR-001 引入。表 tUserPermission(详见 docs/03 § tUserPermission)。 */ +@Data +@TableName("tUserPermission") +public class UserPermissionEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("iUserId") + private Integer iUserId; + + @TableField("iCategoryId") + private Integer iCategoryId; + + // docs/03 § tUserPermission 修订版无 bSelected 列——关联记录存在即「已选」,无需独立 flag。 + // 早期 REQ-USR-001 spec/plan 草稿曾包含 bSelected,与 SSoT docs/03 不一致;以 docs/03 为准。 + + @TableField("sCreatedBy") + private String sCreatedBy; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java new file mode 100644 index 0000000..a361809 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java @@ -0,0 +1,7 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; + +public interface PermissionCategoryMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java new file mode 100644 index 0000000..ec45391 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java @@ -0,0 +1,7 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.module.usr.entity.StaffEntity; + +public interface StaffMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java new file mode 100644 index 0000000..0643443 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java @@ -0,0 +1,19 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.vo.UserListItemVO; +import org.apache.ibatis.annotations.Param; + +public interface UserMapper extends BaseMapper { + + /** + * REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。 + * @param column service 层白名单映射后的 SQL 列字符串(如 "u.sUserName");外部输入绝不直接走这里。 + */ + IPage searchUsers(IPage page, + @Param("query") UserQueryDTO query, + @Param("column") String column); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java new file mode 100644 index 0000000..2027cc1 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java @@ -0,0 +1,7 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.module.usr.entity.UserPermissionEntity; + +public interface UserPermissionMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java b/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java new file mode 100644 index 0000000..50f9e86 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java @@ -0,0 +1,71 @@ +package com.xly.erp.module.usr.security; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * REQ-USR-004 内存版登录失败计数 / 锁定。 + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。 + */ +@Component +public class InMemoryLoginAttemptStore implements LoginAttemptStore { + + static final int LOCK_THRESHOLD = 5; + static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public long cooldownSeconds(String username) { + FailRecord r = store.get(username); + if (r == null || r.lockUntil == null) { + return 0L; + } + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); + if (remaining <= 0L) { + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0) + store.remove(username); + return 0L; + } + return remaining; + } + + @Override + public void recordFailure(String username) { + Instant now = Instant.now(); + store.compute(username, (k, prev) -> { + // 锁定到期 → reset 重新起算 + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil)) + ? new FailRecord() + : (prev == null ? new FailRecord() : prev); + r.count++; + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { + r.lockUntil = now.plus(LOCK_DURATION); + } + return r; + }); + } + + @Override + public void clear(String username) { + store.remove(username); + } + + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。 + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */ + public void expireLockForTest(String username) { + store.computeIfPresent(username, (k, r) -> { + r.lockUntil = Instant.now().minusSeconds(1); + return r; + }); + } + + static class FailRecord { + int count; + Instant lockUntil; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java b/backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java new file mode 100644 index 0000000..1faedc2 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java @@ -0,0 +1,50 @@ +package com.xly.erp.module.usr.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +/** REQ-USR-004 JWT 签发 / 校验封装。HS256,secret 来自 .env.local JWT_SECRET。 */ +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final long expiresInSeconds; + + public JwtTokenProvider(@Value("${erp.jwt.secret}") String secret, + @Value("${erp.jwt.expires-in-seconds}") long expiresInSeconds) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expiresInSeconds = expiresInSeconds; + } + + public long getExpiresInSeconds() { + return expiresInSeconds; + } + + public String sign(int uid, String username, String userType) { + Instant now = Instant.now(); + return Jwts.builder() + .subject(username) + .claim("uid", uid) + .claim("type", userType) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(expiresInSeconds))) + .signWith(key) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java new file mode 100644 index 0000000..737671b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java @@ -0,0 +1,14 @@ +package com.xly.erp.module.usr.security; + +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */ +public interface LoginAttemptStore { + + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ + long cooldownSeconds(String username); + + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ + void recordFailure(String username); + + /** 登录成功清空记录 */ + void clear(String username); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java new file mode 100644 index 0000000..3edf345 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java @@ -0,0 +1,9 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.vo.LoginResultVO; + +public interface LoginService { + /** REQ-USR-004 用户登录 */ + LoginResultVO login(LoginDTO dto); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java new file mode 100644 index 0000000..dc010c8 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java @@ -0,0 +1,19 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserCreateDTO; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.dto.UserUpdateDTO; +import com.xly.erp.module.usr.vo.UserListItemVO; +import com.xly.erp.module.usr.vo.UserVO; + +public interface UserService { + /** REQ-USR-001 用户新增 */ + UserVO create(UserCreateDTO dto); + + /** REQ-USR-002 用户修改 */ + UserVO update(Integer id, UserUpdateDTO dto); + + /** REQ-USR-003 用户列表查询 */ + PageResult search(UserQueryDTO query); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java new file mode 100644 index 0000000..665c530 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java @@ -0,0 +1,87 @@ +package com.xly.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.xly.erp.common.exception.AccountLockedException; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.service.LoginService; +import com.xly.erp.module.usr.vo.LoginResultVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** REQ-USR-004 用户登录 service */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LoginServiceImpl implements LoginService { + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final LoginAttemptStore attemptStore; + private final JwtTokenProvider jwtTokenProvider; + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginResultVO login(LoginDTO dto) { + String username = dto.getSUserName(); + + // 1. 锁定检查 + long cooldown = attemptStore.cooldownSeconds(username); + if (cooldown > 0L) { + log.info("Login locked username={} cooldown={}s", username, cooldown); + throw new AccountLockedException(cooldown); + } + + // 2. 查用户 + UserEntity user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserEntity::getSUserName, username) + .eq(UserEntity::getBDeleted, false)); + if (user == null) { + log.info("Login user-not-found username={}", username); + attemptStore.recordFailure(username); + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁) + long cd = attemptStore.cooldownSeconds(username); + if (cd > 0L) { + throw new AccountLockedException(cd); + } + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); + } + + // 3. BCrypt 校验 + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) { + log.info("Login bad-password username={}", username); + attemptStore.recordFailure(username); + long cd = attemptStore.cooldownSeconds(username); + if (cd > 0L) { + throw new AccountLockedException(cd); + } + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); + } + + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate + attemptStore.clear(username); + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType()); + userMapper.update(null, + new LambdaUpdateWrapper() + .eq(UserEntity::getIIncrement, user.getIIncrement()) + .set(UserEntity::getTLastLoginDate, LocalDateTime.now())); + log.info("Login success username={} uid={}", username, user.getIIncrement()); + + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo( + user.getIIncrement(), user.getSUserNo(), user.getSUserName(), + user.getSUserType(), user.getSLanguage()); + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..ace875b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java @@ -0,0 +1,230 @@ +package com.xly.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserCreateDTO; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.dto.UserUpdateDTO; +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; +import com.xly.erp.module.usr.entity.StaffEntity; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.entity.UserPermissionEntity; +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.mapper.UserPermissionMapper; +import com.xly.erp.module.usr.service.UserService; +import com.xly.erp.module.usr.vo.UserListItemVO; +import com.xly.erp.module.usr.vo.UserVO; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** REQ-USR-001 用户新增 */ +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private static final String INITIAL_PASSWORD = "666666"; + + private final UserMapper userMapper; + private final StaffMapper staffMapper; + private final PermissionCategoryMapper permissionCategoryMapper; + private final UserPermissionMapper userPermissionMapper; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional(rollbackFor = Exception.class) + public UserVO create(UserCreateDTO dto) { + // 1. 唯一性预检:sUserName / sUserNo(bDeleted=0 范围) + Long existsByName = userMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserEntity::getSUserName, dto.getSUserName()) + .eq(UserEntity::getBDeleted, false)); + if (existsByName != null && existsByName > 0L) { + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); + } + Long existsByNo = userMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserEntity::getSUserNo, dto.getSUserNo()) + .eq(UserEntity::getBDeleted, false)); + if (existsByNo != null && existsByNo > 0L) { + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); + } + + // 2. iStaffId 校验 + if (dto.getIStaffId() != null) { + StaffEntity staff = staffMapper.selectById(dto.getIStaffId()); + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) { + throw new BizException(ErrorCode.STAFF_NOT_FOUND); + } + } + + // 3. 权限分类校验:批量查;要求每个 id 都存在且未软删除 + List categoryIds = dto.getPermissionCategoryIds() == null + ? new ArrayList<>() : dto.getPermissionCategoryIds(); + if (!categoryIds.isEmpty()) { + List found = permissionCategoryMapper.selectBatchIds(categoryIds); + if (found.size() != categoryIds.size() + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) { + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND); + } + } + + // 4. 构造 UserEntity 并 insert + UserEntity user = new UserEntity(); + user.setSUserNo(dto.getSUserNo()); + user.setSUserName(dto.getSUserName()); + user.setIStaffId(dto.getIStaffId()); + user.setSUserType(dto.getSUserType()); + user.setSLanguage(dto.getSLanguage()); + user.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : Boolean.FALSE); + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD)); + user.setTCreateDate(LocalDateTime.now()); + user.setBDeleted(Boolean.FALSE); + // tLastLoginDate / sCreatedBy / sBrandsId / sSubsidiaryId / sId / tDeletedDate / sDeletedBy 留 null + + try { + userMapper.insert(user); + } catch (DuplicateKeyException dup) { + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); + } + + // 5. 批量 insert UserPermission + for (Integer categoryId : categoryIds) { + UserPermissionEntity up = new UserPermissionEntity(); + up.setIUserId(user.getIIncrement()); + up.setICategoryId(categoryId); + up.setTCreateDate(LocalDateTime.now()); + // sCreatedBy 留 null(REQ-USR-004 后回填) + userPermissionMapper.insert(up); + } + + return UserVO.from(user, categoryIds); + } + + /** REQ-USR-002 用户修改 */ + @Override + @Transactional(rollbackFor = Exception.class) + public UserVO update(Integer id, UserUpdateDTO dto) { + // 1. 目标用户存在 + 未软删除 + UserEntity target = userMapper.selectById(id); + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) { + throw new BizException(ErrorCode.USR_NOT_FOUND); + } + + // 2. iStaffId 校验(仅当非空) + if (dto.getIStaffId() != null) { + StaffEntity staff = staffMapper.selectById(dto.getIStaffId()); + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) { + throw new BizException(ErrorCode.STAFF_NOT_FOUND); + } + } + + // 3. 权限分类校验(仅当非空) + List categoryIds = dto.getPermissionCategoryIds() == null + ? new ArrayList<>() : dto.getPermissionCategoryIds(); + if (!categoryIds.isEmpty()) { + List found = permissionCategoryMapper.selectBatchIds(categoryIds); + if (found.size() != categoryIds.size() + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) { + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND); + } + } + + // 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash 等保护字段) + target.setIStaffId(dto.getIStaffId()); // 含 null 清空(依赖 iStaffId.IGNORED 策略) + target.setSUserType(dto.getSUserType()); + target.setSLanguage(dto.getSLanguage()); + if (dto.getBCanModifyDocs() != null) { + target.setBCanModifyDocs(dto.getBCanModifyDocs()); + } + + // 5. 落库 user + userMapper.updateById(target); + + // 6. 重建权限关联:先删后插(清空原有,再按 dto 插入) + userPermissionMapper.delete( + new LambdaQueryWrapper() + .eq(UserPermissionEntity::getIUserId, id)); + for (Integer categoryId : categoryIds) { + UserPermissionEntity up = new UserPermissionEntity(); + up.setIUserId(id); + up.setICategoryId(categoryId); + up.setTCreateDate(LocalDateTime.now()); + userPermissionMapper.insert(up); + } + + return UserVO.from(target, categoryIds); + } + + /** REQ-USR-003 用户列表查询 — queryField 白名单映射 + LEFT JOIN tStaff 分页 */ + private static final Map QUERY_COLUMN_MAP = Map.ofEntries( + Map.entry("username", "u.sUserName"), + Map.entry("staffname", "s.sStaffName"), + Map.entry("userno", "u.sUserNo"), + Map.entry("department", "s.sDepartment"), + Map.entry("usertype", "u.sUserType"), + Map.entry("language", "u.sLanguage"), + Map.entry("deleted", "u.bDeleted"), + Map.entry("lastLoginDate", "u.tLastLoginDate"), + Map.entry("createdBy", "u.sCreatedBy")); + + private static final Set MATCH_TYPES = Set.of("contains", "notContains", "equals"); + + @Override + @Transactional(readOnly = true) + public PageResult search(UserQueryDTO query) { + // 1. queryField 白名单 + 列映射(防 SQL 注入)。 + // column 是 service 内部局部变量,通过 mapper @Param("column") 单独传入;不写回 DTO, + // 避免 GET query-string 绑定(@JsonIgnore 仅对 Jackson 生效,无法防 setter 注入)。 + String column = null; + if (query.getQueryField() != null && !query.getQueryField().isEmpty()) { + column = QUERY_COLUMN_MAP.get(query.getQueryField()); + if (column == null) { + throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField()); + } + } + + // 2. matchType 白名单 + if (query.getMatchType() != null && !query.getMatchType().isEmpty() + && !MATCH_TYPES.contains(query.getMatchType())) { + throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType()); + } + + // 3. spec § 业务规则 6:deleted 字段值标准化('true'/'1' → '1';'false'/'0' → '0';其它非法) + if ("deleted".equals(query.getQueryField()) + && query.getQueryValue() != null && !query.getQueryValue().isEmpty()) { + String v = query.getQueryValue().trim().toLowerCase(); + if ("true".equals(v) || "1".equals(v)) { + query.setQueryValue("1"); + } else if ("false".equals(v) || "0".equals(v)) { + query.setQueryValue("0"); + } else { + throw new BizException(ErrorCode.PARAM_INVALID, "deleted queryValue 仅支持 true/false/1/0"); + } + } + + // 4. 默认值兜底 + int pageNum = query.getPageNum() == null ? 1 : query.getPageNum(); + int pageSize = query.getPageSize() == null ? 20 : query.getPageSize(); + + // 5. MP 分页查询 + IPage page = new Page<>(pageNum, pageSize); + IPage result = userMapper.searchUsers(page, query, column); + + return PageResult.of(result); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java new file mode 100644 index 0000000..ba65a76 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java @@ -0,0 +1,26 @@ +package com.xly.erp.module.usr.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** REQ-USR-004 登录结果 VO(含 JWT + 用户基本信息) */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginResultVO { + private String accessToken; + private long expiresIn; + private LoginUserInfo user; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class LoginUserInfo { + private Integer iIncrement; + private String sUserNo; + private String sUserName; + private String sUserType; + private String sLanguage; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java new file mode 100644 index 0000000..86884bf --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java @@ -0,0 +1,21 @@ +package com.xly.erp.module.usr.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** REQ-USR-003 用户列表行 VO(含 LEFT JOIN tStaff 出来的 sStaffName / sDepartment)。 */ +@Data +public class UserListItemVO { + private Integer iIncrement; + private String sUserName; + private String sStaffName; + private String sUserNo; + private String sDepartment; + private String sUserType; + private String sLanguage; + private Boolean bDeleted; + private LocalDateTime tLastLoginDate; + private String sCreatedBy; + private LocalDateTime tCreateDate; +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java new file mode 100644 index 0000000..e1d7ee2 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java @@ -0,0 +1,38 @@ +package com.xly.erp.module.usr.vo; + +import com.xly.erp.module.usr.entity.UserEntity; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** REQ-USR-001 用户 VO(不含 sPasswordHash 等内部字段)。 */ +@Data +public class UserVO { + private Integer iIncrement; + private String sUserNo; + private String sUserName; + private Integer iStaffId; + private String sUserType; + private String sLanguage; + private Boolean bCanModifyDocs; + private LocalDateTime tCreateDate; + private Boolean bDeleted; + private List permissionCategoryIds = new ArrayList<>(); + + public static UserVO from(UserEntity e, List permissionCategoryIds) { + UserVO v = new UserVO(); + v.setIIncrement(e.getIIncrement()); + v.setSUserNo(e.getSUserNo()); + v.setSUserName(e.getSUserName()); + v.setIStaffId(e.getIStaffId()); + v.setSUserType(e.getSUserType()); + v.setSLanguage(e.getSLanguage()); + v.setBCanModifyDocs(e.getBCanModifyDocs()); + v.setTCreateDate(e.getTCreateDate()); + v.setBDeleted(e.getBDeleted()); + v.setPermissionCategoryIds(permissionCategoryIds == null ? new ArrayList<>() : permissionCategoryIds); + return v; + } +} diff --git a/backend/src/main/resources/mapper/usr/UserMapper.xml b/backend/src/main/resources/mapper/usr/UserMapper.xml new file mode 100644 index 0000000..4207d51 --- /dev/null +++ b/backend/src/main/resources/mapper/usr/UserMapper.xml @@ -0,0 +1,42 @@ + + + + + + + diff --git a/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java index 12eebe3..5239297 100644 --- a/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java +++ b/backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java @@ -51,5 +51,11 @@ class ApiResponseTest { assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421); assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921); assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912); + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); + assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431); + assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101); + assertThat(ErrorCode.LOGIN_ACCOUNT_LOCKED.getCode()).isEqualTo(40301); } } diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java new file mode 100644 index 0000000..eab6c72 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java @@ -0,0 +1,239 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.InMemoryLoginAttemptStore; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@Rollback +class LoginControllerIT { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired UserMapper userMapper; + @Autowired PasswordEncoder passwordEncoder; + @Autowired InMemoryLoginAttemptStore attemptStore; + @Autowired JwtTokenProvider jwtTokenProvider; + + private String userName; + + @BeforeEach + void setUp() { + userName = "login_" + System.nanoTime(); + // 每个测试在 store 里清干净(store 是 Spring singleton 跨测试存活) + attemptStore.clear(userName); + } + + private Integer insertUser(String pw) { + UserEntity u = new UserEntity(); + u.setSUserNo("uno_" + System.nanoTime()); + u.setSUserName(userName); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash(passwordEncoder.encode(pw)); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + userMapper.insert(u); + return u.getIIncrement(); + } + + private LoginDTO loginDto(String name, String pw) { + LoginDTO d = new LoginDTO(); + d.setSUserName(name); + d.setSPassword(pw); + d.setSVersion("standard"); + return d; + } + + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } + + @Test + void login_validCredentials_returns200WithToken() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").isString()) + .andExpect(jsonPath("$.data.expiresIn").value(7200)) + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")); + } + + @Test + void login_jwtClaimsAreCorrect() throws Exception { + Integer userId = insertUser("666666"); + + MvcResult result = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andReturn(); + + String body = result.getResponse().getContentAsString(); + String token = objectMapper.readTree(body).path("data").path("accessToken").asText(); + assertThat(token).isNotEmpty(); + + Claims claims = jwtTokenProvider.parse(token); + assertThat(claims.getSubject()).isEqualTo(userName); + assertThat(claims.get("uid", Integer.class)).isEqualTo(userId); + assertThat(claims.get("type", String.class)).isEqualTo("普通用户"); + } + + @Test + void login_invalidUsername_returns40101() throws Exception { + // 不插入用户 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto("ghost_" + System.nanoTime(), "any")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_wrongPassword_returns40101() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_password")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_softDeletedUser_returns40101() throws Exception { + Integer userId = insertUser("666666"); + UserEntity patch = new UserEntity(); + patch.setIIncrement(userId); + patch.setBDeleted(true); + userMapper.updateById(patch); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_missingPassword_returns40010() throws Exception { + LoginDTO dto = loginDto(userName, "any"); + dto.setSPassword(null); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void login_invalidVersion_returns40010() throws Exception { + LoginDTO dto = loginDto(userName, "any"); + dto.setSVersion("experimental"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void login_5thFailureLocks_returns40301() throws Exception { + insertUser("666666"); + + // 4 次错误密码(不锁定) + for (int i = 0; i < 4; i++) { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_" + i)))) + .andExpect(jsonPath("$.code").value(40101)); + } + + // 第 5 次错误密码:触发锁定,返回 40301 + cooldownSeconds + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_5")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40301)) + .andExpect(jsonPath("$.data.cooldownSeconds").isNumber()); + + // 锁定后正确密码也 40301 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(jsonPath("$.code").value(40301)); + } + + @Test + void login_responseExcludesSPasswordHash() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); + } + + @Test + void login_afterLockExpiry_returns200() throws Exception { + insertUser("666666"); + + // 5 次错误密码 → 锁定 + for (int i = 0; i < 5; i++) { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_" + i)))) + .andReturn(); + } + + // 验证当前确实锁定 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(jsonPath("$.code").value(40301)); + + // 把 lockUntil 拨到过去模拟锁定到期 + attemptStore.expireLockForTest(userName); + + // 锁定到期 + 正确密码 → 200 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").isString()); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java new file mode 100644 index 0000000..1ed80c7 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java @@ -0,0 +1,498 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.module.usr.dto.UserCreateDTO; +import com.xly.erp.module.usr.dto.UserUpdateDTO; +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; +import com.xly.erp.module.usr.entity.StaffEntity; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.entity.UserPermissionEntity; +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.mapper.UserPermissionMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@Rollback +class UserControllerIT { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired UserMapper userMapper; + @Autowired StaffMapper staffMapper; + @Autowired PermissionCategoryMapper permissionCategoryMapper; + @Autowired UserPermissionMapper userPermissionMapper; + + private UserCreateDTO baseDto(String userName) { + UserCreateDTO d = new UserCreateDTO(); + d.setSUserNo("uno_" + System.nanoTime()); + d.setSUserName(userName); + d.setSUserType("普通用户"); + d.setSLanguage("zh"); + return d; + } + + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } + + private Integer insertStaff() { + StaffEntity s = new StaffEntity(); + s.setSStaffNo("st_" + System.nanoTime()); + s.setSStaffName("员工A"); + s.setBDeleted(false); + s.setTCreateDate(LocalDateTime.now()); + staffMapper.insert(s); + return s.getIIncrement(); + } + + private Integer insertCategory() { + PermissionCategoryEntity p = new PermissionCategoryEntity(); + p.setSCategoryCode("c_" + System.nanoTime()); + p.setSCategoryName("分类"); + p.setISortOrder(0); + p.setBDeleted(false); + p.setTCreateDate(LocalDateTime.now()); + permissionCategoryMapper.insert(p); + return p.getIIncrement(); + } + + @Test + void post_minimalFields_returns200() throws Exception { + UserCreateDTO dto = baseDto("alice_" + System.nanoTime()); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.iIncrement").isNumber()) + .andExpect(jsonPath("$.data.sUserName").value(dto.getSUserName())) + .andExpect(jsonPath("$.data.bCanModifyDocs").value(false)) + .andExpect(jsonPath("$.data.permissionCategoryIds").isArray()) + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0)); + } + + @Test + void post_withStaffAndPermissions_returns200_andDbAssociated() throws Exception { + Integer staffId = insertStaff(); + Integer cat1 = insertCategory(); + Integer cat2 = insertCategory(); + Integer cat3 = insertCategory(); + + UserCreateDTO dto = baseDto("bob_" + System.nanoTime()); + dto.setIStaffId(staffId); + dto.setPermissionCategoryIds(List.of(cat1, cat2, cat3)); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.iStaffId").value(staffId)); + + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class) + .eq(UserEntity::getSUserName, dto.getSUserName())); + assertThat(u).isNotNull(); + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) + .eq(UserPermissionEntity::getIUserId, u.getIIncrement())); + assertThat(upCount).isEqualTo(3L); + } + + @Test + void post_duplicateUserName_returns40921() throws Exception { + String userName = "dup_" + System.nanoTime(); + UserCreateDTO first = baseDto(userName); + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(first))) + .andExpect(status().isOk()); + + UserCreateDTO second = baseDto(userName); + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(second))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40921)); + } + + @Test + void post_staffNotFound_returns40421() throws Exception { + UserCreateDTO dto = baseDto("noStaff_" + System.nanoTime()); + dto.setIStaffId(999999); + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40421)); + } + + @Test + void post_permissionCategoryNotFound_returns40422() throws Exception { + UserCreateDTO dto = baseDto("noCat_" + System.nanoTime()); + dto.setPermissionCategoryIds(List.of(999999)); + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40422)); + } + + @Test + void post_passwordHashedInDb_notPlaintext() throws Exception { + UserCreateDTO dto = baseDto("pw_" + System.nanoTime()); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()); + + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class) + .eq(UserEntity::getSUserName, dto.getSUserName())); + assertThat(u.getSPasswordHash()) + .satisfiesAnyOf( + h -> assertThat(h).startsWith("$2a$"), + h -> assertThat(h).startsWith("$2b$"), + h -> assertThat(h).startsWith("$2y$")) + .doesNotContain("666666"); + } + + @Test + void post_responseExcludesSPasswordHash() throws Exception { + UserCreateDTO dto = baseDto("priv_" + System.nanoTime()); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sPasswordHash").doesNotExist()); + } + + // ============================================================ + // REQ-USR-002 PUT 系列 + // ============================================================ + + private Integer insertUser(String userName, Integer staffId, List categoryIds) { + UserEntity u = new UserEntity(); + u.setSUserNo("uno_" + System.nanoTime()); + u.setSUserName(userName); + u.setIStaffId(staffId); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$origUser"); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + userMapper.insert(u); + for (Integer cid : categoryIds) { + UserPermissionEntity up = new UserPermissionEntity(); + up.setIUserId(u.getIIncrement()); + up.setICategoryId(cid); + up.setTCreateDate(LocalDateTime.now()); + userPermissionMapper.insert(up); + } + return u.getIIncrement(); + } + + private UserUpdateDTO updateDto(Integer staffId, List permissionIds) { + UserUpdateDTO d = new UserUpdateDTO(); + d.setIStaffId(staffId); + d.setSUserType("超级管理员"); + d.setSLanguage("en"); + d.setBCanModifyDocs(true); + d.setPermissionCategoryIds(permissionIds); + return d; + } + + @Test + void put_validUpdate_returns200_andDbReflects() throws Exception { + Integer staff1 = insertStaff(); + Integer staff2 = insertStaff(); + Integer cat1 = insertCategory(); + Integer cat2 = insertCategory(); + Integer cat3 = insertCategory(); + Integer userId = insertUser("upd_" + System.nanoTime(), staff1, List.of(cat1, cat2, cat3)); + + Integer catNew1 = insertCategory(); + Integer catNew2 = insertCategory(); + + UserUpdateDTO dto = updateDto(staff2, List.of(catNew1, catNew2)); + + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.iStaffId").value(staff2)) + .andExpect(jsonPath("$.data.sUserType").value("超级管理员")) + .andExpect(jsonPath("$.data.sLanguage").value("en")); + + UserEntity reloaded = userMapper.selectById(userId); + assertThat(reloaded.getIStaffId()).isEqualTo(staff2); + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员"); + assertThat(reloaded.getBCanModifyDocs()).isTrue(); + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) + .eq(UserPermissionEntity::getIUserId, userId)); + assertThat(upCount).isEqualTo(2L); // 原 3 删 + 新 2 插 + } + + @Test + void put_clearStaffId_setsNull() throws Exception { + Integer staffId = insertStaff(); + Integer userId = insertUser("clr_" + System.nanoTime(), staffId, List.of()); + + UserUpdateDTO dto = updateDto(null, List.of()); + + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + assertThat(userMapper.selectById(userId).getIStaffId()).isNull(); + } + + @Test + void put_emptyPermissionCategoryIds_clearsAssociations() throws Exception { + Integer cat1 = insertCategory(); + Integer cat2 = insertCategory(); + Integer userId = insertUser("emp_" + System.nanoTime(), null, List.of(cat1, cat2)); + + UserUpdateDTO dto = updateDto(null, List.of()); + + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) + .eq(UserPermissionEntity::getIUserId, userId)); + assertThat(upCount).isZero(); + } + + @Test + void put_targetNotFound_returns40431() throws Exception { + UserUpdateDTO dto = updateDto(null, List.of()); + mockMvc.perform(put("/api/users/999999") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40431)); + } + + @Test + void put_staffNotFound_returns40421() throws Exception { + Integer userId = insertUser("nost_" + System.nanoTime(), null, List.of()); + UserUpdateDTO dto = updateDto(999999, List.of()); + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40421)); + } + + @Test + void put_permissionCategoryNotFound_returns40422() throws Exception { + Integer userId = insertUser("noc_" + System.nanoTime(), null, List.of()); + UserUpdateDTO dto = updateDto(null, List.of(999999)); + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40422)); + } + + @Test + void put_missingRequired_returns40010() throws Exception { + Integer userId = insertUser("miss_" + System.nanoTime(), null, List.of()); + UserUpdateDTO dto = updateDto(null, List.of()); + dto.setSUserType(null); // 必填缺失 + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void put_ignoresProtectedFields_doesNotChangeUserNoOrName() throws Exception { + String origName = "prot_" + System.nanoTime(); + Integer userId = insertUser(origName, null, List.of()); + String origNo = userMapper.selectById(userId).getSUserNo(); + String origHash = userMapper.selectById(userId).getSPasswordHash(); + + // 手工拼 body 含保护字段 + String body = """ + { + "sUserNo": "hijack", + "sUserName": "hijack", + "sPasswordHash": "$2a$10$hijacked", + "iStaffId": null, + "sUserType": "超级管理员", + "sLanguage": "zh-TW", + "bCanModifyDocs": true, + "permissionCategoryIds": [] + } + """; + mockMvc.perform(put("/api/users/" + userId) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + UserEntity reloaded = userMapper.selectById(userId); + assertThat(reloaded.getSUserNo()).isEqualTo(origNo); + assertThat(reloaded.getSUserName()).isEqualTo(origName); + assertThat(reloaded.getSPasswordHash()).isEqualTo(origHash); + // 但其他字段已修改 + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员"); + assertThat(reloaded.getSLanguage()).isEqualTo("zh-TW"); + } + + // ============================================================ + // REQ-USR-003 GET 系列 + // ============================================================ + + @Test + void get_emptyKeyword_returnsAllUndeleted() throws Exception { + Integer staffId = insertStaff(); + insertUser("getall_a_" + System.nanoTime(), staffId, List.of()); + insertUser("getall_b_" + System.nanoTime(), null, List.of()); + + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.total").isNumber()); + } + + @Test + void get_filterByUsernameContains_returnsMatchedSubset() throws Exception { + String alicePrefix = "filt_ali_" + System.nanoTime(); + insertUser(alicePrefix + "_alice", null, List.of()); + insertUser("filt_bob_" + System.nanoTime(), null, List.of()); + + mockMvc.perform(get("/api/users") + .param("queryField", "username") + .param("matchType", "contains") + .param("queryValue", alicePrefix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].sUserName", org.hamcrest.Matchers.startsWith(alicePrefix))); + } + + @Test + void get_filterByStaffnameContains_returnsJoinedResults() throws Exception { + // staff with unique sStaffName, then user referencing it + StaffEntity s = new StaffEntity(); + String staffName = "joined_staff_" + System.nanoTime(); + s.setSStaffNo("st_" + System.nanoTime()); + s.setSStaffName(staffName); + s.setSDepartment("研发部"); + s.setBDeleted(false); + s.setTCreateDate(LocalDateTime.now()); + staffMapper.insert(s); + insertUser("for_staff_" + System.nanoTime(), s.getIIncrement(), List.of()); + + mockMvc.perform(get("/api/users") + .param("queryField", "staffname") + .param("matchType", "contains") + .param("queryValue", staffName)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].sStaffName").value(staffName)); + } + + @Test + void get_filterByDeletedTrue_returnsOnlyDeleted() throws Exception { + // 插一个用户后软删 + Integer userId = insertUser("del_" + System.nanoTime(), null, List.of()); + UserEntity patch = new UserEntity(); + patch.setIIncrement(userId); + patch.setBDeleted(true); + userMapper.updateById(patch); + + mockMvc.perform(get("/api/users") + .param("queryField", "deleted") + .param("matchType", "equals") + .param("queryValue", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.list[?(@.iIncrement==" + userId + ")]").exists()); + } + + @Test + void get_pagination_returnsCorrectSlice() throws Exception { + for (int i = 0; i < 3; i++) { + insertUser("page_" + i + "_" + System.nanoTime(), null, List.of()); + } + + mockMvc.perform(get("/api/users") + .param("pageNum", "1") + .param("pageSize", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pageNum").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(2)) + .andExpect(jsonPath("$.data.list.length()").value(2)); + } + + @Test + void get_responseExcludesInternalFields() throws Exception { + insertUser("priv_" + System.nanoTime(), null, List.of()); + + mockMvc.perform(get("/api/users").param("pageSize", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].sPasswordHash").doesNotExist()) + .andExpect(jsonPath("$.data.list[0].sId").doesNotExist()) + .andExpect(jsonPath("$.data.list[0].iStaffId").doesNotExist()) + .andExpect(jsonPath("$.data.list[0].sBrandsId").doesNotExist()); + } + + @Test + void get_pageSizeTooLarge_returns40010() throws Exception { + mockMvc.perform(get("/api/users").param("pageSize", "101")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void get_invalidQueryField_returns40010() throws Exception { + mockMvc.perform(get("/api/users").param("queryField", "invalid_xyz")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void get_userWithoutStaff_listItemHasNullStaffFields() throws Exception { + String userName = "nostaff_" + System.nanoTime(); + insertUser(userName, null, List.of()); + + mockMvc.perform(get("/api/users") + .param("queryField", "username") + .param("matchType", "equals") + .param("queryValue", userName)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].sUserName").value(userName)) + .andExpect(jsonPath("$.data.list[0].sStaffName").doesNotExist()) + .andExpect(jsonPath("$.data.list[0].sDepartment").doesNotExist()); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java new file mode 100644 index 0000000..fb98d41 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java @@ -0,0 +1,57 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginDTOValidationTest { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private final Validator validator = FACTORY.getValidator(); + + private LoginDTO valid() { + LoginDTO d = new LoginDTO(); + d.setSUserName("alice"); + d.setSPassword("666666"); + d.setSVersion("standard"); + return d; + } + + @Test + void allValid_yieldsNoViolations() { + Set> v = validator.validate(valid()); + assertThat(v).isEmpty(); + } + + @Test + void blankRequiredFields_yieldsViolations() { + LoginDTO d = new LoginDTO(); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) + .contains("sUserName", "sPassword", "sVersion"); + } + + @Test + void invalidVersion_yieldsViolation() { + LoginDTO d = valid(); + d.setSVersion("experimental"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sVersion"); + } + + @Test + void overSized_yieldsViolation() { + LoginDTO d = valid(); + d.setSUserName("a".repeat(51)); + d.setSPassword("p".repeat(101)); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) + .contains("sUserName", "sPassword"); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java new file mode 100644 index 0000000..179aab3 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java @@ -0,0 +1,69 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserCreateDTOValidationTest { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private final Validator validator = FACTORY.getValidator(); + + private UserCreateDTO valid() { + UserCreateDTO d = new UserCreateDTO(); + d.setSUserNo("u001"); + d.setSUserName("alice"); + d.setSUserType("普通用户"); + d.setSLanguage("zh"); + d.setBCanModifyDocs(false); + d.setPermissionCategoryIds(List.of()); + return d; + } + + @Test + void allValidFields_yieldsNoViolations() { + Set> v = validator.validate(valid()); + assertThat(v).isEmpty(); + } + + @Test + void blankRequiredFields_yieldsViolations() { + UserCreateDTO d = new UserCreateDTO(); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) + .contains("sUserNo", "sUserName", "sUserType", "sLanguage"); + } + + @Test + void invalidUserTypeEnum_yieldsViolation() { + UserCreateDTO d = valid(); + d.setSUserType("非法值"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType"); + } + + @Test + void invalidLanguageEnum_yieldsViolation() { + UserCreateDTO d = valid(); + d.setSLanguage("fr"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage"); + } + + @Test + void overSizedFields_yieldsViolations() { + UserCreateDTO d = valid(); + d.setSUserNo("a".repeat(51)); + d.setSUserName("a".repeat(51)); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) + .contains("sUserNo", "sUserName"); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java new file mode 100644 index 0000000..142b90d --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java @@ -0,0 +1,65 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserQueryDTOValidationTest { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private final Validator validator = FACTORY.getValidator(); + + private UserQueryDTO valid() { + UserQueryDTO d = new UserQueryDTO(); + d.setPageNum(1); + d.setPageSize(20); + d.setQueryField("username"); + d.setMatchType("contains"); + d.setQueryValue("alice"); + return d; + } + + @Test + void allValid_yieldsNoViolations() { + Set> v = validator.validate(valid()); + assertThat(v).isEmpty(); + } + + @Test + void pageSizeTooLarge_yieldsViolation() { + UserQueryDTO d = valid(); + d.setPageSize(101); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize"); + } + + @Test + void pageSizeTooSmall_yieldsViolation() { + UserQueryDTO d = valid(); + d.setPageSize(0); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize"); + } + + @Test + void queryFieldInvalidEnum_yieldsViolation() { + UserQueryDTO d = valid(); + d.setQueryField("invalid_field"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryField"); + } + + @Test + void queryValueOverSized_yieldsViolation() { + UserQueryDTO d = valid(); + d.setQueryValue("a".repeat(101)); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryValue"); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java b/backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java new file mode 100644 index 0000000..6595057 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java @@ -0,0 +1,58 @@ +package com.xly.erp.module.usr.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserUpdateDTOValidationTest { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private final Validator validator = FACTORY.getValidator(); + + private UserUpdateDTO valid() { + UserUpdateDTO d = new UserUpdateDTO(); + d.setSUserType("超级管理员"); + d.setSLanguage("en"); + d.setBCanModifyDocs(true); + d.setIStaffId(7); + d.setPermissionCategoryIds(List.of(1, 2)); + return d; + } + + @Test + void allValidFields_yieldsNoViolations() { + Set> v = validator.validate(valid()); + assertThat(v).isEmpty(); + } + + @Test + void blankRequiredFields_yieldsViolations() { + UserUpdateDTO d = new UserUpdateDTO(); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) + .contains("sUserType", "sLanguage"); + } + + @Test + void invalidUserTypeEnum_yieldsViolation() { + UserUpdateDTO d = valid(); + d.setSUserType("非法值"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType"); + } + + @Test + void invalidLanguageEnum_yieldsViolation() { + UserUpdateDTO d = valid(); + d.setSLanguage("fr"); + Set> v = validator.validate(d); + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage"); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java b/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java new file mode 100644 index 0000000..1289f3a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java @@ -0,0 +1,86 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.entity.StaffEntity; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.vo.UserListItemVO; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Rollback +class UserMapperSearchIT { + + @Autowired UserMapper userMapper; + @Autowired StaffMapper staffMapper; + + private Integer insertStaff(String name) { + StaffEntity s = new StaffEntity(); + s.setSStaffNo("st_" + System.nanoTime()); + s.setSStaffName(name); + s.setSDepartment("研发部"); + s.setBDeleted(false); + s.setTCreateDate(LocalDateTime.now()); + staffMapper.insert(s); + return s.getIIncrement(); + } + + private Integer insertUser(String userName, Integer staffId) { + UserEntity u = new UserEntity(); + u.setSUserNo("uno_" + System.nanoTime()); + u.setSUserName(userName); + u.setIStaffId(staffId); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$x"); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + userMapper.insert(u); + return u.getIIncrement(); + } + + @Test + void searchUsers_emptyFilter_returnsAllUndeletedAsPage() { + Integer staffId = insertStaff("张三"); + insertUser("alice_" + System.nanoTime(), staffId); + insertUser("bob_" + System.nanoTime(), null); + + UserQueryDTO query = new UserQueryDTO(); + IPage result = userMapper.searchUsers(new Page<>(1, 50), query, null); + + assertThat(result.getTotal()).isGreaterThanOrEqualTo(2L); + assertThat(result.getRecords()).extracting(UserListItemVO::getSUserName) + .anyMatch(n -> n.startsWith("alice_") || n.startsWith("bob_")); + } + + @Test + void searchUsers_filterByUserName_filtersCorrectly() { + String alicePrefix = "ali_" + System.nanoTime(); + insertUser(alicePrefix + "_alice", null); + insertUser("bob_unmatch_" + System.nanoTime(), null); + + UserQueryDTO query = new UserQueryDTO(); + query.setQueryField("username"); + query.setMatchType("contains"); + query.setQueryValue(alicePrefix); + + // column 由 service 层映射;mapper IT 直接传 "u.sUserName" + IPage result = userMapper.searchUsers(new Page<>(1, 50), query, "u.sUserName"); + + assertThat(result.getRecords()).hasSize(1); + assertThat(result.getRecords().get(0).getSUserName()).startsWith(alicePrefix); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java b/backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java new file mode 100644 index 0000000..84fe8a9 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java @@ -0,0 +1,106 @@ +package com.xly.erp.module.usr.mapper; + +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; +import com.xly.erp.module.usr.entity.StaffEntity; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.entity.UserPermissionEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Rollback +class UsrMappersIT { + + @Autowired UserMapper userMapper; + @Autowired StaffMapper staffMapper; + @Autowired PermissionCategoryMapper permissionCategoryMapper; + @Autowired UserPermissionMapper userPermissionMapper; + + @Test + void staff_insertAndSelect() { + StaffEntity s = new StaffEntity(); + s.setSStaffNo("staff_" + System.nanoTime()); + s.setSStaffName("张三"); + s.setSDepartment("研发部"); + s.setBDeleted(false); + s.setTCreateDate(LocalDateTime.now()); + assertThat(staffMapper.insert(s)).isEqualTo(1); + assertThat(s.getIIncrement()).isPositive(); + StaffEntity loaded = staffMapper.selectById(s.getIIncrement()); + assertThat(loaded.getSStaffName()).isEqualTo("张三"); + assertThat(loaded.getSDepartment()).isEqualTo("研发部"); + } + + @Test + void permissionCategory_insertAndSelect() { + PermissionCategoryEntity p = new PermissionCategoryEntity(); + p.setSCategoryCode("cat_" + System.nanoTime()); + p.setSCategoryName("基础权限"); + p.setISortOrder(0); + p.setBDeleted(false); + p.setTCreateDate(LocalDateTime.now()); + assertThat(permissionCategoryMapper.insert(p)).isEqualTo(1); + PermissionCategoryEntity loaded = permissionCategoryMapper.selectById(p.getIIncrement()); + assertThat(loaded.getSCategoryName()).isEqualTo("基础权限"); + } + + @Test + void user_insertAndSelect() { + UserEntity u = new UserEntity(); + u.setSUserNo("u_" + System.nanoTime()); + u.setSUserName("alice_" + System.nanoTime()); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$abcdefghijklmnopqrstuv"); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + assertThat(userMapper.insert(u)).isEqualTo(1); + UserEntity loaded = userMapper.selectById(u.getIIncrement()); + assertThat(loaded.getSUserName()).startsWith("alice_"); + assertThat(loaded.getSUserType()).isEqualTo("普通用户"); + assertThat(loaded.getSLanguage()).isEqualTo("zh"); + } + + @Test + void userPermission_insertAndSelect_requiresValidUserAndCategory() { + // 先建合法 user + category(FK 兜底) + UserEntity u = new UserEntity(); + u.setSUserNo("upu_" + System.nanoTime()); + u.setSUserName("upa_" + System.nanoTime()); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$x"); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + userMapper.insert(u); + + PermissionCategoryEntity p = new PermissionCategoryEntity(); + p.setSCategoryCode("upc_" + System.nanoTime()); + p.setSCategoryName("upcat"); + p.setISortOrder(0); + p.setBDeleted(false); + p.setTCreateDate(LocalDateTime.now()); + permissionCategoryMapper.insert(p); + + UserPermissionEntity up = new UserPermissionEntity(); + up.setIUserId(u.getIIncrement()); + up.setICategoryId(p.getIIncrement()); + up.setTCreateDate(LocalDateTime.now()); + assertThat(userPermissionMapper.insert(up)).isEqualTo(1); + UserPermissionEntity loaded = userPermissionMapper.selectById(up.getIIncrement()); + assertThat(loaded.getIUserId()).isEqualTo(u.getIIncrement()); + assertThat(loaded.getICategoryId()).isEqualTo(p.getIIncrement()); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java new file mode 100644 index 0000000..b4746e4 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java @@ -0,0 +1,72 @@ +package com.xly.erp.module.usr.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryLoginAttemptStoreTest { + + private InMemoryLoginAttemptStore store; + + @BeforeEach + void setUp() { + store = new InMemoryLoginAttemptStore(); + } + + @Test + void cooldown_initial_returnsZero() { + assertThat(store.cooldownSeconds("alice")).isZero(); + } + + @Test + void recordFailure_under5_doesNotLock() { + for (int i = 0; i < 4; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isZero(); + } + + @Test + void recordFailure_at5_triggersLock_cooldownPositive() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + long cd = store.cooldownSeconds("alice"); + assertThat(cd).isGreaterThan(0L); + assertThat(cd).isLessThanOrEqualTo(15L * 60L); // 锁定 15 min + } + + @Test + void clear_resetsCount() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + store.clear("alice"); + assertThat(store.cooldownSeconds("alice")).isZero(); + } + + @Test + void cooldown_afterExpiry_resetsCount() { + for (int i = 0; i < 5; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); + + // 把锁定时间拨到过去 → 模拟到期 + store.expireLockForTest("alice"); + + // 到期后 cooldownSeconds 应返回 0 + record 清空 + assertThat(store.cooldownSeconds("alice")).isZero(); + + // 验证 reset 真的清空了 count——再 recordFailure 4 次仍未锁定 + for (int i = 0; i < 4; i++) { + store.recordFailure("alice"); + } + assertThat(store.cooldownSeconds("alice")).isZero(); + + // 第 5 次 record 后再次锁定(业务规则 4 完整重启) + store.recordFailure("alice"); + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java new file mode 100644 index 0000000..350db70 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java @@ -0,0 +1,55 @@ +package com.xly.erp.module.usr.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtTokenProviderTest { + + // 测试专用 fake secret——与 .env.local 生产 JWT_SECRET 无关。 + // 32 字节(256 bit)随机 hex,仅用于单元测试隔离。 + private static final String SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final long EXPIRES_IN = 7200L; + + @Test + void signAndParse_returnsClaims() { + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN); + + String token = provider.sign(42, "alice", "普通用户"); + + Claims claims = provider.parse(token); + assertThat(claims.getSubject()).isEqualTo("alice"); + assertThat(claims.get("uid", Integer.class)).isEqualTo(42); + assertThat(claims.get("type", String.class)).isEqualTo("普通用户"); + assertThat(claims.getExpiration().getTime() - claims.getIssuedAt().getTime()) + .isEqualTo(EXPIRES_IN * 1000L); + } + + @Test + void parseExpiredToken_throwsExpiredJwtException() { + // 用同一 secret 手工签一个已过期 token + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + Instant now = Instant.now(); + String expired = Jwts.builder() + .subject("alice") + .issuedAt(Date.from(now.minusSeconds(7200))) + .expiration(Date.from(now.minusSeconds(60))) + .signWith(key) + .compact(); + + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN); + + assertThatThrownBy(() -> provider.parse(expired)) + .isInstanceOf(ExpiredJwtException.class); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java new file mode 100644 index 0000000..1704398 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java @@ -0,0 +1,165 @@ +package com.xly.erp.module.usr.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.xly.erp.common.exception.AccountLockedException; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.service.impl.LoginServiceImpl; +import com.xly.erp.module.usr.vo.LoginResultVO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoginServiceImplTest { + + @Mock UserMapper userMapper; + @Mock PasswordEncoder passwordEncoder; + @Mock LoginAttemptStore attemptStore; + @Mock JwtTokenProvider jwtTokenProvider; + + @InjectMocks LoginServiceImpl service; + + private LoginDTO dto() { + LoginDTO d = new LoginDTO(); + d.setSUserName("alice"); + d.setSPassword("666666"); + d.setSVersion("standard"); + return d; + } + + private UserEntity userEntity() { + UserEntity u = new UserEntity(); + u.setIIncrement(42); + u.setSUserNo("u001"); + u.setSUserName("alice"); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash("$2a$10$hash"); + u.setBDeleted(false); + return u; + } + + @Test + void login_validCredentials_returnsTokenAndClearsFailCount() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(true); + when(jwtTokenProvider.sign(42, "alice", "普通用户")).thenReturn("jwt.token.value"); + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); + + LoginResultVO vo = service.login(dto()); + + assertThat(vo.getAccessToken()).isEqualTo("jwt.token.value"); + assertThat(vo.getExpiresIn()).isEqualTo(7200L); + assertThat(vo.getUser().getIIncrement()).isEqualTo(42); + assertThat(vo.getUser().getSUserName()).isEqualTo("alice"); + assertThat(vo.getUser().getSUserType()).isEqualTo("普通用户"); + verify(attemptStore).clear("alice"); + verify(userMapper).update(isNull(), any(Wrapper.class)); + } + + @Test + void login_userNotFound_returns40101_recordsFailure() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + + verify(attemptStore).recordFailure("alice"); + verify(userMapper, never()).update(any(), any(Wrapper.class)); + } + + @Test + void login_passwordMismatch_returns40101_recordsFailure() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + + verify(attemptStore).recordFailure("alice"); + verify(userMapper, never()).update(any(), any(Wrapper.class)); + } + + @Test + void login_accountLocked_throwsAccountLockedException_withCooldown() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(540L); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(AccountLockedException.class) + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) + .isEqualTo(540L); + + verify(userMapper, never()).selectOne(any(Wrapper.class)); + } + + @Test + void login_5thFailureTriggersLock_throwsAccountLockedException() { + when(attemptStore.cooldownSeconds("alice")) + .thenReturn(0L) // 入口检查通过 + .thenReturn(900L); // recordFailure 后再查时已锁定 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(AccountLockedException.class) + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) + .isEqualTo(900L); + + verify(attemptStore).recordFailure("alice"); + } + + @Test + void login_successUpdatesTLastLoginDate_viaSetClause() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(jwtTokenProvider.sign(anyInt(), anyString(), anyString())).thenReturn("token"); + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); + + service.login(dto()); + + // 验证 update(entity=null, wrapper=non-null),对应 LambdaUpdateWrapper.set(tLastLoginDate, now) + verify(userMapper, times(1)).update(isNull(), any(Wrapper.class)); + } + + @Test + void login_userSoftDeleted_returns40101() { + // selectOne 已过滤 bDeleted=0 → 软删用户返回 null(与 not found 等效路径) + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java new file mode 100644 index 0000000..8424039 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java @@ -0,0 +1,504 @@ +package com.xly.erp.module.usr.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserCreateDTO; +import com.xly.erp.module.usr.dto.UserQueryDTO; +import com.xly.erp.module.usr.dto.UserUpdateDTO; +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; +import com.xly.erp.module.usr.entity.StaffEntity; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.entity.UserPermissionEntity; +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.mapper.UserPermissionMapper; +import com.xly.erp.module.usr.service.impl.UserServiceImpl; +import com.xly.erp.module.usr.vo.UserListItemVO; +import com.xly.erp.module.usr.vo.UserVO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + + @Mock UserMapper userMapper; + @Mock StaffMapper staffMapper; + @Mock PermissionCategoryMapper permissionCategoryMapper; + @Mock UserPermissionMapper userPermissionMapper; + @Mock PasswordEncoder passwordEncoder; + + @InjectMocks UserServiceImpl service; + + private UserCreateDTO baseDto() { + UserCreateDTO d = new UserCreateDTO(); + d.setSUserNo("u001"); + d.setSUserName("alice"); + d.setSUserType("普通用户"); + d.setSLanguage("zh"); + return d; + } + + @Test + void create_minimalFields_returnsVOWithBCryptHash() { + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$mockhash"); + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { + UserEntity u = inv.getArgument(0); + u.setIIncrement(101); + return 1; + }); + + UserVO vo = service.create(baseDto()); + + assertThat(vo.getIIncrement()).isEqualTo(101); + assertThat(vo.getSUserName()).isEqualTo("alice"); + assertThat(vo.getBCanModifyDocs()).isFalse(); + assertThat(vo.getPermissionCategoryIds()).isEmpty(); + + ArgumentCaptor cap = ArgumentCaptor.forClass(UserEntity.class); + verify(userMapper).insert(cap.capture()); + UserEntity saved = cap.getValue(); + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$mockhash"); + assertThat(saved.getBDeleted()).isFalse(); + assertThat(saved.getTCreateDate()).isNotNull(); + assertThat(saved.getSCreatedBy()).isNull(); + assertThat(saved.getSBrandsId()).isNull(); + } + + @Test + void create_withStaffAndPermissions_writesAssociation() { + UserCreateDTO d = baseDto(); + d.setIStaffId(7); + d.setPermissionCategoryIds(List.of(1, 2, 3)); + + StaffEntity staff = new StaffEntity(); + staff.setIIncrement(7); + staff.setBDeleted(false); + when(staffMapper.selectById(7)).thenReturn(staff); + + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of( + cat(1), cat(2), cat(3) + )); + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { + UserEntity u = inv.getArgument(0); + u.setIIncrement(202); + return 1; + }); + + UserVO vo = service.create(d); + + assertThat(vo.getIIncrement()).isEqualTo(202); + assertThat(vo.getIStaffId()).isEqualTo(7); + assertThat(vo.getPermissionCategoryIds()).containsExactly(1, 2, 3); + + ArgumentCaptor upCap = ArgumentCaptor.forClass(UserPermissionEntity.class); + verify(userPermissionMapper, times(3)).insert(upCap.capture()); + List ups = upCap.getAllValues(); + assertThat(ups).extracting(UserPermissionEntity::getIUserId).containsOnly(202); + assertThat(ups).extracting(UserPermissionEntity::getICategoryId).containsExactly(1, 2, 3); + } + + @Test + void create_duplicateUserName_throws40921() { + // 第一次 selectCount(sUserName) 返回 1 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + + assertThatThrownBy(() -> service.create(baseDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); + verify(userMapper, never()).insert((UserEntity) any()); + } + + @Test + void create_duplicateUserNo_throws40921() { + // 第一次 (sUserName) 返回 0;第二次 (sUserNo) 返回 1 + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L); + + assertThatThrownBy(() -> service.create(baseDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); + verify(userMapper, never()).insert((UserEntity) any()); + } + + @Test + void create_staffNotFound_throws40421() { + UserCreateDTO d = baseDto(); + d.setIStaffId(999999); + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(staffMapper.selectById(999999)).thenReturn(null); + + assertThatThrownBy(() -> service.create(d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); + } + + @Test + void create_staffSoftDeleted_throws40421() { + UserCreateDTO d = baseDto(); + d.setIStaffId(5); + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + StaffEntity deleted = new StaffEntity(); + deleted.setIIncrement(5); + deleted.setBDeleted(true); + when(staffMapper.selectById(5)).thenReturn(deleted); + + assertThatThrownBy(() -> service.create(d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); + } + + @Test + void create_permissionCategoryNotFound_throws40422() { + UserCreateDTO d = baseDto(); + d.setPermissionCategoryIds(List.of(1, 999999)); + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(1))); // 只返回 1 条,缺 999999 + + assertThatThrownBy(() -> service.create(d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()); + verify(userMapper, never()).insert((UserEntity) any()); + } + + @Test + void create_emptyPermissionCategoryIds_doesNotInsertAssociation() { + UserCreateDTO d = baseDto(); + d.setPermissionCategoryIds(List.of()); + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { + ((UserEntity) inv.getArgument(0)).setIIncrement(303); + return 1; + }); + + service.create(d); + + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any()); + } + + @Test + void create_concurrentDuplicate_dupKeyException_mappedTo40921() { + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); + when(userMapper.insert((UserEntity) any())) + .thenThrow(new DuplicateKeyException("uk_user_name")); + + assertThatThrownBy(() -> service.create(baseDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); + } + + private static PermissionCategoryEntity cat(int id) { + PermissionCategoryEntity p = new PermissionCategoryEntity(); + p.setIIncrement(id); + p.setBDeleted(false); + return p; + } + + // ============================================================ + // REQ-USR-002 update 系列 + // ============================================================ + + private UserUpdateDTO updateDto() { + UserUpdateDTO d = new UserUpdateDTO(); + d.setIStaffId(7); + d.setSUserType("超级管理员"); + d.setSLanguage("en"); + d.setBCanModifyDocs(true); + d.setPermissionCategoryIds(List.of(10, 20)); + return d; + } + + private UserEntity existingTargetUser(int id) { + UserEntity u = new UserEntity(); + u.setIIncrement(id); + u.setSUserNo("u_orig"); + u.setSUserName("alice_orig"); + u.setIStaffId(3); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$origHash"); + u.setBDeleted(false); + u.setSCreatedBy("system"); + return u; + } + + @Test + void update_targetNotFound_throws40431() { + when(userMapper.selectById(99)).thenReturn(null); + + assertThatThrownBy(() -> service.update(99, updateDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode()); + } + + @Test + void update_targetSoftDeleted_throws40431() { + UserEntity target = existingTargetUser(100); + target.setBDeleted(true); + when(userMapper.selectById(100)).thenReturn(target); + + assertThatThrownBy(() -> service.update(100, updateDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode()); + } + + @Test + void update_staffNotFound_throws40421() { + UserEntity target = existingTargetUser(101); + when(userMapper.selectById(101)).thenReturn(target); + UserUpdateDTO d = updateDto(); + d.setIStaffId(999999); + when(staffMapper.selectById(999999)).thenReturn(null); + + assertThatThrownBy(() -> service.update(101, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); + } + + @Test + void update_staffSoftDeleted_throws40421() { + UserEntity target = existingTargetUser(102); + when(userMapper.selectById(102)).thenReturn(target); + UserUpdateDTO d = updateDto(); + d.setIStaffId(8); + StaffEntity staff = new StaffEntity(); + staff.setIIncrement(8); + staff.setBDeleted(true); + when(staffMapper.selectById(8)).thenReturn(staff); + + assertThatThrownBy(() -> service.update(102, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); + } + + @Test + void update_permissionCategoryNotFound_throws40422() { + UserEntity target = existingTargetUser(103); + when(userMapper.selectById(103)).thenReturn(target); + UserUpdateDTO d = updateDto(); + d.setIStaffId(null); // 跳过 staff 校验 + d.setPermissionCategoryIds(List.of(10, 999999)); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10))); + + assertThatThrownBy(() -> service.update(103, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()); + } + + @Test + void update_full_returnsVOWithUpdatedFields_andRebuildsPermissions() { + UserEntity target = existingTargetUser(104); + when(userMapper.selectById(104)).thenReturn(target); + StaffEntity staff = new StaffEntity(); + staff.setIIncrement(7); + staff.setBDeleted(false); + when(staffMapper.selectById(7)).thenReturn(staff); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); + + UserVO vo = service.update(104, updateDto()); + + ArgumentCaptor userCap = ArgumentCaptor.forClass(UserEntity.class); + verify(userMapper).updateById(userCap.capture()); + UserEntity saved = userCap.getValue(); + // 已修改字段 + assertThat(saved.getIStaffId()).isEqualTo(7); + assertThat(saved.getSUserType()).isEqualTo("超级管理员"); + assertThat(saved.getSLanguage()).isEqualTo("en"); + assertThat(saved.getBCanModifyDocs()).isTrue(); + // 保留字段 + assertThat(saved.getSUserNo()).isEqualTo("u_orig"); + assertThat(saved.getSUserName()).isEqualTo("alice_orig"); + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$origHash"); + assertThat(saved.getSCreatedBy()).isEqualTo("system"); + + // 关联表先删后插 + verify(userPermissionMapper).delete(any(Wrapper.class)); + ArgumentCaptor upCap = ArgumentCaptor.forClass(UserPermissionEntity.class); + verify(userPermissionMapper, times(2)).insert(upCap.capture()); + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getICategoryId) + .containsExactly(10, 20); + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getIUserId) + .containsOnly(104); + + assertThat(vo.getPermissionCategoryIds()).containsExactly(10, 20); + } + + @Test + void update_partialNullBCanModifyDocs_keepsOriginal() { + UserEntity target = existingTargetUser(105); + target.setBCanModifyDocs(true); // 原值 true + when(userMapper.selectById(105)).thenReturn(target); + StaffEntity staff = new StaffEntity(); + staff.setIIncrement(7); + staff.setBDeleted(false); + when(staffMapper.selectById(7)).thenReturn(staff); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); + + UserUpdateDTO d = updateDto(); + d.setBCanModifyDocs(null); // 期望保留原值 + + service.update(105, d); + + ArgumentCaptor userCap = ArgumentCaptor.forClass(UserEntity.class); + verify(userMapper).updateById(userCap.capture()); + assertThat(userCap.getValue().getBCanModifyDocs()).isTrue(); + } + + @Test + void update_clearStaffId_setsToNull() { + UserEntity target = existingTargetUser(106); + target.setIStaffId(3); // 原本有 + when(userMapper.selectById(106)).thenReturn(target); + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); + + UserUpdateDTO d = updateDto(); + d.setIStaffId(null); // 显式清空 + + service.update(106, d); + + ArgumentCaptor userCap = ArgumentCaptor.forClass(UserEntity.class); + verify(userMapper).updateById(userCap.capture()); + assertThat(userCap.getValue().getIStaffId()).isNull(); + } + + @Test + void update_emptyPermissionCategoryIds_clearsAllAssociations() { + UserEntity target = existingTargetUser(107); + when(userMapper.selectById(107)).thenReturn(target); + StaffEntity staff = new StaffEntity(); + staff.setIIncrement(7); + staff.setBDeleted(false); + when(staffMapper.selectById(7)).thenReturn(staff); + + UserUpdateDTO d = updateDto(); + d.setPermissionCategoryIds(List.of()); // 清空全部 + + service.update(107, d); + + verify(userPermissionMapper).delete(any(Wrapper.class)); + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any()); + } + + // ============================================================ + // REQ-USR-003 search 系列 + // ============================================================ + + @Test + void search_emptyDb_returnsEmptyPage() { + UserQueryDTO query = new UserQueryDTO(); + IPage emptyPage = new Page<>(1, 20); + emptyPage.setTotal(0L); + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); + + PageResult result = service.search(query); + assertThat(result.getTotal()).isZero(); + assertThat(result.getList()).isEmpty(); + } + + @Test + void search_invalidQueryField_throws40010() { + UserQueryDTO query = new UserQueryDTO(); + query.setQueryField("invalid_field"); + + assertThatThrownBy(() -> service.search(query)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.PARAM_INVALID.getCode()); + } + + @Test + void search_invalidMatchType_throws40010() { + UserQueryDTO query = new UserQueryDTO(); + query.setMatchType("like"); // 非白名单 + + assertThatThrownBy(() -> service.search(query)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.PARAM_INVALID.getCode()); + } + + @Test + void search_passesMappedColumnToMapper() { + UserQueryDTO query = new UserQueryDTO(); + query.setQueryField("username"); + query.setQueryValue("alice"); + IPage emptyPage = new Page<>(1, 20); + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); + + service.search(query); + + ArgumentCaptor colCap = ArgumentCaptor.forClass(String.class); + verify(userMapper).searchUsers(any(IPage.class), any(UserQueryDTO.class), colCap.capture()); + assertThat(colCap.getValue()).isEqualTo("u.sUserName"); + } + + @Test + void search_appliesDefaultPagination_whenNullPageNumOrSize() { + UserQueryDTO query = new UserQueryDTO(); + query.setPageNum(null); + query.setPageSize(null); + IPage emptyPage = new Page<>(1, 20); + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); + + service.search(query); + + ArgumentCaptor pageCap = ArgumentCaptor.forClass(IPage.class); + verify(userMapper).searchUsers(pageCap.capture(), any(UserQueryDTO.class), any()); + IPage page = pageCap.getValue(); + assertThat(page.getCurrent()).isEqualTo(1L); + assertThat(page.getSize()).isEqualTo(20L); + } + + @Test + void search_deletedQueryValueTrue_normalizedToOne() { + UserQueryDTO query = new UserQueryDTO(); + query.setQueryField("deleted"); + query.setQueryValue("true"); + query.setMatchType("equals"); + IPage emptyPage = new Page<>(1, 20); + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); + + service.search(query); + + ArgumentCaptor cap = ArgumentCaptor.forClass(UserQueryDTO.class); + verify(userMapper).searchUsers(any(IPage.class), cap.capture(), any()); + assertThat(cap.getValue().getQueryValue()).isEqualTo("1"); + } +} diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 2bbdca8..adf8a11 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -68,9 +68,9 @@ - module_usr 用户管理 - 依赖: — - 路径: backend/src/main/java/com/xly/erp/module/usr/, frontend/src/pages/usr/ - - MR: — + - MR: !2 - 功能: - - [ ] REQ-USR-001 用户新增 - - [ ] REQ-USR-002 用户修改 - - [ ] REQ-USR-003 用户查询 - - [ ] REQ-USR-004 用户登录 + - [x] REQ-USR-001 用户新增 + - [x] REQ-USR-002 用户修改 + - [x] REQ-USR-003 用户查询 + - [x] REQ-USR-004 用户登录 diff --git a/docs/superpowers/module-reports/2026-05-07-module_usr.md b/docs/superpowers/module-reports/2026-05-07-module_usr.md new file mode 100644 index 0000000..bc4ae05 --- /dev/null +++ b/docs/superpowers/module-reports/2026-05-07-module_usr.md @@ -0,0 +1,146 @@ +--- +module_id: module_usr +date: 2026-05-07 +git_range: 237a97e..6e0c0e7 (27 commits) +--- + +# 模块完成报告 — module_usr 用户管理 + +## ① 模块信息 +- 模块 ID: module_usr +- 模块名: 用户管理(账户主数据 / 权限关联 / 列表查询 / 登录认证) +- 开发区间: 237a97e(master,含 module_mod merge)→ 6e0c0e7(test-gate evidence),共 27 个 commits + +## ② REQ 完成清单 + +- [x] REQ-USR-001 — 用户新增(`POST /api/users`) + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-001.md + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-001.md + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-001.md(round 2 approve) +- [x] REQ-USR-002 — 用户修改(`PUT /api/users/{id}`) + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-002.md + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-002.md + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-002.md(round 1 approve) +- [x] REQ-USR-003 — 用户查询(`GET /api/users`) + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-003.md + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-003.md + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-003.md(round 2 approve) +- [x] REQ-USR-004 — 用户登录(`POST /api/auth/login`) + - spec: docs/superpowers/specs/2026-05-06-REQ-USR-004.md + - plan: docs/superpowers/plans/2026-05-06-REQ-USR-004.md + - review: docs/superpowers/reviews/2026-05-06-REQ-USR-004.md(round 2 approve) + +## ③ 文件变更表 + +| 文件 | 操作 | 说明 | +|---|---|---| +| backend/pom.xml | M | 追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6(REQ-USR-004 JWT 实现) | +| 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 | +| backend/src/main/java/com/xly/erp/common/response/PageResult.java | A | 通用分页 VO(REQ-USR-003 引入) | +| backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java | A | 携带 cooldownSeconds 的账号锁定异常(REQ-USR-004) | +| backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | M | 追加 AccountLockedException 专用 handler 把 cooldownSeconds 写入 ApiResponse.data | +| backend/src/main/java/com/xly/erp/config/PasswordConfig.java | A | BCryptPasswordEncoder bean(REQ-USR-001 引入) | +| backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java | A | PaginationInnerInterceptor 注册(REQ-USR-003 引入) | +| 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)| +| backend/src/main/java/com/xly/erp/module/usr/mapper/{User,Staff,PermissionCategory,UserPermission}Mapper.java | A | BaseMapper 子接口;UserMapper 追加 searchUsers 自定义 SQL 方法 | +| backend/src/main/resources/mapper/usr/UserMapper.xml | A | 跨表 JOIN tStaff + 动态 WHERE + CAST 处理 bit(1) deleted(REQ-USR-003)| +| backend/src/main/java/com/xly/erp/module/usr/dto/{UserCreate,UserUpdate,UserQuery,Login}DTO.java | A | 4 个 DTO(POST/PUT/GET/Login) | +| backend/src/main/java/com/xly/erp/module/usr/vo/{User,UserListItem,LoginResult}VO.java | A | 3 个 VO(创建/修改返回 / 列表行 / 登录结果含嵌套 LoginUserInfo) | +| 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 | +| 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 | +| backend/src/main/java/com/xly/erp/module/usr/security/{LoginAttemptStore,InMemoryLoginAttemptStore,JwtTokenProvider}.java | A | JWT 签发 + 5 次失败 15 min 内存锁定(REQ-USR-004) | +| backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java | M | 追加 6 个错误码断言 | +| backend/src/test/java/com/xly/erp/module/usr/dto/{UserCreate,UserUpdate,UserQuery,Login}DTOValidationTest.java | A | 4 套 Bean Validation 单测(共 18 个用例)| +| backend/src/test/java/com/xly/erp/module/usr/mapper/{UsrMappersIT,UserMapperSearchIT}.java | A | 4 表 insert/select smoke + searchUsers SQL IT | +| 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)| +| 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) | +| backend/src/test/java/com/xly/erp/module/usr/security/{InMemoryLoginAttemptStore,JwtTokenProvider}Test.java | A | 7 单测(5 store + 2 jwt)| +| docs/08-模块任务管理.md | M | § 二 module_usr 4 个 REQ 全部勾选 | +| docs/superpowers/specs/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份功能规格 | +| docs/superpowers/plans/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份任务级实现计划 | +| docs/superpowers/reviews/2026-05-06-REQ-USR-00{1..4}.md | A | 4 份 AI 审阅报告(含 round 2 修复闭环) | +| docs/superpowers/module-reports/module_usr-test-gate.md | A | 本模块 test-gate 闸门证据 | + +## ④ 数据库使用表 + +- 读: `tUser`(4 个 REQ 都读)/ `tStaff`(USR-001 staff 校验 + USR-003 LEFT JOIN 列表 + USR-002 父校验)/ `tPermissionCategory`(USR-001 / USR-002 关联校验)/ `tUserPermission`(USR-002 重建关联前的 select) +- 写: `tUser`(USR-001 insert / USR-002 update / USR-004 update tLastLoginDate)/ `tUserPermission`(USR-001 insert 关联 / USR-002 重建关联 delete + insert) + +本模块**不写** `tStaff` 和 `tPermissionCategory`——这两张表当前是只读字典,spec 已注明若需增删改请新建独立模块。 + +## ⑤ 测试结果 + +- `scripts/test.sh` 最终:green +- 通过: 172 / 失败: 0 / 跳过: 0 +- 覆盖率: 未启用 JaCoCo(与 module_mod 一致) + +测试分布: +- 单元测试约 81 个:DTO Validation 18 + ApiResponseTest 7 + GlobalExceptionHandlerTest 4 + ModuleServiceImplTest 26 + ModuleCreateDTOValidationTest 5 + UserServiceImplTest 21 + LoginServiceImplTest 7 + 其他(store / jwt 等)8 +- 集成测试约 91 个:ApplicationTest 1 + SecurityConfigTest 1 + ModuleMapperIT 2 + ModuleControllerIT 28 + UsrMappersIT 4 + UserMapperSearchIT 2 + UserControllerIT 25 + LoginControllerIT 10 + 其他 + +`./scripts/test.sh` 流程:setup-test-db.sh DROP+CREATE → mvn build → mvn lint(compile) → mvn test → frontend skip → e2e 略 → reset DB。耗时 ~25s。 + +## ⑥ 本模块新增 Migration + +—(本模块未引入 schema 改动;tUser / tStaff / tPermissionCategory / tUserPermission 由 V1__initial_schema.sql 在 A4 阶段创建) + +## ⑦ 跨模块改动清单(软规则 S2) + +本模块改动了 `common/` 包下的横切组件,按 CLAUDE.md § 🟡 S2 登记: + +| 文件 | 改动 | 原因 | 影响评估 | +|---|---|---|---| +| 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 同步登记 | +| common/response/PageResult.java | 新建 | REQ-USR-003 引入通用分页 VO;下一模块需要分页时直接复用 | 横向组件;本模块单点引入,未来其他模块共享 | +| common/exception/AccountLockedException.java | 新建 | REQ-USR-004 携带 cooldownSeconds 的专用异常 | 仅 LoginService 抛;GlobalExceptionHandler 专用 handler 不影响其他 BizException 路径 | +| common/exception/GlobalExceptionHandler.java | 追加 `@ExceptionHandler(AccountLockedException.class)` | 把 cooldownSeconds 写入 ApiResponse.data | 既有 BizException / Validation / Exception handler 路径 0 改动;前端契约 100% 兼容 | +| config/PasswordConfig.java | 新建 | REQ-USR-001 引入 BCryptPasswordEncoder bean | 横向组件,REQ-USR-004 复用;无副作用 | +| config/MybatisPlusConfig.java | 新建 | REQ-USR-003 引入 PaginationInnerInterceptor | 影响所有 MP `Page` 调用路径,但 module_mod 现有 mapper 未用 Page,无回归 | + +> **未自动生成 cross-module-log 存根**:log-cross-module.sh hook 未触发(可能因为 common/ 不在 hook 监控的"其他业务模块"路径中);本节内容由 module-report 直接登记。 + +## ⑧ 偏离 spec 清单 + +- **REQ-USR-001**:spec/plan 早期草稿要求 `tUserPermission.bSelected=1` 字段,与 docs/03 修订版(已删该列)不一致。fix commit 520c01f 把 spec/plan 中 bSelected 提及改为"无该列"注解;UserPermissionEntity 不含此字段。 +- **REQ-USR-002**:iStaffId 加 `FieldStrategy.IGNORED` 让 NULL 写入生效——同 module_mod ModuleEntity.iParentId 的全局副作用;本期所有 update 路径走 load-then-modify 安全,但**未来 partial update 路径必须 selectById 后再 updateById**,否则 iStaffId 会被静默清空。 +- **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)。 +- **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。 +- **REQ-USR-004**:InMemoryLoginAttemptStore round 1 锁定到期不重置 count(business rule #4 不达成),fix commit d439c0d 修复 cooldownSeconds + recordFailure 双路径处理过期 reset。 +- **REQ-USR-004 (范围说明已声明)**:本期**仅签发 JWT**,不切换其他端点为 authenticated;module_mod 4 端点 + module_usr 3 端点(POST/PUT/GET)仍 permitAll。这是已知技术债(spec § 范围说明 + § ⑩ 已知问题登记)。 +- **REQ-USR-004**:spec § 业务规则 7 客户端 IP 审计未实施(log.info 没拿 HttpServletRequest.getRemoteAddr());登记为已知 gap。 +- **跨 REQ — 多租户字段 / sCreatedBy 留 NULL**:所有写入路径中 `sBrandsId / sSubsidiaryId / sCreatedBy` 都落 NULL;同 module_mod 一致,等 REQ-USR-XXX 引入登录上下文 / 多租户拦截器后回填。 + +## ⑨ AI reviewer 报告汇总 + +- 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) +- REQ-USR-002: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-USR-002.md) +- 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) +- 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) + +## ⑩ 已知问题 + +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。 +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),独立工时排期。 +3. **多租户字段 / sCreatedBy NULL**:与 module_mod 一致;REQ-USR-XXX 引入登录上下文后回填 + 必要时 V_n migration 给历史数据补默认值 + 视业务决策收紧 schema 为 NOT NULL。 +4. **iStaffId IGNORED 全局副作用**:UserEntity.iStaffId 与 ModuleEntity.iParentId 同类风险——任何 partial updateById 路径都会静默清空该列。本期所有路径走 load-then-modify 安全;未来贡献者新增 partial path 必须复用 LambdaUpdateWrapper.set(...) 模式。 +5. **InMemoryLoginAttemptStore 单机限制**:5 次失败锁定在多实例部署下不工作;spec 已声明 Redis 替换留作后续 REQ。`expireLockForTest` 包私有调试入口暴露在生产 jar 中——下一 sweep 移到 src/test 的 testutil。 +6. **客户端 IP 审计未实施**:spec § 业务规则 7 要求 log.info 含客户端 IP;当前 LoginService 只记 sUserName。建议后续在 LoginController 注入 HttpServletRequest.getRemoteAddr() 传给 service。 +7. **Redis 凭据 / 部署**:docs/04 § 零 列了 Redis;本仓库 .env.local 未配 Redis;REQ-USR-004 内存锁定 + 后续可能的会话 / 缓存特性都待 Redis 接入后才能演进。 +8. **REQ-USR-002 spec 验收 #11 IT 回滚证据**:受 IT @Transactional+@Rollback 包裹,service 层回滚无法在 IT 中观测;service 单测已覆盖语义,IT 留 nice-to-have。 +9. **REQ-USR-003 nice-to-have 6 条 IT 缺失**:department equals / deleted=false 显式 / notContains / 排序 / matchType 非枚举 / 空结果 IT;service 单测已覆盖核心。 +10. **docs/05 错误码段位与实际实现偏差**:docs/05 § REQ-USR-001 写 40020 段位;实现统一用 40010 PARAM_INVALID。docs sweep 时对齐。 +11. **JacksonConfig 字段访问可见性配置全局生效**:影响所有 DTO/VO 的 JSON 序列化;当前所有字段都用 @Data 暴露,没有 @JsonIgnore 跳过敏感字段的需求;sPasswordHash 不暴露是因为 UserVO / LoginResultVO.LoginUserInfo / UserListItemVO 都不含该字段而非 @JsonIgnore。后续若有需要隐藏字段的 VO,需重新评估全局策略。 + +## ⑪ 下一模块预览 + +按 docs/02 § 二 顺序,**module_usr 是最后一个模块**——module_mod 已 merge,本 module_usr 是项目计划中的第二个也是最终模块。MR merge 后即视为「全部 REQ 完成」。 + +后续工作(不属于 docs/02 计划清单的 REQ): +- **鉴权清算 sweep**:切 SecurityFilterChain 到 authenticated + JwtAuthenticationFilter + 所有 controller @PreAuthorize + 既有 IT 携带 token 改造(见 § ⑩ #2)。 +- **JWT_SECRET history 清理**:评估是否需要 BFG / git-filter-repo 重写 history(见 § ⑩ #1)。 +- **多租户上下文 + Redis 引入**:补完 sBrandsId / sSubsidiaryId / sCreatedBy 写入;用 Redis 替换 InMemoryLoginAttemptStore(见 § ⑩ #3, #5, #7)。 +- **前端**:本期完全无 frontend 实现;docs/06 § 五 已规划用户管理页面 + 登录页,待前端工程接入后实现。 + +## ⑫ MR 链接 + +http://git.xlyprint.cn/zhuzc/test2/merge_requests/2 (!2) diff --git a/docs/superpowers/module-reports/module_usr-test-gate.md b/docs/superpowers/module-reports/module_usr-test-gate.md new file mode 100644 index 0000000..64efc01 --- /dev/null +++ b/docs/superpowers/module-reports/module_usr-test-gate.md @@ -0,0 +1,34 @@ +## Local test gate — module_usr + +执行时间: 2026-05-07T09:33:12+08:00 + +### scripts/test.sh (subagent) +- 子会话: a04f12f4a6f932a7d +- 命令: `./scripts/test.sh`(setup-test-db.sh DROP+CREATE → mvn build/lint/test → frontend skip → e2e 略 → setup-test-db.sh reset) +- 退出码: 0 +- 通过: 172 / 失败: 0 +- 关键 stdout (≤30 行): + +``` +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 172, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 25.464 s +[INFO] Finished at: 2026-05-07T09:33:12+08:00 +[INFO] ------------------------------------------------------------------------ +[test.sh] skip frontend test +[test.sh] 5/6 E2E +[test.sh] e2e 略 +[test.sh] 6/6 reset test db +[setup-test-db] 即将 DROP + CREATE `xlyweberp_vibe_erp_test` on 118.178.19.35:3318 +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts +[test.sh] GREEN +``` + +结论: green diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-001.md b/docs/superpowers/plans/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..52d1251 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-001.md @@ -0,0 +1,236 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-001.md +--- + +# REQ-USR-001 用户新增 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。 + +**Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。 + +**Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。 + +--- + +## Schema 改动 + +无(V1 已建 5 张表 + FK + UNIQUE 索引)。 + +## 文件变更清单 + +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量 +- 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` +- 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试 + +--- + +## 任务步骤 + +### Task 1: 错误码 + PasswordConfig + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` +- Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` + +**API shape:** +- 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同): + - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")` + - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")` + - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")` + + > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。 +- `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }` + +- [ ] **Step 1.1 写失败断言** + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行: + ``` + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); + ``` + - 子会话: FAIL + +- [ ] **Step 1.2 实现 ErrorCode + PasswordConfig** + - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证) + +- [ ] **Step 1.3 提交** + - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"` + +--- + +### Task 2: 4 张表 entity + mapper + Mapper smoke IT + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` + +**API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式): + +| Entity | 表 | 关键字段(不含 5 个标准列) | +|---|---|---| +| `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | +| `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | +| `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | +| `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) | + +每个 mapper `extends BaseMapper`,无自定义 SQL。 + +> **注意**: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(...)` 处理。 + +- [ ] **Step 2.1 写失败 IT** + - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返) + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` + - 子会话: FAIL(entity / mapper 不存在) + +- [ ] **Step 2.2 实现 4 entity + 4 mapper** + - 子会话: PASS + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"` + +--- + +### Task 3: UserCreateDTO + UserVO + Validation + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` + +**API shape:** + +`UserCreateDTO`: +- `@NotBlank @Size(max=50) String sUserNo` +- `@NotBlank @Size(max=50) String sUserName` +- `Integer iStaffId`(可空) +- `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` +- `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` +- `Boolean bCanModifyDocs`(可空,service 层 default false) +- `List permissionCategoryIds`(可空 / 空数组) + +`UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List permissionCategoryIds)`。 + +- [ ] **Step 3.1 写失败测试(5 个)** + - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations` + - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank) + - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` + - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation` + - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations` + - 子会话: FAIL + +- [ ] **Step 3.2 实现 DTO + VO** + - 子会话: PASS + +- [ ] **Step 3.3 提交** + - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"` + +--- + +### Task 4: UserService.create + Mockito 单元测试 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** +- `interface UserService { UserVO create(UserCreateDTO dto); }` +- `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。 +- `create(dto)` 步骤(plan 锁定): + 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。 + 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。 + 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。 + 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。 + 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。 + 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。 + 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。 +- 标 `@Transactional(rollbackFor = Exception.class)` + +**初始密码常量**(写在 plan 锁定): +``` +private static final String INITIAL_PASSWORD = "666666"; +``` +后续若策略变化,service 单点修改。 + +- [ ] **Step 4.1 写失败测试(9 个)** + - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值 + - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段 + - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0 + - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0 + - `create_staffNotFound_throws40421`:staffMapper.selectById → null + - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true + - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短 + - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调 + - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` / `ArgumentCaptor` + - 子会话: FAIL + +- [ ] **Step 4.2 实现 UserService + Impl** + - 子会话: PASS + +- [ ] **Step 4.3 提交** + - `git commit -m "feat(usr): create user service REQ-USR-001"` + +--- + +### Task 5: UserController + 端到端 IT + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` + +**API shape:** +- `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController` +- `@PostMapping ApiResponse create(@Valid @RequestBody UserCreateDTO dto)` +- Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")` + +- [ ] **Step 5.1 写失败测试(7 个)** + - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[] + - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配 + - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName + - `post_staffNotFound_returns40421`:iStaffId=999999 + - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999] + - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666" + - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper` + - 子会话: FAIL(端点不存在) + +- [ ] **Step 5.2 实现 UserController** + - 子会话: PASS + +- [ ] **Step 5.3 跑全量 backend 测试** + - `cd backend && mvn -B test` + - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。 + +- [ ] **Step 5.4 提交** + - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"` + +--- + +## 提交计划 + +- `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1) +- `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2) +- `feat(usr): user create DTO and VO REQ-USR-001`(Task 3) +- `feat(usr): create user service REQ-USR-001`(Task 4) +- `feat(usr): POST /api/users controller REQ-USR-001`(Task 5) diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-002.md b/docs/superpowers/plans/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..a65e5e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-002.md @@ -0,0 +1,178 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-002.md +--- + +# REQ-USR-002 用户修改 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 实现 `PUT /api/users/{id}`:除 sUserNo / sUserName / sPasswordHash 外的字段全量替换 + 权限组重建(先删后插),返回 UserVO。 + +**Architecture:** 复用 REQ-USR-001 的 entity/mapper/service/exception/Jackson 体系。Service load-then-modify:`selectById` → 校验 + 字段合并 → `updateById`(user)→ `delete`(关联) + 循环 `insert`(关联)。`iStaffId` 字段加 `FieldStrategy.IGNORED` 让 NULL 写入生效。 + +**Tech Stack:** 沿用前序 REQ。 + +--- + +## Schema 改动 + +无。 + +## 文件变更清单 + +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `USR_NOT_FOUND(40431, "用户不存在或已删除")` +- 修改: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` — `iStaffId` 加 `updateStrategy = FieldStrategy.IGNORED` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `update(Integer id, UserUpdateDTO dto): UserVO` +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@PutMapping("/{id}")` +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 9 个 update 单测 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 8 个 PUT 集成测试 + +--- + +## 任务步骤 + +### Task 1: 错误码追加 + UserEntity.iStaffId IGNORED + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` +- Modify: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` + +**API shape:** +- `USR_NOT_FOUND(40431, "用户不存在或已删除")` 追加到 ErrorCode 枚举 +- `UserEntity#iStaffId` 字段注解改为 `@TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)`,并加注释说明(与 REQ-MOD-002 / module_mod 同样的副作用警告) + +- [ ] **Step 1.1 写失败断言** + - `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 追加 `assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);` + - 子会话: FAIL + +- [ ] **Step 1.2 实现错误码 + Entity 注解** + - 子会话验证:`mvn -B test`(全量;让 SpringBootTest 预热 lambda cache)应仍 PASS(USR-001 现有用例 + 新断言) + +- [ ] **Step 1.3 提交** + - `git commit -m "feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002"` + +--- + +### Task 2: UserUpdateDTO + 校验单测 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` + +**API shape:** +- 字段(与 UserCreateDTO 相比剥除 `sUserNo` / `sUserName`): + - `Integer iStaffId`(可空) + - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` + - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` + - `Boolean bCanModifyDocs`(可空,service 层语义:null 保留原值) + - `List permissionCategoryIds`(可空,空数组 / null 都视为清空) + +- [ ] **Step 2.1 写失败测试(4 个)** + - `UserUpdateDTOValidationTest#allValidFields_yieldsNoViolations` + - `UserUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(2 个 @NotBlank) + - `UserUpdateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` + - `UserUpdateDTOValidationTest#invalidLanguageEnum_yieldsViolation` + - 子会话: FAIL + +- [ ] **Step 2.2 实现 DTO** + - 子会话: PASS + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(usr): user update DTO REQ-USR-002"` + +--- + +### Task 3: UserService.update + Mockito 单测 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`(追加方法签名) +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`(追加 9 个测试) + +**API shape:** +- `UserService.update(Integer id, UserUpdateDTO dto): UserVO` +- 实现步骤(plan 锁定): + 1. `target = userMapper.selectById(id)`;`null` 或 `bDeleted=true` → `BizException(USR_NOT_FOUND)` + 2. iStaffId 校验(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` null / bDeleted → `BizException(STAFF_NOT_FOUND)` + 3. 权限分类校验(仅当 dto.permissionCategoryIds 非空):`selectBatchIds` 长度 / bDeleted 检查 → `BizException(PERM_CATEGORY_NOT_FOUND)` + 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy / tLastLoginDate / 多租户 / sId / bDeleted 三件套): + - `target.setIStaffId(dto.getIStaffId())`(含 null 清空) + - `target.setSUserType(dto.getSUserType())` + - `target.setSLanguage(dto.getSLanguage())` + - `if (dto.getBCanModifyDocs() != null) target.setBCanModifyDocs(...)`(部分更新) + 5. `userMapper.updateById(target)` + 6. 重建权限关联: + - `userPermissionMapper.delete(LambdaQueryWrapper.eq(iUserId, id))` 清空所有 + - 若 dto.permissionCategoryIds 非空 → 循环 insert(每条 iUserId / iCategoryId / tCreateDate=now) + 7. 返回 `UserVO.from(target, dto.permissionCategoryIds 或 [])` +- 标 `@Transactional(rollbackFor = Exception.class)` + +- [ ] **Step 3.1 写失败测试(9 个)** + - `update_targetNotFound_throws40431` + - `update_targetSoftDeleted_throws40431` + - `update_staffNotFound_throws40421` + - `update_staffSoftDeleted_throws40421` + - `update_permissionCategoryNotFound_throws40422` + - `update_full_returnsVOWithUpdatedFields_andRebuildsPermissions`:mock target;ArgumentCaptor 验 + - user 已修改字段:iStaffId / sUserType / sLanguage / bCanModifyDocs + - user 保留字段:sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy + - userPermissionMapper.delete 调一次(wrapper 含 eq iUserId);insert 调 N 次(按 dto 的 ids) + - `update_partialNullBCanModifyDocs_keepsOriginal` + - `update_clearStaffId_setsToNull`:dto.iStaffId=null,断言 captor entity.iStaffId == null + - `update_emptyPermissionCategoryIds_clearsAllAssociations`:dto.permissionCategoryIds=[],verify userPermissionMapper.delete 被调一次 + insert never + - 子会话: FAIL + +- [ ] **Step 3.2 实现 service.update** + - 子会话: PASS(含原 9 个 USR-001 单测 + 9 个 USR-002 单测共 18 个) + +- [ ] **Step 3.3 提交** + - `git commit -m "feat(usr): update user service REQ-USR-002"` + +--- + +### Task 4: UserController PUT + 端到端 IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`(追加 8 个 PUT IT) + +**API shape:** +- `@PutMapping("/{id}") ApiResponse update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto)` +- Javadoc:`REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")` + +- [ ] **Step 4.1 写失败测试(8 个)** + - `put_validUpdate_returns200_andDbReflects`:先 mapper.insert 一个 user + staff + 3 个 cat + 3 个 userPermission;PUT 改 staff(另一个) + sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新 cat);断言 200 + reload 验证字段 + 关联表替换为 2 条 + - `put_clearStaffId_setsNull`:原 user.iStaffId=staffId,PUT 时 iStaffId=null,断言 DB user.iStaffId IS NULL + - `put_emptyPermissionCategoryIds_clearsAssociations`:原有 3 条关联,PUT 传 [],断言 DB tUserPermission count = 0 + - `put_targetNotFound_returns40431` + - `put_staffNotFound_returns40421` + - `put_permissionCategoryNotFound_returns40422` + - `put_missingRequired_returns40010`:缺 sUserType + - `put_ignoresProtectedFields_doesNotChangeUserNoOrName`:手工拼 body 含 sUserNo / sUserName / sPasswordHash 字段,PUT 后 reload;断言 sUserNo / sUserName / sPasswordHash 与原值相同 + - 子会话: FAIL + +- [ ] **Step 4.2 实现 PUT 端点** + - 子会话: PASS + +- [ ] **Step 4.3 跑全量 backend 测试** + - `cd backend && mvn -B test` + - 期望 101 + 1(新错误码)+ 4(DTO valid)+ 9(service unit)+ 8(controller IT)= 123 测试,全绿 + +- [ ] **Step 4.4 提交** + - `git commit -m "feat(usr): PUT /api/users/{id} controller REQ-USR-002"` + +--- + +## 提交计划 + +- `feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002`(Task 1) +- `feat(usr): user update DTO REQ-USR-002`(Task 2) +- `feat(usr): update user service REQ-USR-002`(Task 3) +- `feat(usr): PUT /api/users/{id} controller REQ-USR-002`(Task 4) diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-003.md b/docs/superpowers/plans/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..32597e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-003.md @@ -0,0 +1,230 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-003.md +--- + +# REQ-USR-003 用户查询 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 实现 `GET /api/users` 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult。 + +**Architecture:** 引入 MP `PaginationInnerInterceptor` + 通用 `PageResult`。Mapper.xml 自定义 `searchUsers` SQL(LEFT JOIN tStaff + dynamic WHERE)。Service 层做 queryField 白名单校验防 SQL 注入,把白名单 column 字符串放入 query 对象传给 mapper。 + +**Tech Stack:** 沿用前序 REQ;首次启用 MP 分页插件 + XML mapper。 + +--- + +## Schema 改动 + +无。 + +## 文件变更清单 + +- 创建: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` — 通用分页 VO +- 创建: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — `PaginationInnerInterceptor` bean +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java` +- 修改: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `IPage searchUsers(IPage page, @Param("query") UserQueryDTO query)` 方法签名 +- 创建: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `search(UserQueryDTO query): PageResult` +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 search + 白名单 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@GetMapping` +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java` +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 search 单测 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 GET IT + +--- + +## 任务步骤 + +### Task 1: PageResult + MybatisPlusConfig(横切骨架) + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` +- Create: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` + +**API shape:** +- `PageResult`:字段 `long total` + `List list` + `long pageNum` + `long pageSize`;@Data + 静态工厂 `of(IPage mpPage)`(从 MP IPage 构造) +- `MybatisPlusConfig`:`@Bean MybatisPlusInterceptor mybatisPlusInterceptor()` 注册 `PaginationInnerInterceptor(DbType.MYSQL)` + +- [ ] **Step 1.1 实现两个文件(无独立单测,由 Task 4 的 Mapper IT 验证分页)** + +- [ ] **Step 1.2 子会话 mvn 全量测试**(验证 SpringBoot context 启动 + 122 现有测试不回归) + +- [ ] **Step 1.3 提交** + - `git commit -m "feat(common): PageResult + MP pagination config REQ-USR-003"` + +--- + +### Task 2: UserQueryDTO + UserListItemVO + Validation + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java` + +**API shape:** + +`UserQueryDTO`: +- `@Min(1) Integer pageNum = 1`(默认) +- `@Min(1) @Max(100) Integer pageSize = 20` +- `@Pattern(regexp="^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$") String queryField`(可空) +- `@Pattern(regexp="^(contains|notContains|equals)?$") String matchType`(可空) +- `@Size(max=100) String queryValue`(可空) + +`UserListItemVO`:11 字段(spec § 输出)。Lombok `@Data`,无静态工厂(mapper 直接通过 ResultMap / autoMap 映射)。 + +- [ ] **Step 2.1 写失败测试(5 个)** + - `UserQueryDTOValidationTest#allValid_yieldsNoViolations`(含 default 值) + - `UserQueryDTOValidationTest#pageSizeTooLarge_yieldsViolation`(>100) + - `UserQueryDTOValidationTest#pageSizeTooSmall_yieldsViolation`(<1) + - `UserQueryDTOValidationTest#queryFieldInvalidEnum_yieldsViolation` + - `UserQueryDTOValidationTest#queryValueOverSized_yieldsViolation`(101 字符) + - 子会话: FAIL + +- [ ] **Step 2.2 实现 DTO + VO** + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(usr): user query DTO + list item VO REQ-USR-003"` + +--- + +### Task 3: UserMapper.xml searchUsers + Mapper smoke IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`(追加 `IPage searchUsers(...)` 方法签名) +- Create: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL(spec § 实现路径选择 已锁定 SQL 模板) +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java`(新文件,独立 IT) + +**XML SQL 锁定**(spec 已写): + +```xml + +``` + +> `query.column` 字段是 service 层白名单映射后的 SQL 列字符串(如 `"u.sUserName"`),由 `${...}` 渲染——**绝不**接受 DTO 原 queryField 直接拼。 + +为支持 `${query.column}`,需要在 `UserQueryDTO` 加一个 transient 字段 `String column`(service 写入;前端不接受)。 + +- [ ] **Step 3.1 写失败 IT** + - `UserMapperSearchIT#searchUsers_emptyFilter_returnsAllUndeletedAsPage`:插入 2 个 user(含 1 个 staff 关联),`@Autowired UserMapper`,调用 `userMapper.searchUsers(new Page<>(1,10), query)`;断言 page.getTotal() ≥ 2、page.getRecords() 含 sUserName + sStaffName 字段 + - `UserMapperSearchIT#searchUsers_filterByUserName_filtersCorrectly`:插入 alice / bob;query.queryField=username, column="u.sUserName", matchType=contains, queryValue="ali";断言只返回 alice + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper` + - 子会话: FAIL(searchUsers 方法未定义) + +- [ ] **Step 3.2 实现 mapper 方法签名 + XML + UserQueryDTO 加 column 字段** + - 子会话: PASS + +- [ ] **Step 3.3 提交** + - `git commit -m "feat(usr): UserMapper.xml searchUsers REQ-USR-003"` + +--- + +### Task 4: UserService.search + Mockito 单测 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** +- `UserService.search(UserQueryDTO query): PageResult` +- 实现步骤(plan 锁定): + 1. 白名单校验 + 列映射: + ``` + Map COLUMN_MAP = Map.of( + "username", "u.sUserName", "staffname", "s.sStaffName", + "userno", "u.sUserNo", "department", "s.sDepartment", + "usertype", "u.sUserType", "language", "u.sLanguage", + "deleted", "u.bDeleted", "lastLoginDate", "u.tLastLoginDate", + "createdBy", "u.sCreatedBy"); + ``` + 若 `query.queryField` 非空但不在 map → `BizException(PARAM_INVALID, "queryField 非法")`; + 若 `query.matchType` 非空但不在 {contains, notContains, equals} → 同样错误。 + 2. 把映射后的列字符串写到 `query.setColumn(mappedCol)`;如果 queryField 为空,column 也为空。 + 3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。 + 4. 构造 `Page page = new Page<>(query.getPageNum(), query.getPageSize())` + 5. 调 `userMapper.searchUsers(page, query)` + 6. 返回 `PageResult.of(result)` +- 标 `@Transactional(readOnly = true)` + +- [ ] **Step 4.1 写失败测试(5 个)** + - `search_emptyDb_returnsEmptyPage`:mock searchUsers 返回 empty Page;service 返回 PageResult total=0 + - `search_invalidQueryField_throws40010`:query.queryField="invalid" + - `search_invalidMatchType_throws40010`:query.matchType="like" + - `search_passesMappedColumnToMapper`:query.queryField="username";ArgumentCaptor 捕 query 实参,断言 query.column == "u.sUserName" + - `search_appliesDefaultPagination_whenNullPageNumOrSize`:query.pageNum=null, pageSize=null;断言 service 创建的 Page.size==20 && current==1 + - 子会话: FAIL + +- [ ] **Step 4.2 实现 service.search** + - 子会话: PASS + +- [ ] **Step 4.3 提交** + - `git commit -m "feat(usr): user query service REQ-USR-003"` + +--- + +### Task 5: UserController GET + 端到端 IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` + +**API shape:** +- `@GetMapping ApiResponse> search(@Valid UserQueryDTO query)` +- Javadoc:`REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")` + +- [ ] **Step 5.1 写失败测试(9 个)** + - `get_emptyKeyword_returnsAllUndeleted` + - `get_filterByUsernameContains_returnsMatchedSubset` + - `get_filterByStaffnameContains_returnsJoinedResults` + - `get_filterByDeletedTrue_returnsOnlyDeleted` + - `get_pagination_returnsCorrectSlice` + - `get_responseExcludesInternalFields`:断言 jsonPath `$.data.list[0].sPasswordHash` doesNotExist + sId / iStaffId / sBrandsId 都不出现 + - `get_pageSizeTooLarge_returns40010` + - `get_invalidQueryField_returns40010` + - `get_userWithoutStaff_listItemHasNullStaffFields` + - `@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + insert helpers + - 子会话: FAIL + +- [ ] **Step 5.2 实现 GET 端点** + - 子会话: PASS + +- [ ] **Step 5.3 子会话跑全量 mvn test** + - 期望:122 + 5(query DTO valid) + 5(service search unit) + 2(mapper IT) + 9(controller IT) = 143 测试,全绿 + +- [ ] **Step 5.4 提交** + - `git commit -m "feat(usr): GET /api/users controller REQ-USR-003"` + +--- + +## 提交计划 + +- `feat(common): PageResult + MP pagination config REQ-USR-003`(Task 1) +- `feat(usr): user query DTO + list item VO REQ-USR-003`(Task 2) +- `feat(usr): UserMapper.xml searchUsers REQ-USR-003`(Task 3) +- `feat(usr): user query service REQ-USR-003`(Task 4) +- `feat(usr): GET /api/users controller REQ-USR-003`(Task 5) diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-004.md b/docs/superpowers/plans/2026-05-06-REQ-USR-004.md new file mode 100644 index 0000000..3108429 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-004.md @@ -0,0 +1,247 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-004.md +--- + +# REQ-USR-004 用户登录 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 实现 `POST /api/auth/login`:BCrypt 校验密码 → 5 次失败锁定(内存)→ 签发 HS256 JWT → 回写 tLastLoginDate。 + +**Architecture:** 引入 jjwt 0.12.x 签发 JWT;`LoginAttemptStore` 接口 + `InMemoryLoginAttemptStore` 实现(spec 注:Redis 替换为后续 REQ);`JwtTokenProvider` 封装 sign/parse;`LoginService` 协调 4 步逻辑;`SecurityConfig` 白名单 `/api/auth/login`。本 REQ 不切换其他端点为 authenticated(技术债登记)。 + +**Tech Stack:** 沿用 + 新增 jjwt-api/impl/jackson 0.12.x。 + +--- + +## Schema 改动 + +无。 + +## 文件变更清单 + +- 修改: `backend/pom.xml` — 追加 jjwt 三件套 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 LOGIN_INVALID_CREDENTIALS(40101) / LOGIN_ACCOUNT_LOCKED(40301) +- 修改: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 白名单 `/api/auth/login` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`(含嵌套 LoginUserInfo) +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` — sign/parse 封装 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 2 个错误码断言 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` +- 创建: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` + +--- + +## 任务步骤 + +### Task 1: pom.xml + ErrorCode + DTO/VO + DTO Validation + +**Files:** +- Modify: `backend/pom.xml`(追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6 三个 dependency) +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` + +**API shape:** +- `LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误")` +- `LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定")` +- `LoginDTO`:`@NotBlank @Size(max=50) String sUserName` / `@NotBlank @Size(max=100) String sPassword` / `@NotBlank @Pattern(regexp="^standard$") String sVersion` +- `LoginResultVO`:`String accessToken` / `long expiresIn` / `LoginUserInfo user`(内嵌静态类 5 字段:iIncrement / sUserNo / sUserName / sUserType / sLanguage) + +- [ ] **Step 1.1 写失败测试** + - ApiResponseTest 追加:`assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);` + LOGIN_ACCOUNT_LOCKED 40301。 + - LoginDTOValidationTest 4 个用例:allValid / blankRequired / invalidVersion / overSized。 + - 子会话: FAIL(class 不存在) + +- [ ] **Step 1.2 实现** + - 在 pom.xml `` 追加: + ``` + io.jsonwebtoken:jjwt-api:0.12.6 + io.jsonwebtoken:jjwt-impl:0.12.6 (runtime) + io.jsonwebtoken:jjwt-jackson:0.12.6 (runtime) + ``` + - ErrorCode + DTO + VO + Validation + - 子会话: PASS + +- [ ] **Step 1.3 提交** + - `git commit -m "feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004"` + +--- + +### Task 2: JwtTokenProvider + 单元测试 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` + +**API shape:** +- `JwtTokenProvider` Spring `@Component`,构造注入 `@Value("${erp.jwt.secret}") String secret` + `@Value("${erp.jwt.expires-in-seconds}") long expiresIn`。 +- `String sign(int uid, String username, String userType)`:HS256,claims `sub=username` / `uid` / `type=userType` / `iat` / `exp = iat + expiresIn`。 +- `Claims parse(String token)`:验证签名 + 过期,抛 `JwtException` / `ExpiredJwtException`。 + +> 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`(启动期暴露)。 + +- [ ] **Step 2.1 写失败测试** + - `JwtTokenProviderTest#signAndParse_returnsClaims`:注入 fake secret + expiresIn=7200,sign(uid=1, username=alice, type=普通用户) → parse → 断言 sub=alice / uid=1 / type=普通用户 / exp-iat=7200。 + - `JwtTokenProviderTest#parseExpiredToken_throwsExpiredJwtException`:mock Clock 或手工构造已过期 token(用 jjwt builder 设 exp=now-1)。 + - 测试方式:直接 `new JwtTokenProvider(secret, expiresIn)` 实例化,避免 SpringBootTest 开销。 + - 子会话: FAIL + +- [ ] **Step 2.2 实现 JwtTokenProvider** + - 子会话: PASS + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(usr): JwtTokenProvider sign/parse REQ-USR-004"` + +--- + +### Task 3: LoginAttemptStore 接口 + InMemory 实现 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java` + +**API shape:** +``` +interface LoginAttemptStore { + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ + long cooldownSeconds(String username); + + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ + void recordFailure(String username); + + /** 登录成功清空记录 */ + void clear(String username); +} + +@Component +class InMemoryLoginAttemptStore implements LoginAttemptStore { + private static final int LOCK_THRESHOLD = 5; + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); + private final Map store = new ConcurrentHashMap<>(); + // FailRecord{int count; Instant lockUntil}; 同步 access via map.compute +} +``` + +- [ ] **Step 3.1 写失败测试(4 个)** + - `cooldown_initial_returnsZero` + - `recordFailure_under5_doesNotLock` + - `recordFailure_at5_triggersLock_cooldownPositive` + - `clear_resetsCount` + - (不直接测"15min 后解锁"——time-based 测试用 Duration.ZERO mock 不实际可行;spec § 6 验收"锁定到期后可登录"由 LoginServiceImplTest 模拟 lockUntil 至过去验证) + - 子会话: FAIL + +- [ ] **Step 3.2 实现接口 + InMemory** + - 子会话: PASS + +- [ ] **Step 3.3 提交** + - `git commit -m "feat(usr): in-memory login attempt store REQ-USR-004"` + +--- + +### Task 4: LoginService + Mockito 单元测试 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` + +**API shape:** +- `LoginService.login(LoginDTO dto): LoginResultVO` +- 实现步骤(plan 锁定): + 1. 检查锁定:`long cd = attemptStore.cooldownSeconds(dto.sUserName)`。`cd > 0` → 抛 `BizException(LOGIN_ACCOUNT_LOCKED, message + cooldownSeconds)`;message 体内含 cooldownSeconds,由 controller 层把 BizException 转换时把 cd 放到 data.cooldownSeconds。 + - **简化方案**:直接抛 `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`,故选改进方案。 + 2. 查用户:`userMapper.selectOne(eq(sUserName).eq(bDeleted, false))`。null → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 + 3. BCrypt 校验:`passwordEncoder.matches(dto.sPassword, user.sPasswordHash)`。false → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 + 4. 成功:`attemptStore.clear(sUserName)`;签发 token;`userMapper.update(null, LambdaUpdateWrapper.eq(iIncrement,user.iIncrement).set(tLastLoginDate, now))`;构造 LoginResultVO 返回。 +- 标 `@Transactional(rollbackFor = Exception.class)`(成功路径有 update;失败也安全)。 + +**新建** `AccountLockedException`(在 module_usr/security 或 common/exception,本 plan 选 common/exception): +``` +class AccountLockedException extends BizException { + private final long cooldownSeconds; + public AccountLockedException(long cd) { + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); + this.cooldownSeconds = cd; + } +} +``` + +GlobalExceptionHandler 追加 `@ExceptionHandler(AccountLockedException.class)` 把 cooldownSeconds 包进 `ApiResponse.data`,为此需要 `Map` data 形式:`Map.of("cooldownSeconds", e.getCooldownSeconds())`。 + +- [ ] **Step 4.1 写失败测试(7 个)** + - `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)。 + - `login_userNotFound_returns40101_recordsFailure` + - `login_userSoftDeleted_returns40101`:selectOne 已带 bDeleted=0 过滤,覆盖该路径等价于 userNotFound(返回 null);mock selectOne null 即可。 + - `login_passwordMismatch_returns40101_recordsFailure` + - `login_accountLocked_throwsAccountLockedException_withCooldown` + - `login_5thFailureTriggersLock`(mock attemptStore 检验 recordFailure 调用次数) + - `login_successUpdatesTLastLoginDate`:ArgumentCaptor 捕 LambdaUpdateWrapper(或 Wrapper),断言 sql set 含 tLastLoginDate。 + - 子会话: FAIL + +- [ ] **Step 4.2 实现 LoginService + Impl + AccountLockedException + GlobalExceptionHandler 扩展** + - 子会话: PASS + +- [ ] **Step 4.3 提交** + - `git commit -m "feat(usr): login service + account locked handling REQ-USR-004"` + +--- + +### Task 5: LoginController + SecurityConfig 白名单 + 端到端 IT + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` +- Modify: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`(白名单 /api/auth/login) +- Modify: `backend/src/main/resources/application.yml`(确认 `erp.jwt.secret` / `erp.jwt.expires-in-seconds` 已存在;REQ-USR-001 引入时已设置) +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` + +**API shape:** +- `@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor LoginController` +- `@PostMapping("/login") ApiResponse login(@Valid @RequestBody LoginDTO dto)` +- Javadoc: `REQ-USR-004 用户登录 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成` +- SecurityConfig 修改:在 `authorizeHttpRequests` 配置上下文中显式 `requestMatchers("/api/auth/login").permitAll()`,其他保持 permitAll(REQ-USR-004 不强制收紧,留作技术债登记) + +- [ ] **Step 5.1 写失败测试(9 个)** + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback`,`@Autowired UserMapper / PasswordEncoder / InMemoryLoginAttemptStore`,`@BeforeEach` 调用 `attemptStore.clear(sUserName)`(避免跨测试串扰,因为 store 是单例 ConcurrentHashMap)。 + - `login_validCredentials_returns200WithToken`:先 mapper.insert 一个 user(密码 BCrypt 哈希 666666)→ POST 登录 → 断言 200 / token 非空 / expiresIn=7200 / user 嵌套。 + - `login_jwtClaimsAreCorrect`:上一条 token 用 JwtTokenProvider 解析(@Autowired),断言 sub / uid / type 与 DB 一致。 + - `login_invalidUsername_returns40101` + - `login_wrongPassword_returns40101` + - `login_softDeletedUser_returns40101` + - `login_missingPassword_returns40010` + - `login_invalidVersion_returns40010`:sVersion="experimental" + - `login_5thFailureLocks_returns40301`:连续 5 次 wrong password,第 5 次断言 code=40301 + `data.cooldownSeconds > 0` + - `login_responseExcludesSPasswordHash`:jsonPath `$.data.user.sPasswordHash` doesNotExist + - 子会话: FAIL + +- [ ] **Step 5.2 实现 LoginController + SecurityConfig 白名单** + - 子会话: PASS + +- [ ] **Step 5.3 跑全量 backend 测试** + - `cd backend && mvn -B test` + - 期望 144 + 4(DTO valid) + 2(JwtTokenProvider) + 4(InMemoryStore) + 7(service unit) + 9(controller IT) = 170 测试,全绿 + +- [ ] **Step 5.4 提交** + - `git commit -m "feat(usr): POST /api/auth/login controller REQ-USR-004"` + +--- + +## 提交计划 + +- `feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004`(Task 1) +- `feat(usr): JwtTokenProvider sign/parse REQ-USR-004`(Task 2) +- `feat(usr): in-memory login attempt store REQ-USR-004`(Task 3) +- `feat(usr): login service + account locked handling REQ-USR-004`(Task 4) +- `feat(usr): POST /api/auth/login controller REQ-USR-004`(Task 5) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..b358a73 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md @@ -0,0 +1,36 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-06 +round: 2 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-001 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +Round 1 两条 must_fix 处理结果: +1. **HIGH(reviewer 误判)**:`UserServiceImpl.java:102` 实际已含 `up.setTCreateDate(LocalDateTime.now());`,round 1 reviewer 看错。fix commit 520c01f 正确不动代码。 +2. **MEDIUM(已修)**:spec/plan 中 `bSelected` 提及全部转为「无该列」注解,与 docs/03 SSoT + UserPermissionEntity 实现一致(fix commit 520c01f)。 + +## Nice-to-have(待 sweep) + +- 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 跳错枚举。 +- 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 登录校验复用。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — 补 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条 IT 镜像 service 单测分支。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:70 — `permissionCategoryIds` 含重复 id 时 found.size() < ids.size() 误报 40422;可在校验前 distinct。 +- docs/05 § REQ-USR-001 错误码段位 40020 vs spec 落地 40010 sweep 对齐。 +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — Javadoc 注释笔误 `REQ-MOD-001 用户新增`,Controller.java 内已正确为 `REQ-USR-001`;plan 同步修正即可。 + +## 反例 / 测试覆盖缺口 + +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测覆盖)。 +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。 +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。 +4. 跨 round 1 / round 2:fix commit 520c01f 仅触及 3 份 docs,0 行代码变更;UserServiceImpl / UserPermissionEntity / 测试均保持原样,无新引入风险。`scripts/test.sh` 全量 101/0/0/0 BUILD SUCCESS。 + +**核心结论**:round 1 must_fix #1 是 reviewer 误判(无需修),#2 已修。所有 IT + 单元测试 101 个全绿。verdict: approve。 diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..98861d9 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md @@ -0,0 +1,35 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-06 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-002 — round 1 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- 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 校验字段未变。 +- 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)`。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 缺 `put_targetSoftDeleted_returns40431` 和 `put_staffSoftDeleted_returns40421` 两条端到端用例(spec § 验收 #7 / #10 仅在 service 单测覆盖)。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:327 — `put_ignoresProtectedFields_doesNotChangeUserNoOrName` 未断言 tCreateDate 保留(spec § 业务规则 #7 / 验收 #4 列入"不被修改"范围)。 +- 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 同类风险并案管理。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java — Service 中 iStaffId/sUserType/sLanguage 直接覆盖、bCanModifyDocs null 保留——建议加注释或抽 mergeUpdate 私有方法集中 null 语义。 +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java — 未覆盖 `dto.permissionCategoryIds == null` 分支(spec § 业务规则 #5 "不传则只删不插")。当前所有相关测试都用 `List.of()` 而非 null。 + +## 反例 / 测试覆盖缺口 + +1. **AC11 回滚证据**:`put_permissionCategoryNotFound_returns40422` 受 `@Transactional+@Rollback` 制约,无法直接观测 service 层的回滚。 +2. **AC1 重建关联粒度**:`upCount==2` 可被任何 insert 顺序异常掩盖,需细化断言。 +3. **AC7 / AC10 端到端缺失**:目标软删除 / staff 软删除均仅在 service 单测覆盖。 +4. **AC4 tCreateDate 保留**:实现走 load-then-modify 默认 NOT_NULL 策略行为正确,但缺测试佐证。 +5. **业务规则 #5 null permissionCategoryIds 分支**:单测未直接覆盖。 +6. **iStaffId IGNORED 全局副作用**:本期安全,未来 partial-update path 风险,需在 module_usr 完成报告中登记。 + +**核心结论**: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。 diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..51569ee --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md @@ -0,0 +1,36 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-06 +round: 2 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-003 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +Round 1 三条 must_fix 处理结果(commit f53689c): +1. **HIGH SQL 注入** — RESOLVED。UserQueryDTO 删除 `column` 字段;UserMapper.searchUsers 三参签名含 `@Param("column") String column`;UserMapper.xml 用 `${column}`;UserServiceImpl.search 用局部变量映射后通过 mapper 单独传入。 +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` 单测。 +3. **MEDIUM XML deleted 边界** — RESOLVED。queryField=deleted 但 queryValue 空时仍保留默认过滤 `u.bDeleted = 0`,避免返回全量含已删除。 + +## Nice-to-have + +- UserServiceImpl.search:212/214 — 直接 `query.setQueryValue("1")` 改写入参 DTO,单测靠副作用断言。语义上 service 不应突变 controller 入参;可改用局部 `normalizedDeletedValue` + `@Param("deletedValue")` 传给 mapper,XML 改用 `#{deletedValue}`,更纯。 +- round 1 遗留的 6 条 IT 覆盖缺口(department equals / deleted=false / notContains / 排序 / matchType 非枚举 IT / 空结果 IT)+ UserMapperSearchIT 断言强化 + PageResult javadoc + QUERY_COLUMN_MAP 位置 + Base_Column_List 抽取——本轮也不在 must_fix 范畴,留待后续 sweep。 + +## 反例 / 测试覆盖缺口 + +Round 1 must_fix 1 + 2 + 3 三项均已落地: + +1. 注入:UserMapper.xml 唯一 `${...}` 插值仅来自 service 白名单映射,外部输入完全经过 `#{...}` 参数化绑定。 +2. deleted 标准化:单测 `search_deletedQueryValueTrue_normalizedToOne` 钉死 `query.getQueryValue() == "1"`;IT `get_filterByDeletedTrue_returnsOnlyDeleted` 端到端验证返回已删除用户。 +3. XML 边界:deleted 空值分支保留默认 `u.bDeleted = 0`,与 spec § 业务规则 1 一致。 + +`mvn -B test` 经 .env.local 注入后 144/144 全绿,无新高危。 + +**核心结论**:round 1 high 注入风险 + spec § 6 契约缺失全部修复;其余 nice-to-have 不阻塞,留下一 sweep。verdict: approve。 diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-004.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-004.md new file mode 100644 index 0000000..edd0132 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-004.md @@ -0,0 +1,38 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-06 +round: 2 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-004 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +Round 1 三条 must_fix 处理结果(commit d439c0d): +1. **CRITICAL JwtTokenProviderTest 硬编码生产 JWT_SECRET** — RESOLVED。test SECRET 改为 fake 32-byte hex(line 21);`.env.local` JWT_SECRET 已旋转为新随机值。**遗留运维项**:旧值已入 commit b7ed804 git history,所有已部署环境必须同步轮换 JWT_SECRET。 +2. **HIGH InMemoryLoginAttemptStore 锁定到期不重置** — RESOLVED。cooldownSeconds 过期路径 `store.remove`,recordFailure compute 入口检测 prev.lockUntil 过期重建 FailRecord;spec § 业务规则 4 第 4 条达成。 +3. **MEDIUM 验收 #9 无测试** — RESOLVED。补 `cooldown_afterExpiry_resetsCount` 单测(含 reset 后再 4 次未锁、第 5 次再锁的完整往返)+ `login_afterLockExpiry_returns200` IT;`expireLockForTest` 改 public 解决跨包可见性。 + +## Nice-to-have + +- backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java + service — spec § 业务规则 7 客户端 IP 审计未实施;service 未拿 `HttpServletRequest.getRemoteAddr()`。**需在模块完成报告 § ⑩ 登记技术债**。 +- 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 要求)。 +- backend/src/main/java/com/xly/erp/config/SecurityConfig.java — plan Step 5.2 要求 `requestMatchers("/api/auth/login").permitAll()` 显式白名单;当前 anyRequest().permitAll() 等价覆盖但未做 plan 偏离登记。 +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:17 — `java.util.Map` fully-qualified;下次 sweep 加 import。 +- backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java:51 — `expireLockForTest` 已 public 但住在生产包;下一 sweep 可挪到 src/test 的 testutil 包。 + +## 反例 / 测试覆盖缺口 + +Round 1 三项 must_fix 全部解决: +1. JwtTokenProviderTest SECRET 改 fake + .env.local 已旋转;运维侧需轮换部署环境。 +2. cooldownSeconds + recordFailure 双路径处理过期重置;business rule #4 完整。 +3. 验收 #9 单测 + IT 双层覆盖;reset 后能重新触发新一轮锁定。 + +`mvn -B test` 经 .env.local 注入后 172/172 全绿;`scripts/test.sh` GREEN;无新高危。 + +**核心结论**:critical + high + medium 三项关键修复已落地。剩余 5 条 nice-to-have(IP 审计 / SET 子句捕获 / SecurityConfig 显式白名单 / Map import / testutil 移植)在模块完成报告 § ⑩ 登记后留给后续 sweep。verdict: approve。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-001.md b/docs/superpowers/specs/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..ba59bda --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-001.md @@ -0,0 +1,197 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-001 — 用户新增 + +## 目标 + +实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。 + +## 输入 / 触发 + +**接口**:`POST /api/users`,Content-Type `application/json`。 + +**Request body**(`UserCreateDTO`)字段: + +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | +|---|---|---|---|---| +| `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` | +| `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` | +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` | +| `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` | +| `permissionCategoryIds` | List | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 | + +**鉴权**:契约要求 `Authorization: Bearer ` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 + +> **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。 + +## 输出 / 结果 + +**HTTP 200,响应体**: + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "iIncrement": 12, + "sUserNo": "u001", + "sUserName": "alice", + "iStaffId": 7, + "sUserType": "普通用户", + "sLanguage": "zh", + "bCanModifyDocs": false, + "tCreateDate": "2026-05-06T10:30:00", + "bDeleted": false, + "permissionCategoryIds": [1, 2, 3] + }, + "timestamp": 1746528600000 +} +``` + +新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。 + +**不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。 + +## 业务规则 + +1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。 +2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。 +3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 +4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。 +5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。 +6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。 +7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。 +8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。 + +## 边界与约束 + +### 鉴权策略 + +沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) | +| `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +> docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。 + +### 性能 / 并发 + +- 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。 +- `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。 + +### 字符集 / 长度 + +- utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。 +- 长度超 schema 上限 → 视为参数错 40010。 + +### 与 docs/04 § 1.4 / 3.5 一致性 + +- 异常走 GlobalExceptionHandler。 +- BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。 + +### 已知技术债 + +- **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。 +- **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。 + +## 依赖的 schema 表 / 字段 + +**写表**:`tUser`、`tUserPermission` + +**读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典) + +| `tUser` 字段 | 落库逻辑 | +|---|---| +| `iIncrement` | DB AUTO_INCREMENT | +| `sUserNo` / `sUserName` | 入参(必填,唯一) | +| `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) | +| `sUserType` / `sLanguage` | 入参(必填,枚举) | +| `bCanModifyDocs` | 入参(可选,默认 false) | +| `sPasswordHash` | BCrypt("666666") | +| `tCreateDate` | LocalDateTime.now() | +| `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 | + +| `tUserPermission` 字段 | 落库逻辑 | +|---|---| +| `iIncrement` | DB AUTO_INCREMENT | +| `iUserId` | 新用户 iIncrement | +| `iCategoryId` | dto.permissionCategoryIds[i] | +| `tCreateDate` | LocalDateTime.now() | +| `sCreatedBy` | NULL(REQ-USR-004 后回填) | +| 多租户 / `sId` | NULL | + +**索引利用**: +- `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底 +- `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底) + +**外键**: +- `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常 +- `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心 +- `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert + +## 依赖的接口 + +无(本接口独立工作)。 + +REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。 +2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。 +3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。 +4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。 +5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。 +6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。 +7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 +9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。 +10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。 +11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`。 +- 不回显堆栈。 + +### 测试覆盖 + +- **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder + - create_minimalFields_returnsVOWithBCryptHash + - create_withStaffAndPermissions_writesAssociation + - create_duplicateUserName_throws40921 + - create_duplicateUserNo_throws40921 + - create_staffNotFound_throws40421 + - create_staffSoftDeleted_throws40421 + - create_permissionCategoryNotFound_throws40422 + - create_emptyPermissionCategoryIds_doesNotInsertAssociation + - create_concurrentDuplicate_dupKeyException_mappedTo40921 +- **集成测试** `UserControllerIT`: + - post_minimalFields_returns200 + - post_withStaffAndPermissions_returns200_andDbAssociated + - post_duplicateUserName_returns40921 + - post_staffNotFound_returns40421 + - post_permissionCategoryNotFound_returns40422 + - post_passwordHashedInDb_notPlaintext + - post_responseExcludesSPasswordHash + +### 代码与文档 + +- `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。 +- 提交按 `feat(usr): REQ-USR-001` 规范。 +- 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-002.md b/docs/superpowers/specs/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..87b303c --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-002.md @@ -0,0 +1,174 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-002 — 用户修改 + +## 目标 + +实现后端 `PUT /api/users/{id}` 接口:在不破坏唯一性的前提下,更新已有用户的可编辑字段(含权限组关联),返回最新 UserVO(不含 sPasswordHash)。 + +## 输入 / 触发 + +**接口**:`PUT /api/users/{id}`,Content-Type `application/json`。`{id}` = `tUser.iIncrement`。 + +**Request body**(`UserUpdateDTO`)字段——与 REQ-USR-001 输入相比**剥除 `sUserNo` / `sUserName`**(不可改,登录身份固定);其余字段均可修改: + +| 字段 | 类型 | 必填 | 校验 / 取值 | 行为 | +|---|---|---|---|---| +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement`;显式 `null` 表示清空员工关联 | 覆盖 | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | 覆盖 | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | 覆盖 | +| `bCanModifyDocs` | Boolean | 否 | `null` 保持原值;显式覆盖 | 部分更新 | +| `permissionCategoryIds` | List | 否 | 每元素必须存在且未软删除;可空数组(清空所有授权) | 重建关联(先删后插,幂等) | + +> **不在 DTO 中**:`sUserNo`(用户号唯一不可改)、`sUserName`(登录账号唯一不可改)、`sPasswordHash`(密码不通过本接口修改)。Jackson 默认忽略未知字段。 +> 前端 UI 应把这些字段渲染为只读。 + +**鉴权**:契约要求 `Authorization: Bearer ` + `USR:UPDATE`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**复用 REQ-USR-001 的 `UserVO`(10 个字段含 `permissionCategoryIds`),不暴露 sPasswordHash。 + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "iIncrement": 12, + "sUserNo": "u001", + "sUserName": "alice", + "iStaffId": 7, + "sUserType": "超级管理员", + "sLanguage": "en", + "bCanModifyDocs": true, + "tCreateDate": "2026-05-06T10:30:00", + "bDeleted": false, + "permissionCategoryIds": [1, 2] + }, + "timestamp": 1746528600000 +} +``` + +## 业务规则 + +1. **目标用户必须存在且未软删除**:`selectById(id)` 返回 null 或 `bDeleted=1` → `BizException(USR_NOT_FOUND)` (40431)。 +2. **`sUserNo` / `sUserName` / `sPasswordHash` 不可改**:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。 +3. **iStaffId 校验**(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` 不存在或 `bDeleted=true` → `BizException(STAFF_NOT_FOUND)` (40421);显式 `null` 表示清空员工关联,不校验。 +4. **权限分类校验**(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(`selectBatchIds` 一次性查);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 +5. **权限组重建语义**:service 内**先删后插**——先 `userPermissionMapper.delete(eq(iUserId, {id}))` 清空目标用户所有现有关联,再按 `permissionCategoryIds` 顺序插入。空数组 / 不传则只删不插(清空授权)。 +6. **`bCanModifyDocs` / `iStaffId` 部分更新**:DTO 中 `null` 时—— + - `bCanModifyDocs == null` → 保持原值; + - `iStaffId == null` → **显式清空**为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。 +7. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 +8. **审计**:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。 +9. **事务**:`@Transactional(rollbackFor = Exception.class)`,覆盖目标校验 → 父校验 → 权限校验 → user update → tUserPermission delete → tUserPermission insert,整体回滚。 + +## 边界与约束 + +### 鉴权策略 + +沿用 module_mod / REQ-USR-001 SecurityConfig permitAll。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | +| `{id}` 用户不存在或已软删除 | 40431 | `USR_NOT_FOUND`(**新增**) | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(已存在) | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(已存在) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +> docs/05 § REQ-USR-002 中列的 40331(角色变更权限)涉及操作员角色权限模型,需要 SecurityContext + RBAC 才能落地,**REQ-USR-004 后再补**;本 REQ 不实施。 + +### iStaffId 的 NULL 写入 + +借鉴 REQ-MOD-002 经验:MyBatis-Plus 默认 update 跳过 null 字段,需让 `iStaffId` 字段加 `FieldStrategy.IGNORED` 才能把 NULL 写入 SQL。本期在 `UserEntity#iStaffId` 上加 `@TableField(value="iStaffId", updateStrategy=FieldStrategy.IGNORED)`。 + +> 风险与 REQ-MOD-002 相同:未来 partial update 路径会埋雷。本 REQ 走 load-then-modify 全量回填,安全。 + +### 权限组重建的并发 + +- "先删后插"在事务内是原子的;`uk_user_perm` 唯一约束兜底。 +- 同一用户并发修改权限:MySQL 默认行级锁 + 事务隔离 → 后写覆盖(与 REQ-MOD-002 一致)。 + +### 性能 + +- `selectBatchIds` 单次 round-trip 校验 N 个权限分类。 +- `delete + insert` 为 N+1 次 SQL,本期不优化。 + +## 依赖的 schema 表 / 字段 + +**写表**:`tUser`(主体字段更新)、`tUserPermission`(先删后插) + +**读表**:`tStaff`(iStaffId 校验)、`tPermissionCategory`(权限分类校验) + +| `tUser` 字段 | 行为 | +|---|---| +| `iIncrement` / `sUserNo` / `sUserName` / `sPasswordHash` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `sId` / 多租户 / `bDeleted` 三件套 | **不修改** | +| `iStaffId` | 入参覆盖(含 null 设根;需 `FieldStrategy.IGNORED`) | +| `sUserType` / `sLanguage` | 入参覆盖(必填) | +| `bCanModifyDocs` | 入参非 null 覆盖;null 保留 | + +`tUserPermission` 操作:先 `delete(eq(iUserId, {id}))`,再按 `permissionCategoryIds` 顺序 `insert`(每条 `iUserId={id}` / `iCategoryId=...` / `tCreateDate=now`,无 bSelected)。 + +**索引利用**:`uk_user_no` / `uk_user_name`(不会触发,因为本接口不改这两列);`uk_user_perm`(兜底重复授权)。 + +## 依赖的接口 + +无(独立接口;REQ-USR-001 建立的体系完全复用)。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 全字段更新**:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。 +2. **正向 — 清空 iStaffId**:PUT 时显式 `iStaffId=null`,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。 +3. **正向 — 清空权限组**:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。 +4. **正向 — 保留字段**:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。 +5. **正向 — 部分字段保留原值**:DTO 中 `bCanModifyDocs=null`,DB 保留原值(验证 NOT_NULL 策略生效)。 +6. **目标不存在**:`PUT /api/users/999999`,返回 40431。 +7. **目标已软删除**:先建 user 后置 bDeleted=1,PUT 返回 40431。 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 +9. **iStaffId 不存在**:iStaffId=999999,返回 40421。 +10. **iStaffId 已软删除**:返回 40421。 +11. **permissionCategoryIds 任一不存在**:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。 +12. **sUserNo / sUserName / sPasswordHash 字段被忽略**:客户端误传 `sUserNo="hijack"` / `sUserName="hijack"` / `sPasswordHash="$2a$10$xxx"`,DB 中这 3 个字段保持原值。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`。 +- 错误码 200 / 40010 / 40421 / 40422 / 40431 / 50000。 +- 不暴露 sPasswordHash;不回显堆栈。 + +### 测试覆盖 + +- **单元测试** `UserServiceImplTest` 追加(mock 5 个 mapper + PasswordEncoder): + - update_targetNotFound_throws40431 + - update_targetSoftDeleted_throws40431 + - update_staffNotFound_throws40421 + - update_staffSoftDeleted_throws40421 + - update_permissionCategoryNotFound_throws40422 + - update_full_returnsVOWithUpdatedFields_andRebuildsPermissions(捕 ArgumentCaptor,断言 user 字段保留 sUserNo/sUserName/sPasswordHash + 新值字段;断言 userPermissionMapper.delete 调一次 + insert 调 N 次) + - update_partialNullBCanModifyDocs_keepsOriginal + - update_clearStaffId_setsToNull + - update_emptyPermissionCategoryIds_clearsAllAssociations(delete 调一次 + insert 不调) + +- **集成测试** `UserControllerIT` 追加: + - put_validUpdate_returns200_andDbReflects + - put_clearStaffId_setsNull + - put_emptyPermissionCategoryIds_clearsAssociations + - put_targetNotFound_returns40431 + - put_staffNotFound_returns40421 + - put_permissionCategoryNotFound_returns40422 + - put_missingRequired_returns40010 + - put_ignoresProtectedFields_doesNotChangeUserNoOrName(body 含 sUserNo / sUserName / sPasswordHash 但 DB 中这三列保持原值) + +### 代码与文档 + +- `// REQ-USR-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode `USR_NOT_FOUND`。 +- 提交按 `feat(usr): REQ-USR-002`。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-003.md b/docs/superpowers/specs/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..aba4e90 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-003.md @@ -0,0 +1,236 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-003 — 用户查询 + +## 目标 + +实现后端 `GET /api/users` 接口:按 `queryField` + `matchType` + `queryValue` 三件套对 `tUser` 进行过滤(含跨表 JOIN `tStaff` 取员工名 / 部门),分页返回精简 VO 列表。 + +## 输入 / 触发 + +**接口**:`GET /api/users`,无请求体。 + +**Query parameters**(`UserQueryDTO`): + +| 字段 | 类型 | 必填 | 校验 / 取值 | +|---|---|---|---| +| `pageNum` | Integer | 否 | ≥1,默认 1 | +| `pageSize` | Integer | 否 | 1-100,默认 20 | +| `queryField` | String | 否 | 枚举:`username` / `staffname` / `userno` / `department` / `usertype` / `language` / `deleted` / `lastLoginDate` / `createdBy`;缺省视为不过滤 | +| `matchType` | String | 否 | 枚举:`contains` / `notContains` / `equals`;默认 `contains` | +| `queryValue` | String | 否 | 长度 ≤ 100;缺省视为不过滤;`deleted` 字段下视为 `'true' / 'false'` 字符串 | + +**鉴权**:契约要求 `Authorization: Bearer ` + `USR:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**: + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "total": 42, + "list": [ + { + "iIncrement": 12, + "sUserName": "alice", + "sStaffName": "张三", + "sUserNo": "u001", + "sDepartment": "研发部", + "sUserType": "普通用户", + "sLanguage": "zh", + "bDeleted": false, + "tLastLoginDate": "2026-05-06T09:00:00", + "sCreatedBy": "admin", + "tCreateDate": "2026-05-06T08:00:00" + } + ], + "pageNum": 1, + "pageSize": 20 + }, + "timestamp": 1746528600000 +} +``` + +新建 `UserListItemVO`(11 字段)+ 复用项目通用 `PageResult`(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。 + +> **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `iStaffId` / `bCanModifyDocs` / `tDeletedDate` / `sDeletedBy`。 + +## 业务规则 + +1. **范围过滤**:默认仅返回 `bDeleted=0` 用户**除非** `queryField=deleted` 且 `queryValue` 显式过滤——见规则 6。 +2. **跨表 JOIN**:`tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0`;空关联字段(无 staff)展示为空字符串 `""`(VO 字段为 null)。 +3. **queryField 列映射**: + + | queryField 值 | SQL 实际列 | + |---|---| + | `username` | `tUser.sUserName` | + | `staffname` | `tStaff.sStaffName` | + | `userno` | `tUser.sUserNo` | + | `department` | `tStaff.sDepartment` | + | `usertype` | `tUser.sUserType` | + | `language` | `tUser.sLanguage` | + | `deleted` | `tUser.bDeleted` | + | `lastLoginDate` | `tUser.tLastLoginDate` | + | `createdBy` | `tUser.sCreatedBy` | + +4. **matchType 映射**: + + | matchType 值 | SQL 片段 | + |---|---| + | `contains`(默认) | ` LIKE '%' || queryValue || '%'` | + | `notContains` | ` NOT LIKE '%' || queryValue || '%'` | + | `equals` | ` = queryValue` | + +5. **空过滤值语义**:`queryField` 或 `queryValue` 缺省 / 空串 → 不附加该 WHERE 条件。 +6. **deleted 字段特殊处理**:当 `queryField=deleted` 时,`bDeleted=0` 的默认过滤不应用(让 `queryValue` 直接控制——`'false'` / `'0'` → bDeleted=0;`'true'` / `'1'` → bDeleted=1)。 +7. **排序**:按 `tUser.tCreateDate DESC, tUser.iIncrement DESC` 稳定排序。 +8. **分页**:MyBatis-Plus `Page` + `selectPage` 标准分页;total 由 MP 自动计数。 +9. **只读**:`@Transactional(readOnly = true)`;不写库。 +10. **空结果**:`data.list = []` + `data.total = 0` + `code=200`,不返回 404。 + +## 边界与约束 + +### 鉴权策略 + +沿用 SecurityConfig permitAll。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| `pageSize` 超 1-100 / `queryField` / `matchType` 非枚举 / `queryValue` 长度超限 | 40010 | `PARAM_INVALID`(已存在) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +### 实现路径选择 + +引入**自定义 XML SQL**(`UserMapper.xml`):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 `mapper/usr/UserMapper.xml` 添加 `searchUsers(IPage page, @Param("query") UserQueryDTO query)` 方法,对应 ResultMap 映射到 `UserListItemVO`。 + +XML SQL 草稿(写入 plan 锁定): + +```xml + +``` + +> `${col}` 由 service 层通过 enum 映射成实际列名后传入参数(**不是直接拼用户输入**——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。 + +实际实现可能要把 `${col}` 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 `query.setColumn(...)`),XML 用 `${query.column}` 渲染——XMl `${...}` 直接拼接字符串,服务层先白名单校验。 + +### 性能 + +- LEFT JOIN tStaff 走 `tUser.iStaffId` 上的索引(`fk_user_staff`)。 +- `LIKE '%X%'` 左模糊不走索引;本期数据量低可接受。 +- MP `Page` 走 PaginationInnerInterceptor,需要在 `MybatisPlusConfig` 注册(如已存在跳过;如未引入,本 REQ 引入)。 + +### Bean Validation + +`UserQueryDTO` 用 `@Min(1) Integer pageNum`、`@Min(1) @Max(100) Integer pageSize`、`@Pattern` 校验枚举字段。Controller 用 `@Valid`(query 参数 bean 绑定)。 + +## 依赖的 schema 表 / 字段 + +**读表**:`tUser`(主体)+ `tStaff`(LEFT JOIN) + +| `tUser` 字段 | 用途 | +|---|---| +| 全部 11 个输出字段 + `bDeleted` 过滤 | SELECT + WHERE | +| `iStaffId` | LEFT JOIN 键 | + +| `tStaff` 字段 | 用途 | +|---|---| +| `iIncrement` | LEFT JOIN 键 | +| `sStaffName` / `sDepartment` | SELECT + 可选 WHERE | +| `bDeleted` | LEFT JOIN 时附加条件 (s.bDeleted=0) | + +**索引利用**: +- `pk_user`:分页 ORDER BY iIncrement +- `fk_user_staff`(隐式 BTREE on `iStaffId`):JOIN +- `uk_user_no` / `uk_user_name`:equals 匹配能走索引 + +## 依赖的接口 + +无(独立查询接口)。 + +## 验收标准 + +### 功能正确性 + +1. **空查询返回所有未删除用户**:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。 +2. **按用户名 contains 过滤**:DB 有 alice / alex / bob,`queryField=username&matchType=contains&queryValue=al` 返回 alice + alex。 +3. **按员工名 contains 过滤**:通过 LEFT JOIN,匹配 tStaff.sStaffName。 +4. **按部门 equals 过滤**:matchType=equals,命中精确部门。 +5. **deleted=false 过滤**:返回未删除集合。 +6. **deleted=true 过滤**:仅返回已软删除(验证规则 6)。 +7. **notContains 排除**:matchType=notContains,排除指定关键字。 +8. **分页**:pageSize=2,pageNum=1 / 2,断言 list.size + total。 +9. **空结果**:queryValue 完全不匹配,返回 list=[] + total=0。 +10. **响应字段精简**:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。 +11. **未关联 staff 用户**:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。 +12. **pageSize 超限**:pageSize=101,返回 40010。 +13. **queryField 非枚举**:queryField=invalid,返回 40010。 +14. **matchType 非枚举**:返回 40010。 +15. **排序**:tCreateDate DESC,新建在前。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{total, list, pageNum, pageSize}`。 +- 错误码 200 / 40010 / 50000。 +- 不暴露 sPasswordHash 等内部字段。 + +### 测试覆盖 + +- **单元测试** `UserServiceImplTest` 追加(mock UserMapper.searchUsers): + - search_emptyDb_returnsEmptyPage + - search_invalidQueryField_throws40010(service 内白名单校验) + - search_invalidMatchType_throws40010 + - search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射) + +- **集成测试** `UserControllerIT` 追加: + - get_emptyKeyword_returnsAllUndeleted + - get_filterByUsernameContains_returnsMatchedSubset + - get_filterByStaffnameContains_returnsJoinedResults + - get_filterByDeletedTrue_returnsOnlyDeleted + - get_pagination_returnsCorrectSlice + - get_responseExcludesInternalFields + - get_pageSizeTooLarge_returns40010 + - get_invalidQueryField_returns40010 + - get_userWithoutStaff_listItemHasNullStaffFields + +### 代码与文档 + +- `// REQ-USR-003` 注释贴在 Controller / Service 方法 / Mapper.xml 顶部。 +- 提交按 `feat(usr): REQ-USR-003`。 +- **新增** `MybatisPlusConfig`(如不存在):`PaginationInnerInterceptor` bean 启用 MP 分页。 +- **新增** 通用 `PageResult` VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-004.md b/docs/superpowers/specs/2026-05-06-REQ-USR-004.md new file mode 100644 index 0000000..c830d5f --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-004.md @@ -0,0 +1,176 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-004 — 用户登录 + +## 目标 + +实现后端 `POST /api/auth/login` 接口:用户凭用户名 + 密码(+ 版本)完成身份认证,签发限时 JWT access token + 返回用户基本信息;连续失败时临时锁定账号;登录成功回写 `tLastLoginDate`。 + +## 范围说明 + +本 REQ 是 module_usr 的最后一个 REQ,落地 JWT 签发 + 内存锁定计数 + tLastLoginDate 回写。**契约层面**,docs/02 § 三 与既有各 REQ Controller 注释提到「REQ-USR-004 完成后追加 @PreAuthorize + 切换 SecurityConfig 到 authenticated」。**本 REQ 仅签发 token**,SecurityConfig 保持 permitAll;将所有既有端点切换到强制鉴权 + 给每个 Controller 方法加 `@PreAuthorize` + 让现有 IT 携带 token 是另一个体量较大的清算工作(module_mod 4 端点 + module_usr 3 端点 + 全部 IT),作为已知技术债登记到模块完成报告 § ⑩,留给下一个 REQ 或独立 sweep 处理。 + +> 锁定失败计数:spec 写明用**内存** `ConcurrentHashMap` 保管;docs/03 § tUser 业务注记声明走 Redis,但本仓库 .env.local 未配 Redis 凭据、pom 也未引 spring-boot-starter-data-redis。本期保留内存实现并在 javadoc / 业务注记中注明"REQ-USR-XXX 引入 Redis 后替换"。 + +## 输入 / 触发 + +**接口**:`POST /api/auth/login`,Content-Type `application/json`。**不需鉴权**(white-list in SecurityConfig)。 + +**Request body**(`LoginDTO`): + +| 字段 | 类型 | 必填 | 校验 | +|---|---|---|---| +| `sUserName` | String | 是 | 长度 1-50 | +| `sPassword` | String | 是 | 长度 1-100(明文 HTTPS 传输;线下 prod 必须 HTTPS) | +| `sVersion` | String | 是 | 枚举:`standard`(仅一个值;REQ 卡片表 1 写"标准版",spec 用 ASCII `standard` 作 SQL/code 友好) | + +## 输出 / 结果 + +**HTTP 200,响应体**(成功): + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTc0NjUyODYwMCwiZXhwIjoxNzQ2NTM1ODAwfQ...", + "expiresIn": 7200, + "user": { + "iIncrement": 12, + "sUserNo": "u001", + "sUserName": "alice", + "sUserType": "普通用户", + "sLanguage": "zh" + } + }, + "timestamp": 1746528600000 +} +``` + +新建 VO `LoginResultVO`:字段 `accessToken` / `expiresIn`(秒)/ `user`(嵌套 `LoginUserInfo`:iIncrement / sUserNo / sUserName / sUserType / sLanguage 5 字段)。 + +**不返回**:`sPasswordHash` / `iStaffId` / `bCanModifyDocs` / `tLastLoginDate` / 多租户字段 / 软删除三件套。`refreshToken` 暂不签发(spec § 范围说明,留作后续)。 + +## 业务规则 + +1. **JWT 签发**:HS256,secret 来自 `.env.local` `JWT_SECRET`(已配置);`expiresIn = 7200`(2 小时);claims:`sub = sUserName`、`iat`、`exp`、`uid = iIncrement`、`type = sUserType`。 +2. **密码校验**:`BCryptPasswordEncoder.matches(sPassword, user.sPasswordHash)`。复用 REQ-USR-001 引入的 PasswordConfig bean。 +3. **目标用户存在 + 未软删**:`SELECT iIncrement, sUserName, sPasswordHash, sUserType, sLanguage, sUserNo, bDeleted FROM tUser WHERE sUserName = ? AND bDeleted = 0`。null → 失败计数 + `40101`(用户名或密码错误,不区分原因避免账号枚举);`bDeleted=1` 同样 40101。 +4. **失败计数 + 锁定**:内存 `Map`(key=sUserName)。`FailRecord{count, firstFailAt, lockUntil}`。 + - 每次密码错误 → `count++`,记 `firstFailAt`(首次失败时间,每个统计窗口)。 + - `count >= 5` 且未锁定 → 设 `lockUntil = now + 15min`。 + - 已锁定(`now < lockUntil`)→ 返回 `40301`(账号锁定)+ `data.cooldownSeconds = (lockUntil - now)`。 + - 锁定到期(`now >= lockUntil`)→ reset `count=0`,正常进入校验流程。 + - 登录成功 → `cache.remove(sUserName)`(清失败计数)。 +5. **`sVersion` 校验**:当前仅支持 `standard`(DTO @Pattern + service 层防御)。其他值返回 `40010`。 +6. **`tLastLoginDate` 回写**:成功签发 token 后用 `LambdaUpdateWrapper.set(tLastLoginDate, now)` 显式更新,避免 entity-driven update 触发 iStaffId.IGNORED 副作用。 +7. **审计 / 日志**:登录失败 / 锁定 / 成功均 `log.info` 一条(含 sUserName + 客户端 IP,IP 由 controller 通过 `HttpServletRequest.getRemoteAddr()` 获取并传给 service;REQ 卡片 § 验收提到"防暴力破解"以日志为最低抑制)。 +8. **响应格式**:成功 / 失败统一 `{code, message, data, timestamp}`;data 在失败时为 null(除 40301 外,含 `cooldownSeconds`)。 + +## 边界与约束 + +### 鉴权策略 + +- **本接口** `/api/auth/login`:white-list,permitAll(任何人可调)。 +- **其他既有接口**:本 REQ 不切换为 authenticated,仍保留 permitAll。技术债登记在模块完成报告 § ⑩。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| 必填缺失 / 长度超限 / sVersion 非枚举 | 40010 | `PARAM_INVALID`(已存在) | +| 用户名不存在 / 密码错误 / 用户已软删 | 40101 | `LOGIN_INVALID_CREDENTIALS`(**新增**) | +| 账号被临时锁定 | 40301 | `LOGIN_ACCOUNT_LOCKED`(**新增**) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +### Redis 缺位的妥协 + +`InMemoryLoginAttemptStore`(service 层 bean)实现 `LoginAttemptStore` 接口,提供 `recordFailure / clear / isLocked / cooldownSeconds`。后续 REQ 引入 Redis 时新增 `RedisLoginAttemptStore` impl,替换 bean,service 不变。 + +### JWT 库选择 + +引入 `io.jsonwebtoken:jjwt-api / jjwt-impl / jjwt-jackson` 0.12.x(最新稳定)。这属 docs/04 § 零 已列「权限认证 Spring Security / JWT」的具体实现,不触发软规则 S1。 + +### SecurityConfig 改动 + +`SecurityConfig` 在白名单中显式加 `requestMatchers("/api/auth/login").permitAll()`;其他 `anyRequest().permitAll()` 维持原状(与 module_mod / module_usr 已有端点兼容)。 + +### 防暴力破解 + +仅做基础锁定(5 次错误 → 15 分钟锁定)。docs/04 § 1.6 提到 token 生命周期 / 刷新机制 / 密钥管理;本期完成 expires-in、未实施 refreshToken 流转,记入已知 gap。 + +## 依赖的 schema 表 / 字段 + +**读表**:`tUser`(按 sUserName 查 + 校验密码) + +**写表**:`tUser`(仅写 `tLastLoginDate`;其他字段不动) + +| 字段 | 用途 | +|---|---| +| `sUserName` | 主索引(uk_user_name) | +| `sPasswordHash` | BCrypt 校验 | +| `bDeleted` | 过滤 | +| `iIncrement` / `sUserNo` / `sUserType` / `sLanguage` | LoginUserInfo / JWT claims | +| `tLastLoginDate` | 成功登录回写 | + +**索引利用**:`uk_user_name`(UNIQUE,单字段查询 O(1))。 + +**外键 / 关联表**:本接口不用。 + +## 依赖的接口 + +无(独立鉴权入口)。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 凭据正确**:先用 REQ-USR-001 创建用户(默认密码 666666),POST /api/auth/login,返回 200 + accessToken(非空)+ expiresIn=7200 + user 含 5 字段;DB 中 tLastLoginDate 已写入。 +2. **JWT 解析**:解析 accessToken(用 JWT_SECRET),断言 claims 含 `sub == sUserName`、`uid == iIncrement`、`type == sUserType`、`exp - iat == 7200`。 +3. **凭据错误 — 用户名不存在**:返回 40101。 +4. **凭据错误 — 密码错误**:返回 40101;DB tLastLoginDate 不更新;内存计数 +1。 +5. **用户已软删**:先创建用户后置 bDeleted=1,登录返回 40101(不区分原因防账号枚举)。 +6. **必填缺失 / sVersion 非枚举**:返回 40010。 +7. **5 次密码错误后锁定**:连续 5 次错误密码登录同一用户名,第 5 次(含)开始返回 40301 + `data.cooldownSeconds > 0`;正确密码也返回 40301(锁定期内一律锁定)。 +8. **锁定后正确密码仍 40301**:锁定状态下传正确密码也拒绝;登录失败和成功都不重置锁定到期时间。 +9. **锁定到期后可登录**:直接修改内存 store 的 lockUntil 至过去,再用正确密码登录,返回 200。 +10. **响应不含 sPasswordHash**:jsonPath 验证 `$.data.user.sPasswordHash` doesNotExist。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{accessToken, expiresIn, user}`。 +- 错误码 200 / 40010 / 40101 / 40301 / 50000。 +- 不暴露 sPasswordHash;不回显堆栈。 + +### 测试覆盖 + +- **单元测试** `LoginServiceImplTest`(mock UserMapper / PasswordEncoder / LoginAttemptStore / JwtTokenProvider): + - login_validCredentials_returnsTokenAndClearsFailCount + - login_userNotFound_returns40101_recordsFailure + - login_userSoftDeleted_returns40101 + - login_passwordMismatch_returns40101_recordsFailure + - login_accountLocked_returns40301_withCooldown + - login_5thFailureTriggersLock + - login_successUpdatesTLastLoginDate + - jwtTokenProvider_signAndParse_returnsClaims(独立 JwtTokenProvider 单元测试) + +- **集成测试** `LoginControllerIT`(@SpringBootTest @Transactional + 真实 PasswordEncoder + 真实 InMemoryLoginAttemptStore,用 @BeforeEach 重置 store): + - login_validCredentials_returns200WithToken + - login_jwtClaimsAreCorrect + - login_invalidUsername_returns40101 + - login_wrongPassword_returns40101 + - login_softDeletedUser_returns40101 + - login_missingPassword_returns40010 + - login_invalidVersion_returns40010 + - login_5thFailureLocks_returns40301 + - login_responseExcludesSPasswordHash + +### 代码与文档 + +- `// REQ-USR-004` 注释贴在 LoginController / LoginService / JwtTokenProvider / LoginAttemptStore / 新增 ErrorCode。 +- 提交按 `feat(usr): REQ-USR-004` 规范。 +- pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。