Compare View
Commits (56)
-
- 改用 LambdaUpdateWrapper.set(...) 显式声明 SET 列, 避免 iParentId.FieldStrategy.IGNORED 在 entity-driven update 时静默清空父引用。 - 新增 IT delete_preservesOtherFields_onChildModule 钉死非 root 子模块软删后其他列保持原值的回归断言。
-
- 新增 @GetMapping 树查询端点 + 7 个集成测试。 - 修复 maven-surefire-plugin 默认 includes 不含 *IT.java 的配置缺陷, 让 ModuleMapperIT / ModuleControllerIT 也参与 mvn test 全量。
-
让 git pre-push 钩子触发的子 shell 也能拿到正确的 JDK21 路径与 mysql-client 路径,避免在 PATH 短的环境下编译/连库失败。
-
UserPermissionEntity 不含 bSelected 列——docs/03 § tUserPermission 修订版无此列 (关联记录存在即「已选」),早期 spec/plan 草稿与 SSoT 不一致,以 docs/03 为准。
-
清理 spec/plan 中残留的 bSelected 字段提及——docs/03 修订版无该列, 关联记录存在即「已选」。代码 UserPermissionEntity 已正确不含该字段; 本 commit 仅清洁文档使 SSoT 一致。 reviewer round 1 报告的 high『tCreateDate 未设置』是误判: UserServiceImpl.java:102 实际已含 setTCreateDate(LocalDateTime.now()), 本 fix 不动代码。
-
8 个 GET IT 通过。get_filterByDeletedTrue 暂时移除 (PaginationInnerInterceptor + bit(1) 兼容性 + spec § 6 值标准化未实现), 计划 REQ-USR-004 时统一处理。
-
- HIGH 修注入:UserQueryDTO 移除 column 字段, 改成 service 局部变量 + UserMapper @Param("column") 单独传入, 防止 GET query-string 通过 setter 绑定绕过白名单。 - HIGH 修 spec § 6:service 在 queryField=='deleted' 时 把 queryValue 标准化为 '0' / '1';UserMapper.xml 加 deleted 专用 CAST(#{queryValue} AS UNSIGNED) 分支处理 MySQL bit(1) 与字符串隐式比较的不一致;恢复 get_filterByDeletedTrue IT。 - MEDIUM 修 XML deleted 边界:仅当 queryField=='deleted' 且 queryValue 非空时让用户控制 bDeleted 取值,否则保留默认过滤。 -
- CRITICAL:JwtTokenProviderTest 测试 SECRET 改为与生产无关的 fake hex。 注意 .env.local JWT_SECRET 已本地旋转为新随机值;旧值已入 commit b7ed804a git history,运维侧必须同步轮换所有部署环境的 JWT_SECRET。 - HIGH:InMemoryLoginAttemptStore 锁定到期后清空 record;recordFailure 入口检测过期场景重置 count(spec § 业务规则 4 第 4 条达成)。 - MEDIUM:补 cooldown_afterExpiry_resetsCount 单测 + login_afterLockExpiry_returns200 IT 覆盖验收 #9; expireLockForTest 改为 public 让跨包 IT 可调。
Showing
102 changed files
backend/pom.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | ||
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| 5 | + <modelVersion>4.0.0</modelVersion> | ||
| 6 | + | ||
| 7 | + <parent> | ||
| 8 | + <groupId>org.springframework.boot</groupId> | ||
| 9 | + <artifactId>spring-boot-starter-parent</artifactId> | ||
| 10 | + <version>3.2.5</version> | ||
| 11 | + <relativePath/> | ||
| 12 | + </parent> | ||
| 13 | + | ||
| 14 | + <groupId>com.xly.erp</groupId> | ||
| 15 | + <artifactId>erp-backend</artifactId> | ||
| 16 | + <version>0.1.0-SNAPSHOT</version> | ||
| 17 | + <packaging>jar</packaging> | ||
| 18 | + <name>erp-backend</name> | ||
| 19 | + <description>小羚羊 ERP 后端</description> | ||
| 20 | + | ||
| 21 | + <properties> | ||
| 22 | + <java.version>17</java.version> | ||
| 23 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||
| 24 | + <mybatis-plus.version>3.5.7</mybatis-plus.version> | ||
| 25 | + <mapstruct.version>1.5.5.Final</mapstruct.version> | ||
| 26 | + <hutool.version>5.8.27</hutool.version> | ||
| 27 | + <flyway.version>10.10.0</flyway.version> | ||
| 28 | + <lombok.version>1.18.32</lombok.version> | ||
| 29 | + </properties> | ||
| 30 | + | ||
| 31 | + <dependencies> | ||
| 32 | + <dependency> | ||
| 33 | + <groupId>org.springframework.boot</groupId> | ||
| 34 | + <artifactId>spring-boot-starter-web</artifactId> | ||
| 35 | + </dependency> | ||
| 36 | + <dependency> | ||
| 37 | + <groupId>org.springframework.boot</groupId> | ||
| 38 | + <artifactId>spring-boot-starter-validation</artifactId> | ||
| 39 | + </dependency> | ||
| 40 | + <dependency> | ||
| 41 | + <groupId>org.springframework.boot</groupId> | ||
| 42 | + <artifactId>spring-boot-starter-security</artifactId> | ||
| 43 | + </dependency> | ||
| 44 | + | ||
| 45 | + <dependency> | ||
| 46 | + <groupId>com.baomidou</groupId> | ||
| 47 | + <artifactId>mybatis-plus-spring-boot3-starter</artifactId> | ||
| 48 | + <version>${mybatis-plus.version}</version> | ||
| 49 | + </dependency> | ||
| 50 | + | ||
| 51 | + <dependency> | ||
| 52 | + <groupId>org.flywaydb</groupId> | ||
| 53 | + <artifactId>flyway-core</artifactId> | ||
| 54 | + <version>${flyway.version}</version> | ||
| 55 | + </dependency> | ||
| 56 | + <dependency> | ||
| 57 | + <groupId>org.flywaydb</groupId> | ||
| 58 | + <artifactId>flyway-mysql</artifactId> | ||
| 59 | + <version>${flyway.version}</version> | ||
| 60 | + </dependency> | ||
| 61 | + | ||
| 62 | + <dependency> | ||
| 63 | + <groupId>com.mysql</groupId> | ||
| 64 | + <artifactId>mysql-connector-j</artifactId> | ||
| 65 | + <scope>runtime</scope> | ||
| 66 | + </dependency> | ||
| 67 | + | ||
| 68 | + <dependency> | ||
| 69 | + <groupId>org.projectlombok</groupId> | ||
| 70 | + <artifactId>lombok</artifactId> | ||
| 71 | + <optional>true</optional> | ||
| 72 | + </dependency> | ||
| 73 | + <dependency> | ||
| 74 | + <groupId>org.mapstruct</groupId> | ||
| 75 | + <artifactId>mapstruct</artifactId> | ||
| 76 | + <version>${mapstruct.version}</version> | ||
| 77 | + </dependency> | ||
| 78 | + | ||
| 79 | + <dependency> | ||
| 80 | + <groupId>cn.hutool</groupId> | ||
| 81 | + <artifactId>hutool-all</artifactId> | ||
| 82 | + <version>${hutool.version}</version> | ||
| 83 | + </dependency> | ||
| 84 | + | ||
| 85 | + <!-- REQ-USR-004 JWT (jjwt 0.12.x) --> | ||
| 86 | + <dependency> | ||
| 87 | + <groupId>io.jsonwebtoken</groupId> | ||
| 88 | + <artifactId>jjwt-api</artifactId> | ||
| 89 | + <version>0.12.6</version> | ||
| 90 | + </dependency> | ||
| 91 | + <dependency> | ||
| 92 | + <groupId>io.jsonwebtoken</groupId> | ||
| 93 | + <artifactId>jjwt-impl</artifactId> | ||
| 94 | + <version>0.12.6</version> | ||
| 95 | + <scope>runtime</scope> | ||
| 96 | + </dependency> | ||
| 97 | + <dependency> | ||
| 98 | + <groupId>io.jsonwebtoken</groupId> | ||
| 99 | + <artifactId>jjwt-jackson</artifactId> | ||
| 100 | + <version>0.12.6</version> | ||
| 101 | + <scope>runtime</scope> | ||
| 102 | + </dependency> | ||
| 103 | + | ||
| 104 | + <dependency> | ||
| 105 | + <groupId>org.springframework.boot</groupId> | ||
| 106 | + <artifactId>spring-boot-starter-test</artifactId> | ||
| 107 | + <scope>test</scope> | ||
| 108 | + </dependency> | ||
| 109 | + <dependency> | ||
| 110 | + <groupId>org.springframework.security</groupId> | ||
| 111 | + <artifactId>spring-security-test</artifactId> | ||
| 112 | + <scope>test</scope> | ||
| 113 | + </dependency> | ||
| 114 | + </dependencies> | ||
| 115 | + | ||
| 116 | + <build> | ||
| 117 | + <plugins> | ||
| 118 | + <plugin> | ||
| 119 | + <groupId>org.springframework.boot</groupId> | ||
| 120 | + <artifactId>spring-boot-maven-plugin</artifactId> | ||
| 121 | + <configuration> | ||
| 122 | + <excludes> | ||
| 123 | + <exclude> | ||
| 124 | + <groupId>org.projectlombok</groupId> | ||
| 125 | + <artifactId>lombok</artifactId> | ||
| 126 | + </exclude> | ||
| 127 | + </excludes> | ||
| 128 | + </configuration> | ||
| 129 | + </plugin> | ||
| 130 | + <plugin> | ||
| 131 | + <groupId>org.apache.maven.plugins</groupId> | ||
| 132 | + <artifactId>maven-surefire-plugin</artifactId> | ||
| 133 | + <configuration> | ||
| 134 | + <includes> | ||
| 135 | + <include>**/*Test.java</include> | ||
| 136 | + <include>**/*Tests.java</include> | ||
| 137 | + <include>**/*IT.java</include> | ||
| 138 | + </includes> | ||
| 139 | + </configuration> | ||
| 140 | + </plugin> | ||
| 141 | + <plugin> | ||
| 142 | + <groupId>org.apache.maven.plugins</groupId> | ||
| 143 | + <artifactId>maven-compiler-plugin</artifactId> | ||
| 144 | + <configuration> | ||
| 145 | + <source>${java.version}</source> | ||
| 146 | + <target>${java.version}</target> | ||
| 147 | + <annotationProcessorPaths> | ||
| 148 | + <path> | ||
| 149 | + <groupId>org.projectlombok</groupId> | ||
| 150 | + <artifactId>lombok</artifactId> | ||
| 151 | + <version>${lombok.version}</version> | ||
| 152 | + </path> | ||
| 153 | + <path> | ||
| 154 | + <groupId>org.mapstruct</groupId> | ||
| 155 | + <artifactId>mapstruct-processor</artifactId> | ||
| 156 | + <version>${mapstruct.version}</version> | ||
| 157 | + </path> | ||
| 158 | + <path> | ||
| 159 | + <groupId>org.projectlombok</groupId> | ||
| 160 | + <artifactId>lombok-mapstruct-binding</artifactId> | ||
| 161 | + <version>0.2.0</version> | ||
| 162 | + </path> | ||
| 163 | + </annotationProcessorPaths> | ||
| 164 | + </configuration> | ||
| 165 | + </plugin> | ||
| 166 | + </plugins> | ||
| 167 | + </build> | ||
| 168 | +</project> |
backend/src/main/java/com/xly/erp/ErpApplication.java
0 → 100644
| 1 | +package com.xly.erp; | ||
| 2 | + | ||
| 3 | +import org.mybatis.spring.annotation.MapperScan; | ||
| 4 | +import org.springframework.boot.SpringApplication; | ||
| 5 | +import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
| 6 | + | ||
| 7 | +@SpringBootApplication | ||
| 8 | +@MapperScan("com.xly.erp.module.**.mapper") | ||
| 9 | +public class ErpApplication { | ||
| 10 | + public static void main(String[] args) { | ||
| 11 | + SpringApplication.run(ErpApplication.class, args); | ||
| 12 | + } | ||
| 13 | +} |
backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ErrorCode; | ||
| 4 | +import lombok.Getter; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * REQ-USR-004 账号被临时锁定时抛出。携带剩余 cooldownSeconds, | ||
| 8 | + * GlobalExceptionHandler 会把它转换成 ApiResponse.data 的 cooldownSeconds 字段。 | ||
| 9 | + */ | ||
| 10 | +@Getter | ||
| 11 | +public class AccountLockedException extends BizException { | ||
| 12 | + | ||
| 13 | + private final long cooldownSeconds; | ||
| 14 | + | ||
| 15 | + public AccountLockedException(long cooldownSeconds) { | ||
| 16 | + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); | ||
| 17 | + this.cooldownSeconds = cooldownSeconds; | ||
| 18 | + } | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/common/exception/BizException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ErrorCode; | ||
| 4 | +import lombok.Getter; | ||
| 5 | + | ||
| 6 | +@Getter | ||
| 7 | +public class BizException extends RuntimeException { | ||
| 8 | + private final int code; | ||
| 9 | + | ||
| 10 | + public BizException(ErrorCode ec) { | ||
| 11 | + super(ec.getMessage()); | ||
| 12 | + this.code = ec.getCode(); | ||
| 13 | + } | ||
| 14 | + | ||
| 15 | + public BizException(ErrorCode ec, String detail) { | ||
| 16 | + super(detail); | ||
| 17 | + this.code = ec.getCode(); | ||
| 18 | + } | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ApiResponse; | ||
| 4 | +import com.xly.erp.common.response.ErrorCode; | ||
| 5 | +import lombok.extern.slf4j.Slf4j; | ||
| 6 | +import org.springframework.validation.FieldError; | ||
| 7 | +import org.springframework.web.bind.MethodArgumentNotValidException; | ||
| 8 | +import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| 9 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
| 10 | + | ||
| 11 | +@Slf4j | ||
| 12 | +@RestControllerAdvice | ||
| 13 | +public class GlobalExceptionHandler { | ||
| 14 | + | ||
| 15 | + /** REQ-USR-004 账号锁定专用:把 cooldownSeconds 暴露在 data.cooldownSeconds */ | ||
| 16 | + @ExceptionHandler(AccountLockedException.class) | ||
| 17 | + public ApiResponse<java.util.Map<String, Object>> handleAccountLocked(AccountLockedException e) { | ||
| 18 | + log.warn("AccountLocked code={} cooldown={}s", e.getCode(), e.getCooldownSeconds()); | ||
| 19 | + java.util.Map<String, Object> data = java.util.Map.of("cooldownSeconds", e.getCooldownSeconds()); | ||
| 20 | + return new ApiResponse<>(e.getCode(), e.getMessage(), data, System.currentTimeMillis()); | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + @ExceptionHandler(BizException.class) | ||
| 24 | + public ApiResponse<Void> handleBiz(BizException e) { | ||
| 25 | + log.warn("BizException code={} message={}", e.getCode(), e.getMessage()); | ||
| 26 | + return ApiResponse.fail(e.getCode(), e.getMessage()); | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| 30 | + public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) { | ||
| 31 | + FieldError fe = e.getBindingResult().getFieldError(); | ||
| 32 | + String detail = fe == null | ||
| 33 | + ? ErrorCode.PARAM_INVALID.getMessage() | ||
| 34 | + : fe.getField() + ": " + fe.getDefaultMessage(); | ||
| 35 | + return ApiResponse.fail(ErrorCode.PARAM_INVALID, detail); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @ExceptionHandler(Exception.class) | ||
| 39 | + public ApiResponse<Void> handleAll(Exception e) { | ||
| 40 | + log.error("Uncaught exception", e); | ||
| 41 | + return ApiResponse.fail(ErrorCode.INTERNAL_ERROR); | ||
| 42 | + } | ||
| 43 | +} |
backend/src/main/java/com/xly/erp/common/response/ApiResponse.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | ||
| 2 | + | ||
| 3 | +import lombok.AllArgsConstructor; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import lombok.NoArgsConstructor; | ||
| 6 | + | ||
| 7 | +@Data | ||
| 8 | +@NoArgsConstructor | ||
| 9 | +@AllArgsConstructor | ||
| 10 | +public class ApiResponse<T> { | ||
| 11 | + private int code; | ||
| 12 | + private String message; | ||
| 13 | + private T data; | ||
| 14 | + private long timestamp; | ||
| 15 | + | ||
| 16 | + public static <T> ApiResponse<T> ok(T data) { | ||
| 17 | + return new ApiResponse<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data, System.currentTimeMillis()); | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + public static <T> ApiResponse<T> ok(String message, T data) { | ||
| 21 | + return new ApiResponse<>(ErrorCode.SUCCESS.getCode(), message, data, System.currentTimeMillis()); | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + public static <T> ApiResponse<T> fail(ErrorCode ec) { | ||
| 25 | + return new ApiResponse<>(ec.getCode(), ec.getMessage(), null, System.currentTimeMillis()); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + public static <T> ApiResponse<T> fail(ErrorCode ec, String detail) { | ||
| 29 | + return new ApiResponse<>(ec.getCode(), detail, null, System.currentTimeMillis()); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + public static <T> ApiResponse<T> fail(int code, String message) { | ||
| 33 | + return new ApiResponse<>(code, message, null, System.currentTimeMillis()); | ||
| 34 | + } | ||
| 35 | +} |
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | ||
| 2 | + | ||
| 3 | +import lombok.Getter; | ||
| 4 | + | ||
| 5 | +@Getter | ||
| 6 | +public enum ErrorCode { | ||
| 7 | + SUCCESS(200, "操作成功"), | ||
| 8 | + PARAM_INVALID(40010, "参数错误"), | ||
| 9 | + LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误"), | ||
| 10 | + LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定"), | ||
| 11 | + MOD_PARENT_NOT_FOUND(40411, "父模块不存在或已删除"), | ||
| 12 | + MOD_NOT_FOUND(40421, "模块不存在或已删除"), | ||
| 13 | + STAFF_NOT_FOUND(40421, "职员不存在或已删除"), | ||
| 14 | + PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除"), | ||
| 15 | + USR_NOT_FOUND(40431, "用户不存在或已删除"), | ||
| 16 | + MOD_PROC_NAME_DUP(40911, "存储过程名称已存在"), | ||
| 17 | + MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除"), | ||
| 18 | + MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代"), | ||
| 19 | + USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在"), | ||
| 20 | + INTERNAL_ERROR(50000, "服务器内部错误"); | ||
| 21 | + | ||
| 22 | + private final int code; | ||
| 23 | + private final String message; | ||
| 24 | + | ||
| 25 | + ErrorCode(int code, String message) { | ||
| 26 | + this.code = code; | ||
| 27 | + this.message = message; | ||
| 28 | + } | ||
| 29 | +} |
backend/src/main/java/com/xly/erp/common/response/PageResult.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.metadata.IPage; | ||
| 4 | +import lombok.AllArgsConstructor; | ||
| 5 | +import lombok.Data; | ||
| 6 | +import lombok.NoArgsConstructor; | ||
| 7 | + | ||
| 8 | +import java.util.ArrayList; | ||
| 9 | +import java.util.List; | ||
| 10 | + | ||
| 11 | +/** REQ-USR-003 引入的通用分页 VO。`data` 字段嵌套此结构。 */ | ||
| 12 | +@Data | ||
| 13 | +@NoArgsConstructor | ||
| 14 | +@AllArgsConstructor | ||
| 15 | +public class PageResult<T> { | ||
| 16 | + private long total; | ||
| 17 | + private List<T> list = new ArrayList<>(); | ||
| 18 | + private long pageNum; | ||
| 19 | + private long pageSize; | ||
| 20 | + | ||
| 21 | + public static <T> PageResult<T> of(IPage<T> page) { | ||
| 22 | + return new PageResult<>(page.getTotal(), page.getRecords(), page.getCurrent(), page.getSize()); | ||
| 23 | + } | ||
| 24 | +} |
backend/src/main/java/com/xly/erp/config/JacksonConfig.java
0 → 100644
| 1 | +package com.xly.erp.config; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonAutoDetect; | ||
| 4 | +import com.fasterxml.jackson.annotation.PropertyAccessor; | ||
| 5 | +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; | ||
| 6 | +import org.springframework.context.annotation.Bean; | ||
| 7 | +import org.springframework.context.annotation.Configuration; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * 让 Jackson 通过字段名(而非 getter/setter 推断)确定 JSON 属性名。 | ||
| 11 | + * | ||
| 12 | + * <p>项目沿用 docs/03 的匈牙利前缀命名(如 {@code iIncrement} / {@code sUserName}), | ||
| 13 | + * Lombok 生成的 getter({@code getIIncrement})经 JavaBeans Introspector 解析为 | ||
| 14 | + * {@code IIncrement}(首两字符全大写时保留),导致 JSON 输出 {@code "IIncrement"} | ||
| 15 | + * 而非期望的 {@code "iIncrement"}。改为字段访问后,Jackson 直接用字段名作 JSON key。</p> | ||
| 16 | + */ | ||
| 17 | +@Configuration | ||
| 18 | +public class JacksonConfig { | ||
| 19 | + | ||
| 20 | + @Bean | ||
| 21 | + public Jackson2ObjectMapperBuilderCustomizer fieldOnlyVisibility() { | ||
| 22 | + return builder -> builder | ||
| 23 | + .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) | ||
| 24 | + .visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) | ||
| 25 | + .visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE) | ||
| 26 | + .visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.NONE); | ||
| 27 | + } | ||
| 28 | +} |
backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java
0 → 100644
| 1 | +package com.xly.erp.config; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.DbType; | ||
| 4 | +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; | ||
| 5 | +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; | ||
| 6 | +import org.springframework.context.annotation.Bean; | ||
| 7 | +import org.springframework.context.annotation.Configuration; | ||
| 8 | + | ||
| 9 | +/** REQ-USR-003 引入:注册 MyBatis-Plus 分页拦截器,让 `Page<T>` 自动追加 LIMIT 子句。 */ | ||
| 10 | +@Configuration | ||
| 11 | +public class MybatisPlusConfig { | ||
| 12 | + | ||
| 13 | + @Bean | ||
| 14 | + public MybatisPlusInterceptor mybatisPlusInterceptor() { | ||
| 15 | + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); | ||
| 16 | + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); | ||
| 17 | + return interceptor; | ||
| 18 | + } | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/config/PasswordConfig.java
0 → 100644
| 1 | +package com.xly.erp.config; | ||
| 2 | + | ||
| 3 | +import org.springframework.context.annotation.Bean; | ||
| 4 | +import org.springframework.context.annotation.Configuration; | ||
| 5 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| 6 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * REQ-USR-001 引入:BCryptPasswordEncoder 注册为 Spring bean, | ||
| 10 | + * 供 UserService.create / REQ-USR-004 登录校验复用。strength 用 BCrypt 默认(10)。 | ||
| 11 | + */ | ||
| 12 | +@Configuration | ||
| 13 | +public class PasswordConfig { | ||
| 14 | + | ||
| 15 | + @Bean | ||
| 16 | + public PasswordEncoder passwordEncoder() { | ||
| 17 | + return new BCryptPasswordEncoder(); | ||
| 18 | + } | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/config/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.erp.config; | ||
| 2 | + | ||
| 3 | +import org.springframework.context.annotation.Bean; | ||
| 4 | +import org.springframework.context.annotation.Configuration; | ||
| 5 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
| 6 | +import org.springframework.security.web.SecurityFilterChain; | ||
| 7 | + | ||
| 8 | +@Configuration | ||
| 9 | +public class SecurityConfig { | ||
| 10 | + | ||
| 11 | + /** | ||
| 12 | + * REQ-MOD-001 临时配置:所有 /api/** 一律 permitAll,禁用 CSRF / 表单登录。 | ||
| 13 | + * REQ-USR-004 完成时改为 .authenticated() + JWT filter。 | ||
| 14 | + */ | ||
| 15 | + @Bean | ||
| 16 | + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||
| 17 | + http | ||
| 18 | + .csrf(csrf -> csrf.disable()) | ||
| 19 | + .formLogin(form -> form.disable()) | ||
| 20 | + .httpBasic(basic -> basic.disable()) | ||
| 21 | + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); | ||
| 22 | + return http.build(); | ||
| 23 | + } | ||
| 24 | +} |
backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.controller; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ApiResponse; | ||
| 4 | +import com.xly.erp.module.mod.dto.ModuleCreateDTO; | ||
| 5 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 6 | +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | ||
| 7 | +import com.xly.erp.module.mod.service.ModuleService; | ||
| 8 | +import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | ||
| 9 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 10 | +import com.xly.erp.module.mod.vo.ModuleVO; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.RequiredArgsConstructor; | ||
| 13 | +import org.springframework.web.bind.annotation.DeleteMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.PathVariable; | ||
| 16 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 17 | +import org.springframework.web.bind.annotation.PutMapping; | ||
| 18 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 19 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 20 | +import org.springframework.web.bind.annotation.RestController; | ||
| 21 | + | ||
| 22 | +import java.util.List; | ||
| 23 | + | ||
| 24 | +@RestController | ||
| 25 | +@RequestMapping("/api/modules") | ||
| 26 | +@RequiredArgsConstructor | ||
| 27 | +public class ModuleController { | ||
| 28 | + | ||
| 29 | + private final ModuleService moduleService; | ||
| 30 | + | ||
| 31 | + /** REQ-MOD-001 模块新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:CREATE')") */ | ||
| 32 | + @PostMapping | ||
| 33 | + public ApiResponse<ModuleVO> create(@Valid @RequestBody ModuleCreateDTO dto) { | ||
| 34 | + return ApiResponse.ok(moduleService.create(dto)); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /** REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')") */ | ||
| 38 | + @PutMapping("/{id}") | ||
| 39 | + public ApiResponse<ModuleVO> update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) { | ||
| 40 | + return ApiResponse.ok(moduleService.update(id, dto)); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /** REQ-MOD-003 模块软删除 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')") */ | ||
| 44 | + @DeleteMapping("/{id}") | ||
| 45 | + public ApiResponse<ModuleDeleteResultVO> delete(@PathVariable Integer id) { | ||
| 46 | + return ApiResponse.ok(moduleService.delete(id)); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + /** REQ-MOD-004 模块树查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')") */ | ||
| 50 | + @GetMapping | ||
| 51 | + public ApiResponse<List<ModuleTreeNodeVO>> tree(@Valid ModuleQueryDTO query) { | ||
| 52 | + return ApiResponse.ok(moduleService.tree(query)); | ||
| 53 | + } | ||
| 54 | +} |
backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.Min; | ||
| 4 | +import jakarta.validation.constraints.NotBlank; | ||
| 5 | +import jakarta.validation.constraints.Pattern; | ||
| 6 | +import jakarta.validation.constraints.Size; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +@Data | ||
| 10 | +public class ModuleCreateDTO { | ||
| 11 | + | ||
| 12 | + @NotBlank | ||
| 13 | + @Pattern(regexp = "^(手机端|前端业务|系统配置|接口)$", message = "sDisplayType 必须是 手机端/前端业务/系统配置/接口 之一") | ||
| 14 | + private String sDisplayType; | ||
| 15 | + | ||
| 16 | + @NotBlank | ||
| 17 | + @Size(max = 100) | ||
| 18 | + private String sProcedureName; | ||
| 19 | + | ||
| 20 | + @NotBlank | ||
| 21 | + @Size(max = 50) | ||
| 22 | + private String sModuleType; | ||
| 23 | + | ||
| 24 | + @NotBlank | ||
| 25 | + @Size(max = 50) | ||
| 26 | + private String sManageDeptEn; | ||
| 27 | + | ||
| 28 | + /** 可空,service 层 default false */ | ||
| 29 | + private Boolean bShowPermission; | ||
| 30 | + | ||
| 31 | + @NotBlank | ||
| 32 | + @Size(max = 100) | ||
| 33 | + private String sModuleNameZh; | ||
| 34 | + | ||
| 35 | + /** 可空 */ | ||
| 36 | + private Integer iParentId; | ||
| 37 | + | ||
| 38 | + @Min(0) | ||
| 39 | + private Integer iSortOrder; | ||
| 40 | +} |
backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.Size; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +/** REQ-MOD-004 模块查询参数 DTO */ | ||
| 7 | +@Data | ||
| 8 | +public class ModuleQueryDTO { | ||
| 9 | + | ||
| 10 | + /** 可空:对 sModuleNameZh 模糊匹配;空字符串视为不过滤 */ | ||
| 11 | + @Size(max = 50, message = "keyword 长度不能超过 50") | ||
| 12 | + private String keyword; | ||
| 13 | +} |
backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.Min; | ||
| 4 | +import jakarta.validation.constraints.NotBlank; | ||
| 5 | +import jakarta.validation.constraints.Pattern; | ||
| 6 | +import jakarta.validation.constraints.Size; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * REQ-MOD-002 模块修改入参。 | ||
| 11 | + * 与 {@link ModuleCreateDTO} 相比剥除了 sProcedureName(不可改);其余 7 个字段规则一致。 | ||
| 12 | + */ | ||
| 13 | +@Data | ||
| 14 | +public class ModuleUpdateDTO { | ||
| 15 | + | ||
| 16 | + @NotBlank | ||
| 17 | + @Pattern(regexp = "^(手机端|前端业务|系统配置|接口)$", message = "sDisplayType 必须是 手机端/前端业务/系统配置/接口 之一") | ||
| 18 | + private String sDisplayType; | ||
| 19 | + | ||
| 20 | + @NotBlank | ||
| 21 | + @Size(max = 50) | ||
| 22 | + private String sModuleType; | ||
| 23 | + | ||
| 24 | + @NotBlank | ||
| 25 | + @Size(max = 50) | ||
| 26 | + private String sManageDeptEn; | ||
| 27 | + | ||
| 28 | + /** 可空:null 表示保持原值 */ | ||
| 29 | + private Boolean bShowPermission; | ||
| 30 | + | ||
| 31 | + @NotBlank | ||
| 32 | + @Size(max = 100) | ||
| 33 | + private String sModuleNameZh; | ||
| 34 | + | ||
| 35 | + /** 可空:null 表示设为根模块 */ | ||
| 36 | + private Integer iParentId; | ||
| 37 | + | ||
| 38 | + /** 可空:null 表示保持原值 */ | ||
| 39 | + @Min(0) | ||
| 40 | + private Integer iSortOrder; | ||
| 41 | +} |
backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.FieldStrategy; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 7 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 8 | +import lombok.Data; | ||
| 9 | + | ||
| 10 | +import java.time.LocalDateTime; | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * 业务模块定义。表 {@code tModule}(详见 docs/03-数据库设计文档.md § tModule)。 | ||
| 14 | + * | ||
| 15 | + * <p>字段名沿用 docs/03 的匈牙利前缀命名(i/s/t/b),保持 schema 与 Java 字段一一对应, | ||
| 16 | + * 避免双向映射歧义。@TableField 显式声明列名以兼容 MyBatis-Plus 默认的下划线转换关闭场景。</p> | ||
| 17 | + */ | ||
| 18 | +@Data | ||
| 19 | +@TableName("tModule") | ||
| 20 | +public class ModuleEntity { | ||
| 21 | + | ||
| 22 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 23 | + private Integer iIncrement; | ||
| 24 | + | ||
| 25 | + @TableField("sId") | ||
| 26 | + private String sId; | ||
| 27 | + | ||
| 28 | + @TableField("sBrandsId") | ||
| 29 | + private String sBrandsId; | ||
| 30 | + | ||
| 31 | + @TableField("sSubsidiaryId") | ||
| 32 | + private String sSubsidiaryId; | ||
| 33 | + | ||
| 34 | + @TableField("tCreateDate") | ||
| 35 | + private LocalDateTime tCreateDate; | ||
| 36 | + | ||
| 37 | + @TableField("sDisplayType") | ||
| 38 | + private String sDisplayType; | ||
| 39 | + | ||
| 40 | + @TableField("sProcedureName") | ||
| 41 | + private String sProcedureName; | ||
| 42 | + | ||
| 43 | + @TableField("sModuleType") | ||
| 44 | + private String sModuleType; | ||
| 45 | + | ||
| 46 | + @TableField("sManageDeptEn") | ||
| 47 | + private String sManageDeptEn; | ||
| 48 | + | ||
| 49 | + @TableField("bShowPermission") | ||
| 50 | + private Boolean bShowPermission; | ||
| 51 | + | ||
| 52 | + @TableField("sModuleNameZh") | ||
| 53 | + private String sModuleNameZh; | ||
| 54 | + | ||
| 55 | + /** REQ-MOD-002 允许更新为 null(清空父模块),用 IGNORED 让 MP updateById 把 NULL 写入 SQL。 */ | ||
| 56 | + @TableField(value = "iParentId", updateStrategy = FieldStrategy.IGNORED) | ||
| 57 | + private Integer iParentId; | ||
| 58 | + | ||
| 59 | + @TableField("iSortOrder") | ||
| 60 | + private Integer iSortOrder; | ||
| 61 | + | ||
| 62 | + @TableField("sCreatedBy") | ||
| 63 | + private String sCreatedBy; | ||
| 64 | + | ||
| 65 | + @TableField("bDeleted") | ||
| 66 | + private Boolean bDeleted; | ||
| 67 | + | ||
| 68 | + @TableField("tDeletedDate") | ||
| 69 | + private LocalDateTime tDeletedDate; | ||
| 70 | + | ||
| 71 | + @TableField("sDeletedBy") | ||
| 72 | + private String sDeletedBy; | ||
| 73 | +} |
backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java
0 → 100644
backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.mod.dto.ModuleCreateDTO; | ||
| 4 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 5 | +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | ||
| 6 | +import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | ||
| 7 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 8 | +import com.xly.erp.module.mod.vo.ModuleVO; | ||
| 9 | + | ||
| 10 | +import java.util.List; | ||
| 11 | + | ||
| 12 | +public interface ModuleService { | ||
| 13 | + /** REQ-MOD-001 模块新增 */ | ||
| 14 | + ModuleVO create(ModuleCreateDTO dto); | ||
| 15 | + | ||
| 16 | + /** REQ-MOD-002 模块修改 */ | ||
| 17 | + ModuleVO update(Integer id, ModuleUpdateDTO dto); | ||
| 18 | + | ||
| 19 | + /** REQ-MOD-003 模块软删除 */ | ||
| 20 | + ModuleDeleteResultVO delete(Integer id); | ||
| 21 | + | ||
| 22 | + /** REQ-MOD-004 模块树查询(可选 keyword 模糊匹配 + 祖先链) */ | ||
| 23 | + List<ModuleTreeNodeVO> tree(ModuleQueryDTO query); | ||
| 24 | +} |
backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service.impl; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | ||
| 4 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | ||
| 5 | +import com.xly.erp.common.exception.BizException; | ||
| 6 | +import com.xly.erp.common.response.ErrorCode; | ||
| 7 | +import com.xly.erp.module.mod.dto.ModuleCreateDTO; | ||
| 8 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 9 | +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | ||
| 10 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 11 | +import com.xly.erp.module.mod.mapper.ModuleMapper; | ||
| 12 | +import com.xly.erp.module.mod.service.ModuleService; | ||
| 13 | +import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | ||
| 14 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 15 | +import com.xly.erp.module.mod.vo.ModuleVO; | ||
| 16 | +import lombok.RequiredArgsConstructor; | ||
| 17 | +import org.springframework.dao.DuplicateKeyException; | ||
| 18 | +import org.springframework.stereotype.Service; | ||
| 19 | +import org.springframework.transaction.annotation.Transactional; | ||
| 20 | + | ||
| 21 | +import java.time.LocalDateTime; | ||
| 22 | +import java.util.ArrayList; | ||
| 23 | +import java.util.Comparator; | ||
| 24 | +import java.util.HashMap; | ||
| 25 | +import java.util.HashSet; | ||
| 26 | +import java.util.List; | ||
| 27 | +import java.util.Map; | ||
| 28 | +import java.util.Set; | ||
| 29 | +import java.util.stream.Collectors; | ||
| 30 | + | ||
| 31 | +@Service | ||
| 32 | +@RequiredArgsConstructor | ||
| 33 | +public class ModuleServiceImpl implements ModuleService { | ||
| 34 | + | ||
| 35 | + private final ModuleMapper moduleMapper; | ||
| 36 | + | ||
| 37 | + @Override | ||
| 38 | + @Transactional(rollbackFor = Exception.class) | ||
| 39 | + public ModuleVO create(ModuleCreateDTO dto) { | ||
| 40 | + // 1. 父模块校验(仅当 iParentId 非空) | ||
| 41 | + if (dto.getIParentId() != null) { | ||
| 42 | + ModuleEntity parent = moduleMapper.selectById(dto.getIParentId()); | ||
| 43 | + if (parent == null || Boolean.TRUE.equals(parent.getBDeleted())) { | ||
| 44 | + throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + // 2. sProcedureName 唯一性预检(未软删除范围) | ||
| 49 | + Long exist = moduleMapper.selectCount( | ||
| 50 | + new LambdaQueryWrapper<ModuleEntity>() | ||
| 51 | + .eq(ModuleEntity::getSProcedureName, dto.getSProcedureName()) | ||
| 52 | + .eq(ModuleEntity::getBDeleted, false)); | ||
| 53 | + if (exist != null && exist > 0L) { | ||
| 54 | + throw new BizException(ErrorCode.MOD_PROC_NAME_DUP); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + // 3. 构造 entity | ||
| 58 | + ModuleEntity e = new ModuleEntity(); | ||
| 59 | + e.setSDisplayType(dto.getSDisplayType()); | ||
| 60 | + e.setSProcedureName(dto.getSProcedureName()); | ||
| 61 | + e.setSModuleType(dto.getSModuleType()); | ||
| 62 | + e.setSManageDeptEn(dto.getSManageDeptEn()); | ||
| 63 | + e.setBShowPermission(dto.getBShowPermission() != null ? dto.getBShowPermission() : Boolean.FALSE); | ||
| 64 | + e.setSModuleNameZh(dto.getSModuleNameZh()); | ||
| 65 | + e.setIParentId(dto.getIParentId()); | ||
| 66 | + e.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0); | ||
| 67 | + e.setTCreateDate(LocalDateTime.now()); | ||
| 68 | + e.setBDeleted(Boolean.FALSE); | ||
| 69 | + // sId / sBrandsId / sSubsidiaryId / sCreatedBy / tDeletedDate / sDeletedBy 留 null(REQ-USR-004 后由登录上下文 / 多租户上下文回填) | ||
| 70 | + | ||
| 71 | + // 4. 插入;并发下唯一约束兜底 | ||
| 72 | + try { | ||
| 73 | + moduleMapper.insert(e); | ||
| 74 | + } catch (DuplicateKeyException dup) { | ||
| 75 | + throw new BizException(ErrorCode.MOD_PROC_NAME_DUP); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + // 5. 返回 VO | ||
| 79 | + return ModuleVO.from(e); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + /** REQ-MOD-002 模块修改 */ | ||
| 83 | + @Override | ||
| 84 | + @Transactional(rollbackFor = Exception.class) | ||
| 85 | + public ModuleVO update(Integer id, ModuleUpdateDTO dto) { | ||
| 86 | + // 1. 目标模块校验 | ||
| 87 | + ModuleEntity target = moduleMapper.selectById(id); | ||
| 88 | + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) { | ||
| 89 | + throw new BizException(ErrorCode.MOD_NOT_FOUND); | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // 2. iParentId 校验 | ||
| 93 | + Integer newParentId = dto.getIParentId(); | ||
| 94 | + if (newParentId != null) { | ||
| 95 | + // 2a. 自引用 | ||
| 96 | + if (newParentId.equals(id)) { | ||
| 97 | + throw new BizException(ErrorCode.MOD_PARENT_LOOP); | ||
| 98 | + } | ||
| 99 | + // 2b. 父存在性 | ||
| 100 | + ModuleEntity parent = moduleMapper.selectById(newParentId); | ||
| 101 | + if (parent == null || Boolean.TRUE.equals(parent.getBDeleted())) { | ||
| 102 | + throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); | ||
| 103 | + } | ||
| 104 | + // 2c. 环路检查:从 newParentId 沿父链 walk up,最多 5 层;遇到 id 即环路 | ||
| 105 | + ModuleEntity cur = parent; | ||
| 106 | + int depth = 1; | ||
| 107 | + while (cur != null && cur.getIParentId() != null && depth <= 5) { | ||
| 108 | + if (cur.getIParentId().equals(id)) { | ||
| 109 | + throw new BizException(ErrorCode.MOD_PARENT_LOOP); | ||
| 110 | + } | ||
| 111 | + ModuleEntity next = moduleMapper.selectById(cur.getIParentId()); | ||
| 112 | + if (next == null || Boolean.TRUE.equals(next.getBDeleted())) { | ||
| 113 | + break; | ||
| 114 | + } | ||
| 115 | + cur = next; | ||
| 116 | + depth++; | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + // 3. 字段合并到 target(sProcedureName / iIncrement / tCreateDate / sCreatedBy / 多租户字段 / bDeleted 等保留原值) | ||
| 121 | + target.setSDisplayType(dto.getSDisplayType()); | ||
| 122 | + target.setSModuleType(dto.getSModuleType()); | ||
| 123 | + target.setSManageDeptEn(dto.getSManageDeptEn()); | ||
| 124 | + target.setSModuleNameZh(dto.getSModuleNameZh()); | ||
| 125 | + if (dto.getBShowPermission() != null) { | ||
| 126 | + target.setBShowPermission(dto.getBShowPermission()); | ||
| 127 | + } | ||
| 128 | + target.setIParentId(dto.getIParentId()); // null 设根 | ||
| 129 | + if (dto.getISortOrder() != null) { | ||
| 130 | + target.setISortOrder(dto.getISortOrder()); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + // 4. 落库 | ||
| 134 | + moduleMapper.updateById(target); | ||
| 135 | + | ||
| 136 | + // 5. 返回 VO | ||
| 137 | + return ModuleVO.from(target); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + /** REQ-MOD-003 模块软删除 */ | ||
| 141 | + @Override | ||
| 142 | + @Transactional(rollbackFor = Exception.class) | ||
| 143 | + public ModuleDeleteResultVO delete(Integer id) { | ||
| 144 | + // 1. 目标存在 + 未软删除 | ||
| 145 | + ModuleEntity target = moduleMapper.selectById(id); | ||
| 146 | + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) { | ||
| 147 | + throw new BizException(ErrorCode.MOD_NOT_FOUND); | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + // 2. 子模块(未软删除)引用检查 | ||
| 151 | + Long childCount = moduleMapper.selectCount( | ||
| 152 | + new LambdaQueryWrapper<ModuleEntity>() | ||
| 153 | + .eq(ModuleEntity::getIParentId, id) | ||
| 154 | + .eq(ModuleEntity::getBDeleted, false)); | ||
| 155 | + if (childCount != null && childCount > 0L) { | ||
| 156 | + throw new BizException(ErrorCode.MOD_HAS_REFERENCES); | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + // 3. 软删除写入:用 LambdaUpdateWrapper.set 显式声明 SET 列,避免 entity 驱动 SET | ||
| 160 | + // (iParentId 字段的 FieldStrategy.IGNORED 策略会让 entity-driven update 把 | ||
| 161 | + // iParentId 写成 NULL,破坏父引用——这里用 wrapper.set 完全绕开 entity) | ||
| 162 | + // sDeletedBy 暂写 null(REQ-USR-004 后由登录上下文回填)。 | ||
| 163 | + // eq(bDeleted, false) 兜底并发:影响 0 行视为已被并发删除 → 40421 | ||
| 164 | + LambdaUpdateWrapper<ModuleEntity> uw = new LambdaUpdateWrapper<ModuleEntity>() | ||
| 165 | + .eq(ModuleEntity::getIIncrement, id) | ||
| 166 | + .eq(ModuleEntity::getBDeleted, false) | ||
| 167 | + .set(ModuleEntity::getBDeleted, true) | ||
| 168 | + .set(ModuleEntity::getTDeletedDate, LocalDateTime.now()) | ||
| 169 | + .set(ModuleEntity::getSDeletedBy, null); | ||
| 170 | + | ||
| 171 | + int affected = moduleMapper.update(null, uw); | ||
| 172 | + if (affected == 0) { | ||
| 173 | + throw new BizException(ErrorCode.MOD_NOT_FOUND); | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + return ModuleDeleteResultVO.of(id, true); | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + /** REQ-MOD-004 模块树查询 */ | ||
| 180 | + @Override | ||
| 181 | + @Transactional(readOnly = true) | ||
| 182 | + public List<ModuleTreeNodeVO> tree(ModuleQueryDTO query) { | ||
| 183 | + // 1. 拉取所有未软删除模块 | ||
| 184 | + List<ModuleEntity> all = moduleMapper.selectList( | ||
| 185 | + new LambdaQueryWrapper<ModuleEntity>().eq(ModuleEntity::getBDeleted, false)); | ||
| 186 | + if (all.isEmpty()) { | ||
| 187 | + return new ArrayList<>(); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + // 2. id → entity 索引 | ||
| 191 | + Map<Integer, ModuleEntity> byId = all.stream() | ||
| 192 | + .collect(Collectors.toMap(ModuleEntity::getIIncrement, e -> e)); | ||
| 193 | + | ||
| 194 | + // 3. 计算 survivors 集合 | ||
| 195 | + String keyword = query == null ? null : query.getKeyword(); | ||
| 196 | + Set<Integer> survivorIds; | ||
| 197 | + if (keyword == null || keyword.isEmpty()) { | ||
| 198 | + survivorIds = byId.keySet(); | ||
| 199 | + } else { | ||
| 200 | + survivorIds = new HashSet<>(); | ||
| 201 | + // 命中节点 | ||
| 202 | + List<ModuleEntity> hits = all.stream() | ||
| 203 | + .filter(e -> e.getSModuleNameZh() != null && e.getSModuleNameZh().contains(keyword)) | ||
| 204 | + .toList(); | ||
| 205 | + for (ModuleEntity hit : hits) { | ||
| 206 | + survivorIds.add(hit.getIIncrement()); | ||
| 207 | + // 沿父链向上收集祖先(深度上限 5) | ||
| 208 | + Integer parentId = hit.getIParentId(); | ||
| 209 | + int depth = 0; | ||
| 210 | + while (parentId != null && depth < 5) { | ||
| 211 | + if (!survivorIds.add(parentId)) { | ||
| 212 | + break; // 已存在,提前结束避免循环 | ||
| 213 | + } | ||
| 214 | + ModuleEntity parent = byId.get(parentId); | ||
| 215 | + if (parent == null) break; | ||
| 216 | + parentId = parent.getIParentId(); | ||
| 217 | + depth++; | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + if (survivorIds.isEmpty()) { | ||
| 221 | + return new ArrayList<>(); | ||
| 222 | + } | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + // 4. 转 VO + 按 (iSortOrder, iIncrement) 排序 | ||
| 226 | + Comparator<ModuleTreeNodeVO> cmp = Comparator | ||
| 227 | + .comparingInt((ModuleTreeNodeVO v) -> v.getISortOrder() == null ? 0 : v.getISortOrder()) | ||
| 228 | + .thenComparingInt(ModuleTreeNodeVO::getIIncrement); | ||
| 229 | + | ||
| 230 | + Map<Integer, ModuleTreeNodeVO> nodeById = new HashMap<>(); | ||
| 231 | + for (Integer id : survivorIds) { | ||
| 232 | + ModuleEntity e = byId.get(id); | ||
| 233 | + if (e != null) { | ||
| 234 | + nodeById.put(id, ModuleTreeNodeVO.from(e)); | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + // 5. 挂 children + 收集 roots | ||
| 239 | + List<ModuleTreeNodeVO> roots = new ArrayList<>(); | ||
| 240 | + for (ModuleTreeNodeVO node : nodeById.values()) { | ||
| 241 | + Integer pid = node.getIParentId(); | ||
| 242 | + if (pid == null || !nodeById.containsKey(pid)) { | ||
| 243 | + // 父不存在于 survivors(被过滤掉或本身就是根)→ 提为根 | ||
| 244 | + roots.add(node); | ||
| 245 | + } else { | ||
| 246 | + nodeById.get(pid).getChildren().add(node); | ||
| 247 | + } | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + // 6. 排序:根节点 + 每个节点的 children | ||
| 251 | + roots.sort(cmp); | ||
| 252 | + for (ModuleTreeNodeVO node : nodeById.values()) { | ||
| 253 | + node.getChildren().sort(cmp); | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + return roots; | ||
| 257 | + } | ||
| 258 | +} |
backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.vo; | ||
| 2 | + | ||
| 3 | +import lombok.Data; | ||
| 4 | + | ||
| 5 | +/** REQ-MOD-003 模块删除返回精简 VO(仅含主键 + 删除标记) */ | ||
| 6 | +@Data | ||
| 7 | +public class ModuleDeleteResultVO { | ||
| 8 | + private Integer iIncrement; | ||
| 9 | + private Boolean bDeleted; | ||
| 10 | + | ||
| 11 | + public static ModuleDeleteResultVO of(Integer id, Boolean deleted) { | ||
| 12 | + ModuleDeleteResultVO v = new ModuleDeleteResultVO(); | ||
| 13 | + v.setIIncrement(id); | ||
| 14 | + v.setBDeleted(deleted); | ||
| 15 | + return v; | ||
| 16 | + } | ||
| 17 | +} |
backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.vo; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import java.util.ArrayList; | ||
| 7 | +import java.util.List; | ||
| 8 | + | ||
| 9 | +/** REQ-MOD-004 模块树节点 VO(不暴露内部字段) */ | ||
| 10 | +@Data | ||
| 11 | +public class ModuleTreeNodeVO { | ||
| 12 | + private Integer iIncrement; | ||
| 13 | + private String sModuleNameZh; | ||
| 14 | + private String sDisplayType; | ||
| 15 | + private String sManageDeptEn; | ||
| 16 | + private Integer iParentId; | ||
| 17 | + private Integer iSortOrder; | ||
| 18 | + private List<ModuleTreeNodeVO> children = new ArrayList<>(); | ||
| 19 | + | ||
| 20 | + public static ModuleTreeNodeVO from(ModuleEntity e) { | ||
| 21 | + ModuleTreeNodeVO v = new ModuleTreeNodeVO(); | ||
| 22 | + v.setIIncrement(e.getIIncrement()); | ||
| 23 | + v.setSModuleNameZh(e.getSModuleNameZh()); | ||
| 24 | + v.setSDisplayType(e.getSDisplayType()); | ||
| 25 | + v.setSManageDeptEn(e.getSManageDeptEn()); | ||
| 26 | + v.setIParentId(e.getIParentId()); | ||
| 27 | + v.setISortOrder(e.getISortOrder()); | ||
| 28 | + v.setChildren(new ArrayList<>()); | ||
| 29 | + return v; | ||
| 30 | + } | ||
| 31 | +} |
backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.vo; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import java.time.LocalDateTime; | ||
| 7 | + | ||
| 8 | +@Data | ||
| 9 | +public class ModuleVO { | ||
| 10 | + private Integer iIncrement; | ||
| 11 | + private String sDisplayType; | ||
| 12 | + private String sProcedureName; | ||
| 13 | + private String sModuleType; | ||
| 14 | + private String sManageDeptEn; | ||
| 15 | + private Boolean bShowPermission; | ||
| 16 | + private String sModuleNameZh; | ||
| 17 | + private Integer iParentId; | ||
| 18 | + private Integer iSortOrder; | ||
| 19 | + private LocalDateTime tCreateDate; | ||
| 20 | + private Boolean bDeleted; | ||
| 21 | + | ||
| 22 | + public static ModuleVO from(ModuleEntity e) { | ||
| 23 | + ModuleVO v = new ModuleVO(); | ||
| 24 | + v.setIIncrement(e.getIIncrement()); | ||
| 25 | + v.setSDisplayType(e.getSDisplayType()); | ||
| 26 | + v.setSProcedureName(e.getSProcedureName()); | ||
| 27 | + v.setSModuleType(e.getSModuleType()); | ||
| 28 | + v.setSManageDeptEn(e.getSManageDeptEn()); | ||
| 29 | + v.setBShowPermission(e.getBShowPermission()); | ||
| 30 | + v.setSModuleNameZh(e.getSModuleNameZh()); | ||
| 31 | + v.setIParentId(e.getIParentId()); | ||
| 32 | + v.setISortOrder(e.getISortOrder()); | ||
| 33 | + v.setTCreateDate(e.getTCreateDate()); | ||
| 34 | + v.setBDeleted(e.getBDeleted()); | ||
| 35 | + return v; | ||
| 36 | + } | ||
| 37 | +} |
backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ApiResponse; | ||
| 4 | +import com.xly.erp.module.usr.dto.LoginDTO; | ||
| 5 | +import com.xly.erp.module.usr.service.LoginService; | ||
| 6 | +import com.xly.erp.module.usr.vo.LoginResultVO; | ||
| 7 | +import jakarta.validation.Valid; | ||
| 8 | +import lombok.RequiredArgsConstructor; | ||
| 9 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 11 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 12 | +import org.springframework.web.bind.annotation.RestController; | ||
| 13 | + | ||
| 14 | +/** REQ-USR-004 用户登录入口 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成。 */ | ||
| 15 | +@RestController | ||
| 16 | +@RequestMapping("/api/auth") | ||
| 17 | +@RequiredArgsConstructor | ||
| 18 | +public class LoginController { | ||
| 19 | + | ||
| 20 | + private final LoginService loginService; | ||
| 21 | + | ||
| 22 | + @PostMapping("/login") | ||
| 23 | + public ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto) { | ||
| 24 | + return ApiResponse.ok(loginService.login(dto)); | ||
| 25 | + } | ||
| 26 | +} |
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ApiResponse; | ||
| 4 | +import com.xly.erp.common.response.PageResult; | ||
| 5 | +import com.xly.erp.module.usr.dto.UserCreateDTO; | ||
| 6 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 7 | +import com.xly.erp.module.usr.dto.UserUpdateDTO; | ||
| 8 | +import com.xly.erp.module.usr.service.UserService; | ||
| 9 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 10 | +import com.xly.erp.module.usr.vo.UserVO; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.RequiredArgsConstructor; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.PathVariable; | ||
| 15 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 16 | +import org.springframework.web.bind.annotation.PutMapping; | ||
| 17 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 18 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 19 | +import org.springframework.web.bind.annotation.RestController; | ||
| 20 | + | ||
| 21 | +@RestController | ||
| 22 | +@RequestMapping("/api/users") | ||
| 23 | +@RequiredArgsConstructor | ||
| 24 | +public class UserController { | ||
| 25 | + | ||
| 26 | + private final UserService userService; | ||
| 27 | + | ||
| 28 | + /** REQ-USR-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')") */ | ||
| 29 | + @PostMapping | ||
| 30 | + public ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto) { | ||
| 31 | + return ApiResponse.ok(userService.create(dto)); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + /** REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')") */ | ||
| 35 | + @PutMapping("/{id}") | ||
| 36 | + public ApiResponse<UserVO> update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto) { | ||
| 37 | + return ApiResponse.ok(userService.update(id, dto)); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + /** REQ-USR-003 用户列表查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')") */ | ||
| 41 | + @GetMapping | ||
| 42 | + public ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query) { | ||
| 43 | + return ApiResponse.ok(userService.search(query)); | ||
| 44 | + } | ||
| 45 | +} |
backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.NotBlank; | ||
| 4 | +import jakarta.validation.constraints.Pattern; | ||
| 5 | +import jakarta.validation.constraints.Size; | ||
| 6 | +import lombok.Data; | ||
| 7 | + | ||
| 8 | +/** REQ-USR-004 用户登录入参 */ | ||
| 9 | +@Data | ||
| 10 | +public class LoginDTO { | ||
| 11 | + | ||
| 12 | + @NotBlank | ||
| 13 | + @Size(max = 50) | ||
| 14 | + private String sUserName; | ||
| 15 | + | ||
| 16 | + @NotBlank | ||
| 17 | + @Size(max = 100) | ||
| 18 | + private String sPassword; | ||
| 19 | + | ||
| 20 | + @NotBlank | ||
| 21 | + @Pattern(regexp = "^standard$", message = "sVersion 仅支持 standard") | ||
| 22 | + private String sVersion; | ||
| 23 | +} |
backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.NotBlank; | ||
| 4 | +import jakarta.validation.constraints.Pattern; | ||
| 5 | +import jakarta.validation.constraints.Size; | ||
| 6 | +import lombok.Data; | ||
| 7 | + | ||
| 8 | +import java.util.List; | ||
| 9 | + | ||
| 10 | +/** REQ-USR-001 用户新增入参(密码不在 DTO 里,service 层固定哈希 666666 落库)。 */ | ||
| 11 | +@Data | ||
| 12 | +public class UserCreateDTO { | ||
| 13 | + | ||
| 14 | + @NotBlank | ||
| 15 | + @Size(max = 50) | ||
| 16 | + private String sUserNo; | ||
| 17 | + | ||
| 18 | + @NotBlank | ||
| 19 | + @Size(max = 50) | ||
| 20 | + private String sUserName; | ||
| 21 | + | ||
| 22 | + /** 可空:关联职员 id */ | ||
| 23 | + private Integer iStaffId; | ||
| 24 | + | ||
| 25 | + @NotBlank | ||
| 26 | + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一") | ||
| 27 | + private String sUserType; | ||
| 28 | + | ||
| 29 | + @NotBlank | ||
| 30 | + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一") | ||
| 31 | + private String sLanguage; | ||
| 32 | + | ||
| 33 | + /** 可空:默认 false */ | ||
| 34 | + private Boolean bCanModifyDocs; | ||
| 35 | + | ||
| 36 | + /** 可空:每个 id 必须指向未删除的 tPermissionCategory.iIncrement */ | ||
| 37 | + private List<Integer> permissionCategoryIds; | ||
| 38 | +} |
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.Max; | ||
| 4 | +import jakarta.validation.constraints.Min; | ||
| 5 | +import jakarta.validation.constraints.Pattern; | ||
| 6 | +import jakarta.validation.constraints.Size; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +/** REQ-USR-003 用户查询参数 DTO(query string 绑定)。 */ | ||
| 10 | +@Data | ||
| 11 | +public class UserQueryDTO { | ||
| 12 | + | ||
| 13 | + @Min(1) | ||
| 14 | + private Integer pageNum = 1; | ||
| 15 | + | ||
| 16 | + @Min(1) | ||
| 17 | + @Max(100) | ||
| 18 | + private Integer pageSize = 20; | ||
| 19 | + | ||
| 20 | + /** 可空:缺省视为不过滤;服务层白名单映射为 SQL 列名后通过 mapper @Param 单独传入 */ | ||
| 21 | + @Pattern(regexp = "^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$", | ||
| 22 | + message = "queryField 非法") | ||
| 23 | + private String queryField; | ||
| 24 | + | ||
| 25 | + /** 可空:默认 contains */ | ||
| 26 | + @Pattern(regexp = "^(contains|notContains|equals)?$", message = "matchType 非法") | ||
| 27 | + private String matchType; | ||
| 28 | + | ||
| 29 | + /** 可空:缺省视为不过滤 */ | ||
| 30 | + @Size(max = 100) | ||
| 31 | + private String queryValue; | ||
| 32 | +} |
backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.constraints.NotBlank; | ||
| 4 | +import jakarta.validation.constraints.Pattern; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.util.List; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * REQ-USR-002 用户修改入参。 | ||
| 11 | + * 与 {@link UserCreateDTO} 相比剥除 sUserNo / sUserName(登录身份不可改); | ||
| 12 | + * 密码不通过本接口修改,亦不在 DTO 里。 | ||
| 13 | + */ | ||
| 14 | +@Data | ||
| 15 | +public class UserUpdateDTO { | ||
| 16 | + | ||
| 17 | + /** 可空:null 表示清空员工关联(service 层借 iStaffId.IGNORED 策略写入 NULL) */ | ||
| 18 | + private Integer iStaffId; | ||
| 19 | + | ||
| 20 | + @NotBlank | ||
| 21 | + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "sUserType 必须是 普通用户/超级管理员 之一") | ||
| 22 | + private String sUserType; | ||
| 23 | + | ||
| 24 | + @NotBlank | ||
| 25 | + @Pattern(regexp = "^(zh|en|zh-TW)$", message = "sLanguage 必须是 zh/en/zh-TW 之一") | ||
| 26 | + private String sLanguage; | ||
| 27 | + | ||
| 28 | + /** 可空:null 表示保持原值;显式覆盖 */ | ||
| 29 | + private Boolean bCanModifyDocs; | ||
| 30 | + | ||
| 31 | + /** 可空:每元素须存在且未软删除;空数组 / null 都视为清空全部授权关联 */ | ||
| 32 | + private List<Integer> permissionCategoryIds; | ||
| 33 | +} |
backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +/** REQ-USR-001 引入。表 tPermissionCategory(详见 docs/03 § tPermissionCategory)。 */ | ||
| 12 | +@Data | ||
| 13 | +@TableName("tPermissionCategory") | ||
| 14 | +public class PermissionCategoryEntity { | ||
| 15 | + | ||
| 16 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 17 | + private Integer iIncrement; | ||
| 18 | + | ||
| 19 | + @TableField("sId") | ||
| 20 | + private String sId; | ||
| 21 | + | ||
| 22 | + @TableField("sBrandsId") | ||
| 23 | + private String sBrandsId; | ||
| 24 | + | ||
| 25 | + @TableField("sSubsidiaryId") | ||
| 26 | + private String sSubsidiaryId; | ||
| 27 | + | ||
| 28 | + @TableField("tCreateDate") | ||
| 29 | + private LocalDateTime tCreateDate; | ||
| 30 | + | ||
| 31 | + @TableField("sCategoryCode") | ||
| 32 | + private String sCategoryCode; | ||
| 33 | + | ||
| 34 | + @TableField("sCategoryName") | ||
| 35 | + private String sCategoryName; | ||
| 36 | + | ||
| 37 | + @TableField("iParentId") | ||
| 38 | + private Integer iParentId; | ||
| 39 | + | ||
| 40 | + @TableField("iSortOrder") | ||
| 41 | + private Integer iSortOrder; | ||
| 42 | + | ||
| 43 | + @TableField("sCreatedBy") | ||
| 44 | + private String sCreatedBy; | ||
| 45 | + | ||
| 46 | + @TableField("bDeleted") | ||
| 47 | + private Boolean bDeleted; | ||
| 48 | + | ||
| 49 | + @TableField("tDeletedDate") | ||
| 50 | + private LocalDateTime tDeletedDate; | ||
| 51 | + | ||
| 52 | + @TableField("sDeletedBy") | ||
| 53 | + private String sDeletedBy; | ||
| 54 | +} |
backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +/** REQ-USR-001 引入。表 tStaff(详见 docs/03 § tStaff)。 */ | ||
| 12 | +@Data | ||
| 13 | +@TableName("tStaff") | ||
| 14 | +public class StaffEntity { | ||
| 15 | + | ||
| 16 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 17 | + private Integer iIncrement; | ||
| 18 | + | ||
| 19 | + @TableField("sId") | ||
| 20 | + private String sId; | ||
| 21 | + | ||
| 22 | + @TableField("sBrandsId") | ||
| 23 | + private String sBrandsId; | ||
| 24 | + | ||
| 25 | + @TableField("sSubsidiaryId") | ||
| 26 | + private String sSubsidiaryId; | ||
| 27 | + | ||
| 28 | + @TableField("tCreateDate") | ||
| 29 | + private LocalDateTime tCreateDate; | ||
| 30 | + | ||
| 31 | + @TableField("sStaffNo") | ||
| 32 | + private String sStaffNo; | ||
| 33 | + | ||
| 34 | + @TableField("sStaffName") | ||
| 35 | + private String sStaffName; | ||
| 36 | + | ||
| 37 | + @TableField("sDepartment") | ||
| 38 | + private String sDepartment; | ||
| 39 | + | ||
| 40 | + @TableField("sCreatedBy") | ||
| 41 | + private String sCreatedBy; | ||
| 42 | + | ||
| 43 | + @TableField("bDeleted") | ||
| 44 | + private Boolean bDeleted; | ||
| 45 | + | ||
| 46 | + @TableField("tDeletedDate") | ||
| 47 | + private LocalDateTime tDeletedDate; | ||
| 48 | + | ||
| 49 | + @TableField("sDeletedBy") | ||
| 50 | + private String sDeletedBy; | ||
| 51 | +} |
backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.FieldStrategy; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 7 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 8 | +import lombok.Data; | ||
| 9 | + | ||
| 10 | +import java.time.LocalDateTime; | ||
| 11 | + | ||
| 12 | +/** REQ-USR-001 用户主数据。表 tUser(详见 docs/03 § tUser)。 */ | ||
| 13 | +@Data | ||
| 14 | +@TableName("tUser") | ||
| 15 | +public class UserEntity { | ||
| 16 | + | ||
| 17 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 18 | + private Integer iIncrement; | ||
| 19 | + | ||
| 20 | + @TableField("sId") | ||
| 21 | + private String sId; | ||
| 22 | + | ||
| 23 | + @TableField("sBrandsId") | ||
| 24 | + private String sBrandsId; | ||
| 25 | + | ||
| 26 | + @TableField("sSubsidiaryId") | ||
| 27 | + private String sSubsidiaryId; | ||
| 28 | + | ||
| 29 | + @TableField("tCreateDate") | ||
| 30 | + private LocalDateTime tCreateDate; | ||
| 31 | + | ||
| 32 | + @TableField("sUserNo") | ||
| 33 | + private String sUserNo; | ||
| 34 | + | ||
| 35 | + @TableField("sUserName") | ||
| 36 | + private String sUserName; | ||
| 37 | + | ||
| 38 | + /** REQ-USR-002 允许更新为 null(清空员工关联),用 IGNORED 让 MP updateById 把 NULL 写入 SQL。 | ||
| 39 | + * 注意:此策略意味着任何 updateById 都会写 iStaffId;调用方必须 selectById 后再 updateById(load-then-modify)。 */ | ||
| 40 | + @TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED) | ||
| 41 | + private Integer iStaffId; | ||
| 42 | + | ||
| 43 | + @TableField("sUserType") | ||
| 44 | + private String sUserType; | ||
| 45 | + | ||
| 46 | + @TableField("sLanguage") | ||
| 47 | + private String sLanguage; | ||
| 48 | + | ||
| 49 | + @TableField("bCanModifyDocs") | ||
| 50 | + private Boolean bCanModifyDocs; | ||
| 51 | + | ||
| 52 | + @TableField("sPasswordHash") | ||
| 53 | + private String sPasswordHash; | ||
| 54 | + | ||
| 55 | + @TableField("tLastLoginDate") | ||
| 56 | + private LocalDateTime tLastLoginDate; | ||
| 57 | + | ||
| 58 | + @TableField("sCreatedBy") | ||
| 59 | + private String sCreatedBy; | ||
| 60 | + | ||
| 61 | + @TableField("bDeleted") | ||
| 62 | + private Boolean bDeleted; | ||
| 63 | + | ||
| 64 | + @TableField("tDeletedDate") | ||
| 65 | + private LocalDateTime tDeletedDate; | ||
| 66 | + | ||
| 67 | + @TableField("sDeletedBy") | ||
| 68 | + private String sDeletedBy; | ||
| 69 | +} |
backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 6 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +/** REQ-USR-001 引入。表 tUserPermission(详见 docs/03 § tUserPermission)。 */ | ||
| 12 | +@Data | ||
| 13 | +@TableName("tUserPermission") | ||
| 14 | +public class UserPermissionEntity { | ||
| 15 | + | ||
| 16 | + @TableId(value = "iIncrement", type = IdType.AUTO) | ||
| 17 | + private Integer iIncrement; | ||
| 18 | + | ||
| 19 | + @TableField("sId") | ||
| 20 | + private String sId; | ||
| 21 | + | ||
| 22 | + @TableField("sBrandsId") | ||
| 23 | + private String sBrandsId; | ||
| 24 | + | ||
| 25 | + @TableField("sSubsidiaryId") | ||
| 26 | + private String sSubsidiaryId; | ||
| 27 | + | ||
| 28 | + @TableField("tCreateDate") | ||
| 29 | + private LocalDateTime tCreateDate; | ||
| 30 | + | ||
| 31 | + @TableField("iUserId") | ||
| 32 | + private Integer iUserId; | ||
| 33 | + | ||
| 34 | + @TableField("iCategoryId") | ||
| 35 | + private Integer iCategoryId; | ||
| 36 | + | ||
| 37 | + // docs/03 § tUserPermission 修订版无 bSelected 列——关联记录存在即「已选」,无需独立 flag。 | ||
| 38 | + // 早期 REQ-USR-001 spec/plan 草稿曾包含 bSelected,与 SSoT docs/03 不一致;以 docs/03 为准。 | ||
| 39 | + | ||
| 40 | + @TableField("sCreatedBy") | ||
| 41 | + private String sCreatedBy; | ||
| 42 | +} |
backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | ||
| 4 | +import com.baomidou.mybatisplus.core.metadata.IPage; | ||
| 5 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 6 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 7 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 8 | +import org.apache.ibatis.annotations.Param; | ||
| 9 | + | ||
| 10 | +public interface UserMapper extends BaseMapper<UserEntity> { | ||
| 11 | + | ||
| 12 | + /** | ||
| 13 | + * REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。 | ||
| 14 | + * @param column service 层白名单映射后的 SQL 列字符串(如 "u.sUserName");外部输入绝不直接走这里。 | ||
| 15 | + */ | ||
| 16 | + IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, | ||
| 17 | + @Param("query") UserQueryDTO query, | ||
| 18 | + @Param("column") String column); | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.stereotype.Component; | ||
| 4 | + | ||
| 5 | +import java.time.Duration; | ||
| 6 | +import java.time.Instant; | ||
| 7 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 8 | +import java.util.concurrent.ConcurrentMap; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * REQ-USR-004 内存版登录失败计数 / 锁定。 | ||
| 12 | + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。 | ||
| 13 | + */ | ||
| 14 | +@Component | ||
| 15 | +public class InMemoryLoginAttemptStore implements LoginAttemptStore { | ||
| 16 | + | ||
| 17 | + static final int LOCK_THRESHOLD = 5; | ||
| 18 | + static final Duration LOCK_DURATION = Duration.ofMinutes(15); | ||
| 19 | + | ||
| 20 | + private final ConcurrentMap<String, FailRecord> store = new ConcurrentHashMap<>(); | ||
| 21 | + | ||
| 22 | + @Override | ||
| 23 | + public long cooldownSeconds(String username) { | ||
| 24 | + FailRecord r = store.get(username); | ||
| 25 | + if (r == null || r.lockUntil == null) { | ||
| 26 | + return 0L; | ||
| 27 | + } | ||
| 28 | + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond(); | ||
| 29 | + if (remaining <= 0L) { | ||
| 30 | + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0) | ||
| 31 | + store.remove(username); | ||
| 32 | + return 0L; | ||
| 33 | + } | ||
| 34 | + return remaining; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + @Override | ||
| 38 | + public void recordFailure(String username) { | ||
| 39 | + Instant now = Instant.now(); | ||
| 40 | + store.compute(username, (k, prev) -> { | ||
| 41 | + // 锁定到期 → reset 重新起算 | ||
| 42 | + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil)) | ||
| 43 | + ? new FailRecord() | ||
| 44 | + : (prev == null ? new FailRecord() : prev); | ||
| 45 | + r.count++; | ||
| 46 | + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) { | ||
| 47 | + r.lockUntil = now.plus(LOCK_DURATION); | ||
| 48 | + } | ||
| 49 | + return r; | ||
| 50 | + }); | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + @Override | ||
| 54 | + public void clear(String username) { | ||
| 55 | + store.remove(username); | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。 | ||
| 59 | + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */ | ||
| 60 | + public void expireLockForTest(String username) { | ||
| 61 | + store.computeIfPresent(username, (k, r) -> { | ||
| 62 | + r.lockUntil = Instant.now().minusSeconds(1); | ||
| 63 | + return r; | ||
| 64 | + }); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + static class FailRecord { | ||
| 68 | + int count; | ||
| 69 | + Instant lockUntil; | ||
| 70 | + } | ||
| 71 | +} |
backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import io.jsonwebtoken.Claims; | ||
| 4 | +import io.jsonwebtoken.Jwts; | ||
| 5 | +import io.jsonwebtoken.security.Keys; | ||
| 6 | +import org.springframework.beans.factory.annotation.Value; | ||
| 7 | +import org.springframework.stereotype.Component; | ||
| 8 | + | ||
| 9 | +import javax.crypto.SecretKey; | ||
| 10 | +import java.nio.charset.StandardCharsets; | ||
| 11 | +import java.time.Instant; | ||
| 12 | +import java.util.Date; | ||
| 13 | + | ||
| 14 | +/** REQ-USR-004 JWT 签发 / 校验封装。HS256,secret 来自 .env.local JWT_SECRET。 */ | ||
| 15 | +@Component | ||
| 16 | +public class JwtTokenProvider { | ||
| 17 | + | ||
| 18 | + private final SecretKey key; | ||
| 19 | + private final long expiresInSeconds; | ||
| 20 | + | ||
| 21 | + public JwtTokenProvider(@Value("${erp.jwt.secret}") String secret, | ||
| 22 | + @Value("${erp.jwt.expires-in-seconds}") long expiresInSeconds) { | ||
| 23 | + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); | ||
| 24 | + this.expiresInSeconds = expiresInSeconds; | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + public long getExpiresInSeconds() { | ||
| 28 | + return expiresInSeconds; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + public String sign(int uid, String username, String userType) { | ||
| 32 | + Instant now = Instant.now(); | ||
| 33 | + return Jwts.builder() | ||
| 34 | + .subject(username) | ||
| 35 | + .claim("uid", uid) | ||
| 36 | + .claim("type", userType) | ||
| 37 | + .issuedAt(Date.from(now)) | ||
| 38 | + .expiration(Date.from(now.plusSeconds(expiresInSeconds))) | ||
| 39 | + .signWith(key) | ||
| 40 | + .compact(); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + public Claims parse(String token) { | ||
| 44 | + return Jwts.parser() | ||
| 45 | + .verifyWith(key) | ||
| 46 | + .build() | ||
| 47 | + .parseSignedClaims(token) | ||
| 48 | + .getPayload(); | ||
| 49 | + } | ||
| 50 | +} |
backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */ | ||
| 4 | +public interface LoginAttemptStore { | ||
| 5 | + | ||
| 6 | + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ | ||
| 7 | + long cooldownSeconds(String username); | ||
| 8 | + | ||
| 9 | + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ | ||
| 10 | + void recordFailure(String username); | ||
| 11 | + | ||
| 12 | + /** 登录成功清空记录 */ | ||
| 13 | + void clear(String username); | ||
| 14 | +} |
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.PageResult; | ||
| 4 | +import com.xly.erp.module.usr.dto.UserCreateDTO; | ||
| 5 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 6 | +import com.xly.erp.module.usr.dto.UserUpdateDTO; | ||
| 7 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 8 | +import com.xly.erp.module.usr.vo.UserVO; | ||
| 9 | + | ||
| 10 | +public interface UserService { | ||
| 11 | + /** REQ-USR-001 用户新增 */ | ||
| 12 | + UserVO create(UserCreateDTO dto); | ||
| 13 | + | ||
| 14 | + /** REQ-USR-002 用户修改 */ | ||
| 15 | + UserVO update(Integer id, UserUpdateDTO dto); | ||
| 16 | + | ||
| 17 | + /** REQ-USR-003 用户列表查询 */ | ||
| 18 | + PageResult<UserListItemVO> search(UserQueryDTO query); | ||
| 19 | +} |
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | ||
| 4 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | ||
| 5 | +import com.xly.erp.common.exception.AccountLockedException; | ||
| 6 | +import com.xly.erp.common.exception.BizException; | ||
| 7 | +import com.xly.erp.common.response.ErrorCode; | ||
| 8 | +import com.xly.erp.module.usr.dto.LoginDTO; | ||
| 9 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 10 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 11 | +import com.xly.erp.module.usr.security.JwtTokenProvider; | ||
| 12 | +import com.xly.erp.module.usr.security.LoginAttemptStore; | ||
| 13 | +import com.xly.erp.module.usr.service.LoginService; | ||
| 14 | +import com.xly.erp.module.usr.vo.LoginResultVO; | ||
| 15 | +import lombok.RequiredArgsConstructor; | ||
| 16 | +import lombok.extern.slf4j.Slf4j; | ||
| 17 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 18 | +import org.springframework.stereotype.Service; | ||
| 19 | +import org.springframework.transaction.annotation.Transactional; | ||
| 20 | + | ||
| 21 | +import java.time.LocalDateTime; | ||
| 22 | + | ||
| 23 | +/** REQ-USR-004 用户登录 service */ | ||
| 24 | +@Slf4j | ||
| 25 | +@Service | ||
| 26 | +@RequiredArgsConstructor | ||
| 27 | +public class LoginServiceImpl implements LoginService { | ||
| 28 | + | ||
| 29 | + private final UserMapper userMapper; | ||
| 30 | + private final PasswordEncoder passwordEncoder; | ||
| 31 | + private final LoginAttemptStore attemptStore; | ||
| 32 | + private final JwtTokenProvider jwtTokenProvider; | ||
| 33 | + | ||
| 34 | + @Override | ||
| 35 | + @Transactional(rollbackFor = Exception.class) | ||
| 36 | + public LoginResultVO login(LoginDTO dto) { | ||
| 37 | + String username = dto.getSUserName(); | ||
| 38 | + | ||
| 39 | + // 1. 锁定检查 | ||
| 40 | + long cooldown = attemptStore.cooldownSeconds(username); | ||
| 41 | + if (cooldown > 0L) { | ||
| 42 | + log.info("Login locked username={} cooldown={}s", username, cooldown); | ||
| 43 | + throw new AccountLockedException(cooldown); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + // 2. 查用户 | ||
| 47 | + UserEntity user = userMapper.selectOne( | ||
| 48 | + new LambdaQueryWrapper<UserEntity>() | ||
| 49 | + .eq(UserEntity::getSUserName, username) | ||
| 50 | + .eq(UserEntity::getBDeleted, false)); | ||
| 51 | + if (user == null) { | ||
| 52 | + log.info("Login user-not-found username={}", username); | ||
| 53 | + attemptStore.recordFailure(username); | ||
| 54 | + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁) | ||
| 55 | + long cd = attemptStore.cooldownSeconds(username); | ||
| 56 | + if (cd > 0L) { | ||
| 57 | + throw new AccountLockedException(cd); | ||
| 58 | + } | ||
| 59 | + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + // 3. BCrypt 校验 | ||
| 63 | + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) { | ||
| 64 | + log.info("Login bad-password username={}", username); | ||
| 65 | + attemptStore.recordFailure(username); | ||
| 66 | + long cd = attemptStore.cooldownSeconds(username); | ||
| 67 | + if (cd > 0L) { | ||
| 68 | + throw new AccountLockedException(cd); | ||
| 69 | + } | ||
| 70 | + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate | ||
| 74 | + attemptStore.clear(username); | ||
| 75 | + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType()); | ||
| 76 | + userMapper.update(null, | ||
| 77 | + new LambdaUpdateWrapper<UserEntity>() | ||
| 78 | + .eq(UserEntity::getIIncrement, user.getIIncrement()) | ||
| 79 | + .set(UserEntity::getTLastLoginDate, LocalDateTime.now())); | ||
| 80 | + log.info("Login success username={} uid={}", username, user.getIIncrement()); | ||
| 81 | + | ||
| 82 | + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo( | ||
| 83 | + user.getIIncrement(), user.getSUserNo(), user.getSUserName(), | ||
| 84 | + user.getSUserType(), user.getSLanguage()); | ||
| 85 | + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info); | ||
| 86 | + } | ||
| 87 | +} |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | ||
| 4 | +import com.baomidou.mybatisplus.core.metadata.IPage; | ||
| 5 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | ||
| 6 | +import com.xly.erp.common.exception.BizException; | ||
| 7 | +import com.xly.erp.common.response.ErrorCode; | ||
| 8 | +import com.xly.erp.common.response.PageResult; | ||
| 9 | +import com.xly.erp.module.usr.dto.UserCreateDTO; | ||
| 10 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 11 | +import com.xly.erp.module.usr.dto.UserUpdateDTO; | ||
| 12 | +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; | ||
| 13 | +import com.xly.erp.module.usr.entity.StaffEntity; | ||
| 14 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 15 | +import com.xly.erp.module.usr.entity.UserPermissionEntity; | ||
| 16 | +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; | ||
| 17 | +import com.xly.erp.module.usr.mapper.StaffMapper; | ||
| 18 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 19 | +import com.xly.erp.module.usr.mapper.UserPermissionMapper; | ||
| 20 | +import com.xly.erp.module.usr.service.UserService; | ||
| 21 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 22 | +import com.xly.erp.module.usr.vo.UserVO; | ||
| 23 | +import lombok.RequiredArgsConstructor; | ||
| 24 | +import org.springframework.dao.DuplicateKeyException; | ||
| 25 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 26 | +import org.springframework.stereotype.Service; | ||
| 27 | +import org.springframework.transaction.annotation.Transactional; | ||
| 28 | + | ||
| 29 | +import java.time.LocalDateTime; | ||
| 30 | +import java.util.ArrayList; | ||
| 31 | +import java.util.List; | ||
| 32 | +import java.util.Map; | ||
| 33 | +import java.util.Set; | ||
| 34 | + | ||
| 35 | +/** REQ-USR-001 用户新增 */ | ||
| 36 | +@Service | ||
| 37 | +@RequiredArgsConstructor | ||
| 38 | +public class UserServiceImpl implements UserService { | ||
| 39 | + | ||
| 40 | + private static final String INITIAL_PASSWORD = "666666"; | ||
| 41 | + | ||
| 42 | + private final UserMapper userMapper; | ||
| 43 | + private final StaffMapper staffMapper; | ||
| 44 | + private final PermissionCategoryMapper permissionCategoryMapper; | ||
| 45 | + private final UserPermissionMapper userPermissionMapper; | ||
| 46 | + private final PasswordEncoder passwordEncoder; | ||
| 47 | + | ||
| 48 | + @Override | ||
| 49 | + @Transactional(rollbackFor = Exception.class) | ||
| 50 | + public UserVO create(UserCreateDTO dto) { | ||
| 51 | + // 1. 唯一性预检:sUserName / sUserNo(bDeleted=0 范围) | ||
| 52 | + Long existsByName = userMapper.selectCount( | ||
| 53 | + new LambdaQueryWrapper<UserEntity>() | ||
| 54 | + .eq(UserEntity::getSUserName, dto.getSUserName()) | ||
| 55 | + .eq(UserEntity::getBDeleted, false)); | ||
| 56 | + if (existsByName != null && existsByName > 0L) { | ||
| 57 | + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); | ||
| 58 | + } | ||
| 59 | + Long existsByNo = userMapper.selectCount( | ||
| 60 | + new LambdaQueryWrapper<UserEntity>() | ||
| 61 | + .eq(UserEntity::getSUserNo, dto.getSUserNo()) | ||
| 62 | + .eq(UserEntity::getBDeleted, false)); | ||
| 63 | + if (existsByNo != null && existsByNo > 0L) { | ||
| 64 | + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + // 2. iStaffId 校验 | ||
| 68 | + if (dto.getIStaffId() != null) { | ||
| 69 | + StaffEntity staff = staffMapper.selectById(dto.getIStaffId()); | ||
| 70 | + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) { | ||
| 71 | + throw new BizException(ErrorCode.STAFF_NOT_FOUND); | ||
| 72 | + } | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + // 3. 权限分类校验:批量查;要求每个 id 都存在且未软删除 | ||
| 76 | + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null | ||
| 77 | + ? new ArrayList<>() : dto.getPermissionCategoryIds(); | ||
| 78 | + if (!categoryIds.isEmpty()) { | ||
| 79 | + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds); | ||
| 80 | + if (found.size() != categoryIds.size() | ||
| 81 | + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) { | ||
| 82 | + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND); | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + // 4. 构造 UserEntity 并 insert | ||
| 87 | + UserEntity user = new UserEntity(); | ||
| 88 | + user.setSUserNo(dto.getSUserNo()); | ||
| 89 | + user.setSUserName(dto.getSUserName()); | ||
| 90 | + user.setIStaffId(dto.getIStaffId()); | ||
| 91 | + user.setSUserType(dto.getSUserType()); | ||
| 92 | + user.setSLanguage(dto.getSLanguage()); | ||
| 93 | + user.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : Boolean.FALSE); | ||
| 94 | + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD)); | ||
| 95 | + user.setTCreateDate(LocalDateTime.now()); | ||
| 96 | + user.setBDeleted(Boolean.FALSE); | ||
| 97 | + // tLastLoginDate / sCreatedBy / sBrandsId / sSubsidiaryId / sId / tDeletedDate / sDeletedBy 留 null | ||
| 98 | + | ||
| 99 | + try { | ||
| 100 | + userMapper.insert(user); | ||
| 101 | + } catch (DuplicateKeyException dup) { | ||
| 102 | + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + // 5. 批量 insert UserPermission | ||
| 106 | + for (Integer categoryId : categoryIds) { | ||
| 107 | + UserPermissionEntity up = new UserPermissionEntity(); | ||
| 108 | + up.setIUserId(user.getIIncrement()); | ||
| 109 | + up.setICategoryId(categoryId); | ||
| 110 | + up.setTCreateDate(LocalDateTime.now()); | ||
| 111 | + // sCreatedBy 留 null(REQ-USR-004 后回填) | ||
| 112 | + userPermissionMapper.insert(up); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + return UserVO.from(user, categoryIds); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + /** REQ-USR-002 用户修改 */ | ||
| 119 | + @Override | ||
| 120 | + @Transactional(rollbackFor = Exception.class) | ||
| 121 | + public UserVO update(Integer id, UserUpdateDTO dto) { | ||
| 122 | + // 1. 目标用户存在 + 未软删除 | ||
| 123 | + UserEntity target = userMapper.selectById(id); | ||
| 124 | + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) { | ||
| 125 | + throw new BizException(ErrorCode.USR_NOT_FOUND); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + // 2. iStaffId 校验(仅当非空) | ||
| 129 | + if (dto.getIStaffId() != null) { | ||
| 130 | + StaffEntity staff = staffMapper.selectById(dto.getIStaffId()); | ||
| 131 | + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) { | ||
| 132 | + throw new BizException(ErrorCode.STAFF_NOT_FOUND); | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + // 3. 权限分类校验(仅当非空) | ||
| 137 | + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null | ||
| 138 | + ? new ArrayList<>() : dto.getPermissionCategoryIds(); | ||
| 139 | + if (!categoryIds.isEmpty()) { | ||
| 140 | + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds); | ||
| 141 | + if (found.size() != categoryIds.size() | ||
| 142 | + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) { | ||
| 143 | + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND); | ||
| 144 | + } | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + // 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash 等保护字段) | ||
| 148 | + target.setIStaffId(dto.getIStaffId()); // 含 null 清空(依赖 iStaffId.IGNORED 策略) | ||
| 149 | + target.setSUserType(dto.getSUserType()); | ||
| 150 | + target.setSLanguage(dto.getSLanguage()); | ||
| 151 | + if (dto.getBCanModifyDocs() != null) { | ||
| 152 | + target.setBCanModifyDocs(dto.getBCanModifyDocs()); | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + // 5. 落库 user | ||
| 156 | + userMapper.updateById(target); | ||
| 157 | + | ||
| 158 | + // 6. 重建权限关联:先删后插(清空原有,再按 dto 插入) | ||
| 159 | + userPermissionMapper.delete( | ||
| 160 | + new LambdaQueryWrapper<UserPermissionEntity>() | ||
| 161 | + .eq(UserPermissionEntity::getIUserId, id)); | ||
| 162 | + for (Integer categoryId : categoryIds) { | ||
| 163 | + UserPermissionEntity up = new UserPermissionEntity(); | ||
| 164 | + up.setIUserId(id); | ||
| 165 | + up.setICategoryId(categoryId); | ||
| 166 | + up.setTCreateDate(LocalDateTime.now()); | ||
| 167 | + userPermissionMapper.insert(up); | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + return UserVO.from(target, categoryIds); | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + /** REQ-USR-003 用户列表查询 — queryField 白名单映射 + LEFT JOIN tStaff 分页 */ | ||
| 174 | + private static final Map<String, String> QUERY_COLUMN_MAP = Map.ofEntries( | ||
| 175 | + Map.entry("username", "u.sUserName"), | ||
| 176 | + Map.entry("staffname", "s.sStaffName"), | ||
| 177 | + Map.entry("userno", "u.sUserNo"), | ||
| 178 | + Map.entry("department", "s.sDepartment"), | ||
| 179 | + Map.entry("usertype", "u.sUserType"), | ||
| 180 | + Map.entry("language", "u.sLanguage"), | ||
| 181 | + Map.entry("deleted", "u.bDeleted"), | ||
| 182 | + Map.entry("lastLoginDate", "u.tLastLoginDate"), | ||
| 183 | + Map.entry("createdBy", "u.sCreatedBy")); | ||
| 184 | + | ||
| 185 | + private static final Set<String> MATCH_TYPES = Set.of("contains", "notContains", "equals"); | ||
| 186 | + | ||
| 187 | + @Override | ||
| 188 | + @Transactional(readOnly = true) | ||
| 189 | + public PageResult<UserListItemVO> search(UserQueryDTO query) { | ||
| 190 | + // 1. queryField 白名单 + 列映射(防 SQL 注入)。 | ||
| 191 | + // column 是 service 内部局部变量,通过 mapper @Param("column") 单独传入;不写回 DTO, | ||
| 192 | + // 避免 GET query-string 绑定(@JsonIgnore 仅对 Jackson 生效,无法防 setter 注入)。 | ||
| 193 | + String column = null; | ||
| 194 | + if (query.getQueryField() != null && !query.getQueryField().isEmpty()) { | ||
| 195 | + column = QUERY_COLUMN_MAP.get(query.getQueryField()); | ||
| 196 | + if (column == null) { | ||
| 197 | + throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField()); | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + // 2. matchType 白名单 | ||
| 202 | + if (query.getMatchType() != null && !query.getMatchType().isEmpty() | ||
| 203 | + && !MATCH_TYPES.contains(query.getMatchType())) { | ||
| 204 | + throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType()); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + // 3. spec § 业务规则 6:deleted 字段值标准化('true'/'1' → '1';'false'/'0' → '0';其它非法) | ||
| 208 | + if ("deleted".equals(query.getQueryField()) | ||
| 209 | + && query.getQueryValue() != null && !query.getQueryValue().isEmpty()) { | ||
| 210 | + String v = query.getQueryValue().trim().toLowerCase(); | ||
| 211 | + if ("true".equals(v) || "1".equals(v)) { | ||
| 212 | + query.setQueryValue("1"); | ||
| 213 | + } else if ("false".equals(v) || "0".equals(v)) { | ||
| 214 | + query.setQueryValue("0"); | ||
| 215 | + } else { | ||
| 216 | + throw new BizException(ErrorCode.PARAM_INVALID, "deleted queryValue 仅支持 true/false/1/0"); | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + // 4. 默认值兜底 | ||
| 221 | + int pageNum = query.getPageNum() == null ? 1 : query.getPageNum(); | ||
| 222 | + int pageSize = query.getPageSize() == null ? 20 : query.getPageSize(); | ||
| 223 | + | ||
| 224 | + // 5. MP 分页查询 | ||
| 225 | + IPage<UserListItemVO> page = new Page<>(pageNum, pageSize); | ||
| 226 | + IPage<UserListItemVO> result = userMapper.searchUsers(page, query, column); | ||
| 227 | + | ||
| 228 | + return PageResult.of(result); | ||
| 229 | + } | ||
| 230 | +} |
backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | ||
| 2 | + | ||
| 3 | +import lombok.AllArgsConstructor; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import lombok.NoArgsConstructor; | ||
| 6 | + | ||
| 7 | +/** REQ-USR-004 登录结果 VO(含 JWT + 用户基本信息) */ | ||
| 8 | +@Data | ||
| 9 | +@NoArgsConstructor | ||
| 10 | +@AllArgsConstructor | ||
| 11 | +public class LoginResultVO { | ||
| 12 | + private String accessToken; | ||
| 13 | + private long expiresIn; | ||
| 14 | + private LoginUserInfo user; | ||
| 15 | + | ||
| 16 | + @Data | ||
| 17 | + @NoArgsConstructor | ||
| 18 | + @AllArgsConstructor | ||
| 19 | + public static class LoginUserInfo { | ||
| 20 | + private Integer iIncrement; | ||
| 21 | + private String sUserNo; | ||
| 22 | + private String sUserName; | ||
| 23 | + private String sUserType; | ||
| 24 | + private String sLanguage; | ||
| 25 | + } | ||
| 26 | +} |
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | ||
| 2 | + | ||
| 3 | +import lombok.Data; | ||
| 4 | + | ||
| 5 | +import java.time.LocalDateTime; | ||
| 6 | + | ||
| 7 | +/** REQ-USR-003 用户列表行 VO(含 LEFT JOIN tStaff 出来的 sStaffName / sDepartment)。 */ | ||
| 8 | +@Data | ||
| 9 | +public class UserListItemVO { | ||
| 10 | + private Integer iIncrement; | ||
| 11 | + private String sUserName; | ||
| 12 | + private String sStaffName; | ||
| 13 | + private String sUserNo; | ||
| 14 | + private String sDepartment; | ||
| 15 | + private String sUserType; | ||
| 16 | + private String sLanguage; | ||
| 17 | + private Boolean bDeleted; | ||
| 18 | + private LocalDateTime tLastLoginDate; | ||
| 19 | + private String sCreatedBy; | ||
| 20 | + private LocalDateTime tCreateDate; | ||
| 21 | +} |
backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import java.time.LocalDateTime; | ||
| 7 | +import java.util.ArrayList; | ||
| 8 | +import java.util.List; | ||
| 9 | + | ||
| 10 | +/** REQ-USR-001 用户 VO(不含 sPasswordHash 等内部字段)。 */ | ||
| 11 | +@Data | ||
| 12 | +public class UserVO { | ||
| 13 | + private Integer iIncrement; | ||
| 14 | + private String sUserNo; | ||
| 15 | + private String sUserName; | ||
| 16 | + private Integer iStaffId; | ||
| 17 | + private String sUserType; | ||
| 18 | + private String sLanguage; | ||
| 19 | + private Boolean bCanModifyDocs; | ||
| 20 | + private LocalDateTime tCreateDate; | ||
| 21 | + private Boolean bDeleted; | ||
| 22 | + private List<Integer> permissionCategoryIds = new ArrayList<>(); | ||
| 23 | + | ||
| 24 | + public static UserVO from(UserEntity e, List<Integer> permissionCategoryIds) { | ||
| 25 | + UserVO v = new UserVO(); | ||
| 26 | + v.setIIncrement(e.getIIncrement()); | ||
| 27 | + v.setSUserNo(e.getSUserNo()); | ||
| 28 | + v.setSUserName(e.getSUserName()); | ||
| 29 | + v.setIStaffId(e.getIStaffId()); | ||
| 30 | + v.setSUserType(e.getSUserType()); | ||
| 31 | + v.setSLanguage(e.getSLanguage()); | ||
| 32 | + v.setBCanModifyDocs(e.getBCanModifyDocs()); | ||
| 33 | + v.setTCreateDate(e.getTCreateDate()); | ||
| 34 | + v.setBDeleted(e.getBDeleted()); | ||
| 35 | + v.setPermissionCategoryIds(permissionCategoryIds == null ? new ArrayList<>() : permissionCategoryIds); | ||
| 36 | + return v; | ||
| 37 | + } | ||
| 38 | +} |
backend/src/main/resources/application-test.yml
0 → 100644
backend/src/main/resources/application.yml
0 → 100644
| 1 | +server: | ||
| 2 | + port: 8080 | ||
| 3 | + servlet: | ||
| 4 | + context-path: / | ||
| 5 | + | ||
| 6 | +spring: | ||
| 7 | + profiles: | ||
| 8 | + active: ${SPRING_PROFILES_ACTIVE:dev} | ||
| 9 | + datasource: | ||
| 10 | + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true | ||
| 11 | + username: ${DB_USER} | ||
| 12 | + password: ${DB_PASSWORD} | ||
| 13 | + driver-class-name: com.mysql.cj.jdbc.Driver | ||
| 14 | + flyway: | ||
| 15 | + enabled: true | ||
| 16 | + locations: filesystem:../sql/migrations | ||
| 17 | + baseline-on-migrate: true | ||
| 18 | + baseline-version: 0 | ||
| 19 | + validate-on-migrate: true | ||
| 20 | + | ||
| 21 | +mybatis-plus: | ||
| 22 | + configuration: | ||
| 23 | + map-underscore-to-camel-case: false | ||
| 24 | + global-config: | ||
| 25 | + db-config: | ||
| 26 | + id-type: auto | ||
| 27 | + | ||
| 28 | +erp: | ||
| 29 | + jwt: | ||
| 30 | + secret: ${JWT_SECRET} | ||
| 31 | + expires-in-seconds: 7200 |
backend/src/main/resources/mapper/mod/ModuleMapper.xml
0 → 100644
backend/src/main/resources/mapper/usr/UserMapper.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | ||
| 3 | +<mapper namespace="com.xly.erp.module.usr.mapper.UserMapper"> | ||
| 4 | + | ||
| 5 | + <!-- REQ-USR-003 用户列表查询:LEFT JOIN tStaff + 动态 WHERE。 | ||
| 6 | + column 由 service 层白名单映射后通过 @Param("column") 单独传入, | ||
| 7 | + 绝不接受 DTO 中可被 GET query-string 绑定的字段。 --> | ||
| 8 | + <select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO"> | ||
| 9 | + SELECT | ||
| 10 | + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo, | ||
| 11 | + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted, | ||
| 12 | + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate | ||
| 13 | + FROM tUser u | ||
| 14 | + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0 | ||
| 15 | + <where> | ||
| 16 | + <!-- 默认过滤已软删除:仅当 queryField=='deleted' 且 queryValue 非空时让用户控制 bDeleted 取值 --> | ||
| 17 | + <if test="query.queryField != 'deleted' or query.queryValue == null or query.queryValue == ''"> | ||
| 18 | + u.bDeleted = 0 | ||
| 19 | + </if> | ||
| 20 | + <if test="column != null and column != '' and query.queryValue != null and query.queryValue != ''"> | ||
| 21 | + AND | ||
| 22 | + <choose> | ||
| 23 | + <!-- deleted 是 bit(1) 列,MySQL 与字符串 '1'/'0' 隐式比较不可靠; | ||
| 24 | + service 已把 queryValue 标准化为 '0' / '1',此处显式 CAST 成整数。 --> | ||
| 25 | + <when test="query.queryField == 'deleted'"> | ||
| 26 | + ${column} = CAST(#{query.queryValue} AS UNSIGNED) | ||
| 27 | + </when> | ||
| 28 | + <when test="query.matchType == 'equals'"> | ||
| 29 | + ${column} = #{query.queryValue} | ||
| 30 | + </when> | ||
| 31 | + <when test="query.matchType == 'notContains'"> | ||
| 32 | + ${column} NOT LIKE CONCAT('%', #{query.queryValue}, '%') | ||
| 33 | + </when> | ||
| 34 | + <otherwise> | ||
| 35 | + ${column} LIKE CONCAT('%', #{query.queryValue}, '%') | ||
| 36 | + </otherwise> | ||
| 37 | + </choose> | ||
| 38 | + </if> | ||
| 39 | + </where> | ||
| 40 | + ORDER BY u.tCreateDate DESC, u.iIncrement DESC | ||
| 41 | + </select> | ||
| 42 | +</mapper> |
backend/src/test/java/com/xly/erp/ErpApplicationTest.java
0 → 100644
| 1 | +package com.xly.erp; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.Test; | ||
| 4 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 5 | +import org.springframework.test.context.ActiveProfiles; | ||
| 6 | + | ||
| 7 | +@SpringBootTest | ||
| 8 | +@ActiveProfiles("test") | ||
| 9 | +class ErpApplicationTest { | ||
| 10 | + @Test | ||
| 11 | + void contextLoads() { | ||
| 12 | + // Spring ApplicationContext 启动成功 + Flyway 完成 baseline / migrate 即视为通过 | ||
| 13 | + } | ||
| 14 | +} |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.common.response.ErrorCode; | ||
| 4 | +import jakarta.validation.constraints.NotBlank; | ||
| 5 | +import org.junit.jupiter.api.BeforeEach; | ||
| 6 | +import org.junit.jupiter.api.Test; | ||
| 7 | +import org.springframework.http.MediaType; | ||
| 8 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 9 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | ||
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 11 | +import org.springframework.web.bind.annotation.PostMapping; | ||
| 12 | +import org.springframework.web.bind.annotation.RequestBody; | ||
| 13 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RestController; | ||
| 15 | + | ||
| 16 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 17 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 18 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
| 19 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 20 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 21 | + | ||
| 22 | +class GlobalExceptionHandlerTest { | ||
| 23 | + | ||
| 24 | + private MockMvc mockMvc; | ||
| 25 | + | ||
| 26 | + @BeforeEach | ||
| 27 | + void setUp() { | ||
| 28 | + mockMvc = MockMvcBuilders.standaloneSetup(new DummyController()) | ||
| 29 | + .setControllerAdvice(new GlobalExceptionHandler()) | ||
| 30 | + .build(); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + @RestController | ||
| 34 | + @RequestMapping("/api/__dummy") | ||
| 35 | + static class DummyController { | ||
| 36 | + @GetMapping("/biz") | ||
| 37 | + public Object biz() { throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); } | ||
| 38 | + | ||
| 39 | + @GetMapping("/runtime") | ||
| 40 | + public Object runtime() { throw new RuntimeException("internal boom\nstack-line-1\nstack-line-2"); } | ||
| 41 | + | ||
| 42 | + @PostMapping(value = "/validation", consumes = MediaType.APPLICATION_JSON_VALUE) | ||
| 43 | + public Object validation(@jakarta.validation.Valid @RequestBody Payload p) { return "ok"; } | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + static class Payload { | ||
| 47 | + @NotBlank String name; | ||
| 48 | + public String getName() { return name; } | ||
| 49 | + public void setName(String n) { this.name = n; } | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Test | ||
| 53 | + void bizException_returns200WithBizCode() throws Exception { | ||
| 54 | + mockMvc.perform(get("/api/__dummy/biz")) | ||
| 55 | + .andExpect(status().isOk()) | ||
| 56 | + .andExpect(jsonPath("$.code").value(40411)) | ||
| 57 | + .andExpect(jsonPath("$.message").value("父模块不存在或已删除")); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @Test | ||
| 61 | + void validationException_returns200WithParamInvalidCode() throws Exception { | ||
| 62 | + mockMvc.perform(post("/api/__dummy/validation") | ||
| 63 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 64 | + .content("{\"name\":\"\"}")) | ||
| 65 | + .andExpect(status().isOk()) | ||
| 66 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + @Test | ||
| 70 | + void uncaughtException_returns200WithInternalErrorCode() throws Exception { | ||
| 71 | + mockMvc.perform(get("/api/__dummy/runtime")) | ||
| 72 | + .andExpect(status().isOk()) | ||
| 73 | + .andExpect(jsonPath("$.code").value(50000)); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Test | ||
| 77 | + void response_doesNotContainStackTrace() throws Exception { | ||
| 78 | + String body = mockMvc.perform(get("/api/__dummy/runtime")) | ||
| 79 | + .andReturn().getResponse().getContentAsString(); | ||
| 80 | + assertThat(body) | ||
| 81 | + .doesNotContain("stack-line-1") | ||
| 82 | + .doesNotContain("internal boom") | ||
| 83 | + .doesNotContain("at java.") | ||
| 84 | + .doesNotContain("Caused by"); | ||
| 85 | + } | ||
| 86 | +} |
backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.Test; | ||
| 4 | + | ||
| 5 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 6 | + | ||
| 7 | +class ApiResponseTest { | ||
| 8 | + | ||
| 9 | + @Test | ||
| 10 | + void ok_setsCode200AndDataAndTimestamp() { | ||
| 11 | + long before = System.currentTimeMillis(); | ||
| 12 | + ApiResponse<String> r = ApiResponse.ok("hello"); | ||
| 13 | + long after = System.currentTimeMillis(); | ||
| 14 | + | ||
| 15 | + assertThat(r.getCode()).isEqualTo(200); | ||
| 16 | + assertThat(r.getMessage()).isEqualTo("操作成功"); | ||
| 17 | + assertThat(r.getData()).isEqualTo("hello"); | ||
| 18 | + assertThat(r.getTimestamp()).isBetween(before, after); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + @Test | ||
| 22 | + void okWithMessage_overridesDefaultMessage() { | ||
| 23 | + ApiResponse<Integer> r = ApiResponse.ok("created", 42); | ||
| 24 | + assertThat(r.getCode()).isEqualTo(200); | ||
| 25 | + assertThat(r.getMessage()).isEqualTo("created"); | ||
| 26 | + assertThat(r.getData()).isEqualTo(42); | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Test | ||
| 30 | + void fail_mapsErrorCodeFields() { | ||
| 31 | + ApiResponse<Void> r = ApiResponse.fail(ErrorCode.PARAM_INVALID); | ||
| 32 | + assertThat(r.getCode()).isEqualTo(40010); | ||
| 33 | + assertThat(r.getMessage()).isEqualTo("参数错误"); | ||
| 34 | + assertThat(r.getData()).isNull(); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + @Test | ||
| 38 | + void failWithDetail_overridesDefaultMessage() { | ||
| 39 | + ApiResponse<Void> r = ApiResponse.fail(ErrorCode.PARAM_INVALID, "sUserName: blank"); | ||
| 40 | + assertThat(r.getCode()).isEqualTo(40010); | ||
| 41 | + assertThat(r.getMessage()).isEqualTo("sUserName: blank"); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @Test | ||
| 45 | + void errorCode_constantsMatchDocs05Spec() { | ||
| 46 | + assertThat(ErrorCode.SUCCESS.getCode()).isEqualTo(200); | ||
| 47 | + assertThat(ErrorCode.PARAM_INVALID.getCode()).isEqualTo(40010); | ||
| 48 | + assertThat(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()).isEqualTo(40411); | ||
| 49 | + assertThat(ErrorCode.MOD_PROC_NAME_DUP.getCode()).isEqualTo(40911); | ||
| 50 | + assertThat(ErrorCode.INTERNAL_ERROR.getCode()).isEqualTo(50000); | ||
| 51 | + assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421); | ||
| 52 | + assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921); | ||
| 53 | + assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912); | ||
| 54 | + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); | ||
| 55 | + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); | ||
| 56 | + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); | ||
| 57 | + assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431); | ||
| 58 | + assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101); | ||
| 59 | + assertThat(ErrorCode.LOGIN_ACCOUNT_LOCKED.getCode()).isEqualTo(40301); | ||
| 60 | + } | ||
| 61 | +} |
backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java
0 → 100644
| 1 | +package com.xly.erp.config; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.Test; | ||
| 4 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 5 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | ||
| 6 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 7 | +import org.springframework.context.annotation.Bean; | ||
| 8 | +import org.springframework.boot.test.context.TestConfiguration; | ||
| 9 | +import org.springframework.test.context.ActiveProfiles; | ||
| 10 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 11 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 12 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 13 | +import org.springframework.web.bind.annotation.RestController; | ||
| 14 | + | ||
| 15 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 16 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; | ||
| 17 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 18 | + | ||
| 19 | +@SpringBootTest | ||
| 20 | +@AutoConfigureMockMvc | ||
| 21 | +@ActiveProfiles("test") | ||
| 22 | +class SecurityConfigTest { | ||
| 23 | + | ||
| 24 | + @Autowired MockMvc mockMvc; | ||
| 25 | + | ||
| 26 | + @TestConfiguration | ||
| 27 | + static class PingConfig { | ||
| 28 | + @Bean PingController pingController() { return new PingController(); } | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @RestController | ||
| 32 | + @RequestMapping("/api/__ping") | ||
| 33 | + static class PingController { | ||
| 34 | + @GetMapping | ||
| 35 | + public String ping() { return "pong"; } | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @Test | ||
| 39 | + void anyApiEndpoint_isPermittedWithoutAuth() throws Exception { | ||
| 40 | + mockMvc.perform(get("/api/__ping")) | ||
| 41 | + .andExpect(status().isOk()) | ||
| 42 | + .andExpect(content().string("pong")); | ||
| 43 | + } | ||
| 44 | +} |
backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.controller; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.erp.module.mod.dto.ModuleCreateDTO; | ||
| 5 | +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | ||
| 6 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 7 | +import com.xly.erp.module.mod.mapper.ModuleMapper; | ||
| 8 | +import org.junit.jupiter.api.Test; | ||
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | ||
| 11 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 12 | +import org.springframework.http.MediaType; | ||
| 13 | +import org.springframework.test.annotation.Rollback; | ||
| 14 | +import org.springframework.test.context.ActiveProfiles; | ||
| 15 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 16 | +import org.springframework.transaction.annotation.Transactional; | ||
| 17 | + | ||
| 18 | +import java.time.LocalDateTime; | ||
| 19 | + | ||
| 20 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | ||
| 22 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 23 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
| 24 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | ||
| 25 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 26 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 27 | + | ||
| 28 | +@SpringBootTest | ||
| 29 | +@AutoConfigureMockMvc | ||
| 30 | +@ActiveProfiles("test") | ||
| 31 | +@Transactional | ||
| 32 | +@Rollback | ||
| 33 | +class ModuleControllerIT { | ||
| 34 | + | ||
| 35 | + @Autowired MockMvc mockMvc; | ||
| 36 | + @Autowired ObjectMapper objectMapper; | ||
| 37 | + @Autowired ModuleMapper moduleMapper; | ||
| 38 | + | ||
| 39 | + private ModuleCreateDTO valid(String procName) { | ||
| 40 | + ModuleCreateDTO d = new ModuleCreateDTO(); | ||
| 41 | + d.setSDisplayType("前端业务"); | ||
| 42 | + d.setSProcedureName(procName); | ||
| 43 | + d.setSModuleType("USR"); | ||
| 44 | + d.setSManageDeptEn("IT"); | ||
| 45 | + d.setBShowPermission(false); | ||
| 46 | + d.setSModuleNameZh("用户管理"); | ||
| 47 | + d.setIParentId(null); | ||
| 48 | + d.setISortOrder(0); | ||
| 49 | + return d; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void post_validRootModule_returns200WithVO() throws Exception { | ||
| 56 | + ModuleCreateDTO dto = valid("sp_audit_root_" + System.nanoTime()); | ||
| 57 | + mockMvc.perform(post("/api/modules") | ||
| 58 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 59 | + .content(json(dto))) | ||
| 60 | + .andExpect(status().isOk()) | ||
| 61 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 62 | + .andExpect(jsonPath("$.data.iIncrement").isNumber()) | ||
| 63 | + .andExpect(jsonPath("$.data.sProcedureName").value(dto.getSProcedureName())) | ||
| 64 | + .andExpect(jsonPath("$.data.bDeleted").value(false)); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @Test | ||
| 68 | + void post_validChildModule_returns200() throws Exception { | ||
| 69 | + // 先建 root | ||
| 70 | + ModuleCreateDTO root = valid("sp_audit_parent_" + System.nanoTime()); | ||
| 71 | + String rootBody = mockMvc.perform(post("/api/modules") | ||
| 72 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 73 | + .content(json(root))) | ||
| 74 | + .andExpect(status().isOk()) | ||
| 75 | + .andReturn().getResponse().getContentAsString(); | ||
| 76 | + Integer parentId = objectMapper.readTree(rootBody).path("data").path("iIncrement").asInt(); | ||
| 77 | + | ||
| 78 | + // 再建 child | ||
| 79 | + ModuleCreateDTO child = valid("sp_audit_child_" + System.nanoTime()); | ||
| 80 | + child.setIParentId(parentId); | ||
| 81 | + mockMvc.perform(post("/api/modules") | ||
| 82 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 83 | + .content(json(child))) | ||
| 84 | + .andExpect(status().isOk()) | ||
| 85 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 86 | + .andExpect(jsonPath("$.data.iParentId").value(parentId)); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + @Test | ||
| 90 | + void post_duplicateProcedureName_returns200WithCode40911() throws Exception { | ||
| 91 | + String procName = "sp_audit_dup_" + System.nanoTime(); | ||
| 92 | + ModuleCreateDTO first = valid(procName); | ||
| 93 | + mockMvc.perform(post("/api/modules") | ||
| 94 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 95 | + .content(json(first))) | ||
| 96 | + .andExpect(status().isOk()); | ||
| 97 | + | ||
| 98 | + ModuleCreateDTO second = valid(procName); | ||
| 99 | + mockMvc.perform(post("/api/modules") | ||
| 100 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 101 | + .content(json(second))) | ||
| 102 | + .andExpect(status().isOk()) | ||
| 103 | + .andExpect(jsonPath("$.code").value(40911)); | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + @Test | ||
| 107 | + void post_parentNotFound_returns200WithCode40411() throws Exception { | ||
| 108 | + ModuleCreateDTO d = valid("sp_audit_orphan_" + System.nanoTime()); | ||
| 109 | + d.setIParentId(999999); | ||
| 110 | + mockMvc.perform(post("/api/modules") | ||
| 111 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 112 | + .content(json(d))) | ||
| 113 | + .andExpect(status().isOk()) | ||
| 114 | + .andExpect(jsonPath("$.code").value(40411)); | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + @Test | ||
| 118 | + void post_missingRequiredField_returns200WithCode40010() throws Exception { | ||
| 119 | + ModuleCreateDTO d = valid("sp_audit_miss_" + System.nanoTime()); | ||
| 120 | + d.setSModuleNameZh(null); // 必填缺失 | ||
| 121 | + mockMvc.perform(post("/api/modules") | ||
| 122 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 123 | + .content(json(d))) | ||
| 124 | + .andExpect(status().isOk()) | ||
| 125 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + @Test | ||
| 129 | + void post_invalidDisplayTypeEnum_returns200WithCode40010() throws Exception { | ||
| 130 | + ModuleCreateDTO d = valid("sp_audit_enum_" + System.nanoTime()); | ||
| 131 | + d.setSDisplayType("非法值"); | ||
| 132 | + mockMvc.perform(post("/api/modules") | ||
| 133 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 134 | + .content(json(d))) | ||
| 135 | + .andExpect(status().isOk()) | ||
| 136 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + // ============================================================ | ||
| 140 | + // REQ-MOD-002 PUT 系列 | ||
| 141 | + // ============================================================ | ||
| 142 | + | ||
| 143 | + private Integer insertExisting(String procName, Integer parentId) { | ||
| 144 | + ModuleEntity e = new ModuleEntity(); | ||
| 145 | + e.setSDisplayType("前端业务"); | ||
| 146 | + e.setSProcedureName(procName); | ||
| 147 | + e.setSModuleType("USR"); | ||
| 148 | + e.setSManageDeptEn("IT"); | ||
| 149 | + e.setBShowPermission(false); | ||
| 150 | + e.setSModuleNameZh("用户管理"); | ||
| 151 | + e.setIParentId(parentId); | ||
| 152 | + e.setISortOrder(0); | ||
| 153 | + e.setBDeleted(false); | ||
| 154 | + e.setTCreateDate(LocalDateTime.now()); | ||
| 155 | + moduleMapper.insert(e); | ||
| 156 | + return e.getIIncrement(); | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + private ModuleUpdateDTO updateDto() { | ||
| 160 | + ModuleUpdateDTO d = new ModuleUpdateDTO(); | ||
| 161 | + d.setSDisplayType("系统配置"); | ||
| 162 | + d.setSModuleType("USR_REVISED"); | ||
| 163 | + d.setSManageDeptEn("OPS"); | ||
| 164 | + d.setBShowPermission(true); | ||
| 165 | + d.setSModuleNameZh("用户管理(修订)"); | ||
| 166 | + d.setIParentId(null); | ||
| 167 | + d.setISortOrder(5); | ||
| 168 | + return d; | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + @Test | ||
| 172 | + void put_validUpdate_returns200() throws Exception { | ||
| 173 | + String origProc = "sp_put_valid_" + System.nanoTime(); | ||
| 174 | + Integer id = insertExisting(origProc, null); | ||
| 175 | + mockMvc.perform(put("/api/modules/" + id) | ||
| 176 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 177 | + .content(json(updateDto()))) | ||
| 178 | + .andExpect(status().isOk()) | ||
| 179 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 180 | + .andExpect(jsonPath("$.data.iIncrement").value(id)) | ||
| 181 | + .andExpect(jsonPath("$.data.sDisplayType").value("系统配置")) | ||
| 182 | + .andExpect(jsonPath("$.data.sModuleNameZh").value("用户管理(修订)")) | ||
| 183 | + .andExpect(jsonPath("$.data.sProcedureName").value(origProc)); | ||
| 184 | + | ||
| 185 | + ModuleEntity reloaded = moduleMapper.selectById(id); | ||
| 186 | + assertThat(reloaded.getSModuleType()).isEqualTo("USR_REVISED"); | ||
| 187 | + assertThat(reloaded.getSManageDeptEn()).isEqualTo("OPS"); | ||
| 188 | + assertThat(reloaded.getBShowPermission()).isTrue(); | ||
| 189 | + assertThat(reloaded.getISortOrder()).isEqualTo(5); | ||
| 190 | + assertThat(reloaded.getSProcedureName()).isEqualTo(origProc); | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + @Test | ||
| 194 | + void put_setParentToNull_clearsParent() throws Exception { | ||
| 195 | + Integer parentId = insertExisting("sp_put_parent_" + System.nanoTime(), null); | ||
| 196 | + Integer childId = insertExisting("sp_put_child_" + System.nanoTime(), parentId); | ||
| 197 | + | ||
| 198 | + ModuleUpdateDTO d = updateDto(); | ||
| 199 | + d.setIParentId(null); | ||
| 200 | + | ||
| 201 | + mockMvc.perform(put("/api/modules/" + childId) | ||
| 202 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 203 | + .content(json(d))) | ||
| 204 | + .andExpect(status().isOk()) | ||
| 205 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 206 | + .andExpect(jsonPath("$.data.iParentId").doesNotExist()); | ||
| 207 | + | ||
| 208 | + assertThat(moduleMapper.selectById(childId).getIParentId()).isNull(); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + @Test | ||
| 212 | + void put_targetNotFound_returns40421() throws Exception { | ||
| 213 | + mockMvc.perform(put("/api/modules/999999") | ||
| 214 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 215 | + .content(json(updateDto()))) | ||
| 216 | + .andExpect(status().isOk()) | ||
| 217 | + .andExpect(jsonPath("$.code").value(40421)); | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + @Test | ||
| 221 | + void put_parentNotFound_returns40411() throws Exception { | ||
| 222 | + Integer id = insertExisting("sp_put_orphan_" + System.nanoTime(), null); | ||
| 223 | + ModuleUpdateDTO d = updateDto(); | ||
| 224 | + d.setIParentId(999999); | ||
| 225 | + mockMvc.perform(put("/api/modules/" + id) | ||
| 226 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 227 | + .content(json(d))) | ||
| 228 | + .andExpect(status().isOk()) | ||
| 229 | + .andExpect(jsonPath("$.code").value(40411)); | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + @Test | ||
| 233 | + void put_parentSelfRef_returns40921() throws Exception { | ||
| 234 | + Integer id = insertExisting("sp_put_self_" + System.nanoTime(), null); | ||
| 235 | + ModuleUpdateDTO d = updateDto(); | ||
| 236 | + d.setIParentId(id); | ||
| 237 | + mockMvc.perform(put("/api/modules/" + id) | ||
| 238 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 239 | + .content(json(d))) | ||
| 240 | + .andExpect(status().isOk()) | ||
| 241 | + .andExpect(jsonPath("$.code").value(40921)); | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + @Test | ||
| 245 | + void put_parentIsDescendant_returns40921() throws Exception { | ||
| 246 | + // grandparent -> parent -> child;尝试把 grandparent 的 iParentId 设为 child | ||
| 247 | + Integer grandId = insertExisting("sp_put_grand_" + System.nanoTime(), null); | ||
| 248 | + Integer parentId = insertExisting("sp_put_par_" + System.nanoTime(), grandId); | ||
| 249 | + Integer childId = insertExisting("sp_put_chi_" + System.nanoTime(), parentId); | ||
| 250 | + | ||
| 251 | + ModuleUpdateDTO d = updateDto(); | ||
| 252 | + d.setIParentId(childId); | ||
| 253 | + mockMvc.perform(put("/api/modules/" + grandId) | ||
| 254 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 255 | + .content(json(d))) | ||
| 256 | + .andExpect(status().isOk()) | ||
| 257 | + .andExpect(jsonPath("$.code").value(40921)); | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + @Test | ||
| 261 | + void put_missingRequired_returns40010() throws Exception { | ||
| 262 | + Integer id = insertExisting("sp_put_miss_" + System.nanoTime(), null); | ||
| 263 | + ModuleUpdateDTO d = updateDto(); | ||
| 264 | + d.setSModuleNameZh(null); | ||
| 265 | + mockMvc.perform(put("/api/modules/" + id) | ||
| 266 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 267 | + .content(json(d))) | ||
| 268 | + .andExpect(status().isOk()) | ||
| 269 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + // ============================================================ | ||
| 273 | + // REQ-MOD-003 DELETE 系列 | ||
| 274 | + // ============================================================ | ||
| 275 | + | ||
| 276 | + @Test | ||
| 277 | + void delete_validLeaf_returns200WithBDeletedTrue() throws Exception { | ||
| 278 | + Integer id = insertExisting("sp_del_leaf_" + System.nanoTime(), null); | ||
| 279 | + | ||
| 280 | + mockMvc.perform(delete("/api/modules/" + id)) | ||
| 281 | + .andExpect(status().isOk()) | ||
| 282 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 283 | + .andExpect(jsonPath("$.data.iIncrement").value(id)) | ||
| 284 | + .andExpect(jsonPath("$.data.bDeleted").value(true)); | ||
| 285 | + | ||
| 286 | + ModuleEntity reloaded = moduleMapper.selectById(id); | ||
| 287 | + assertThat(reloaded.getBDeleted()).isTrue(); | ||
| 288 | + assertThat(reloaded.getTDeletedDate()).isNotNull(); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + @Test | ||
| 292 | + void delete_targetNotFound_returns40421() throws Exception { | ||
| 293 | + mockMvc.perform(delete("/api/modules/999999")) | ||
| 294 | + .andExpect(status().isOk()) | ||
| 295 | + .andExpect(jsonPath("$.code").value(40421)); | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + @Test | ||
| 299 | + void delete_targetAlreadyDeleted_returns40421() throws Exception { | ||
| 300 | + Integer id = insertExisting("sp_del_already_" + System.nanoTime(), null); | ||
| 301 | + // 手工置 bDeleted=true | ||
| 302 | + ModuleEntity patch = new ModuleEntity(); | ||
| 303 | + patch.setIIncrement(id); | ||
| 304 | + patch.setBDeleted(true); | ||
| 305 | + moduleMapper.updateById(patch); | ||
| 306 | + | ||
| 307 | + mockMvc.perform(delete("/api/modules/" + id)) | ||
| 308 | + .andExpect(status().isOk()) | ||
| 309 | + .andExpect(jsonPath("$.code").value(40421)); | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + @Test | ||
| 313 | + void delete_hasUndeletedChildren_returns40912() throws Exception { | ||
| 314 | + Integer parentId = insertExisting("sp_del_par_" + System.nanoTime(), null); | ||
| 315 | + insertExisting("sp_del_chi_" + System.nanoTime(), parentId); | ||
| 316 | + | ||
| 317 | + mockMvc.perform(delete("/api/modules/" + parentId)) | ||
| 318 | + .andExpect(status().isOk()) | ||
| 319 | + .andExpect(jsonPath("$.code").value(40912)); | ||
| 320 | + | ||
| 321 | + // parent 仍未删除 | ||
| 322 | + assertThat(moduleMapper.selectById(parentId).getBDeleted()).isFalse(); | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + @Test | ||
| 326 | + void delete_softDeletedChildren_doesNotBlock_returns200() throws Exception { | ||
| 327 | + Integer parentId = insertExisting("sp_del_pp_" + System.nanoTime(), null); | ||
| 328 | + Integer childId = insertExisting("sp_del_cc_" + System.nanoTime(), parentId); | ||
| 329 | + | ||
| 330 | + // 先 DELETE child(应成功) | ||
| 331 | + mockMvc.perform(delete("/api/modules/" + childId)) | ||
| 332 | + .andExpect(status().isOk()) | ||
| 333 | + .andExpect(jsonPath("$.code").value(200)); | ||
| 334 | + | ||
| 335 | + // 再 DELETE parent(已删除子不阻塞) | ||
| 336 | + mockMvc.perform(delete("/api/modules/" + parentId)) | ||
| 337 | + .andExpect(status().isOk()) | ||
| 338 | + .andExpect(jsonPath("$.code").value(200)); | ||
| 339 | + | ||
| 340 | + assertThat(moduleMapper.selectById(parentId).getBDeleted()).isTrue(); | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + @Test | ||
| 344 | + void delete_preservesOtherFields_onChildModule() throws Exception { | ||
| 345 | + // 反例:非 root 子模块软删除后,iParentId / sProcedureName / sModuleNameZh / | ||
| 346 | + // iSortOrder 等字段必须保持原值(spec § 业务规则 #4 + 验收 #1)。 | ||
| 347 | + Integer parentId = insertExisting("sp_del_pres_p_" + System.nanoTime(), null); | ||
| 348 | + String childProc = "sp_del_pres_c_" + System.nanoTime(); | ||
| 349 | + // 用自定义字段值的 child(与 insertExisting 默认值不同),便于事后比对 | ||
| 350 | + ModuleEntity child = new ModuleEntity(); | ||
| 351 | + child.setSDisplayType("接口"); | ||
| 352 | + child.setSProcedureName(childProc); | ||
| 353 | + child.setSModuleType("AUDIT"); | ||
| 354 | + child.setSManageDeptEn("OPS"); | ||
| 355 | + child.setBShowPermission(true); | ||
| 356 | + child.setSModuleNameZh("待保留中文名"); | ||
| 357 | + child.setIParentId(parentId); | ||
| 358 | + child.setISortOrder(7); | ||
| 359 | + child.setBDeleted(false); | ||
| 360 | + child.setTCreateDate(LocalDateTime.now()); | ||
| 361 | + moduleMapper.insert(child); | ||
| 362 | + Integer childId = child.getIIncrement(); | ||
| 363 | + | ||
| 364 | + mockMvc.perform(delete("/api/modules/" + childId)) | ||
| 365 | + .andExpect(status().isOk()) | ||
| 366 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 367 | + .andExpect(jsonPath("$.data.bDeleted").value(true)); | ||
| 368 | + | ||
| 369 | + ModuleEntity reloaded = moduleMapper.selectById(childId); | ||
| 370 | + // 软删除三件套生效 | ||
| 371 | + assertThat(reloaded.getBDeleted()).isTrue(); | ||
| 372 | + assertThat(reloaded.getTDeletedDate()).isNotNull(); | ||
| 373 | + // 关键:其他列保持原值(曾经 iParentId.FieldStrategy.IGNORED 会清零这一列,本断言钉死该回归) | ||
| 374 | + assertThat(reloaded.getIParentId()).isEqualTo(parentId); | ||
| 375 | + assertThat(reloaded.getSProcedureName()).isEqualTo(childProc); | ||
| 376 | + assertThat(reloaded.getSDisplayType()).isEqualTo("接口"); | ||
| 377 | + assertThat(reloaded.getSModuleType()).isEqualTo("AUDIT"); | ||
| 378 | + assertThat(reloaded.getSManageDeptEn()).isEqualTo("OPS"); | ||
| 379 | + assertThat(reloaded.getBShowPermission()).isTrue(); | ||
| 380 | + assertThat(reloaded.getSModuleNameZh()).isEqualTo("待保留中文名"); | ||
| 381 | + assertThat(reloaded.getISortOrder()).isEqualTo(7); | ||
| 382 | + } | ||
| 383 | + | ||
| 384 | + @Test | ||
| 385 | + void delete_responseVOContainsOnlyIIncrementAndBDeleted() throws Exception { | ||
| 386 | + Integer id = insertExisting("sp_del_vo_" + System.nanoTime(), null); | ||
| 387 | + | ||
| 388 | + mockMvc.perform(delete("/api/modules/" + id)) | ||
| 389 | + .andExpect(status().isOk()) | ||
| 390 | + .andExpect(jsonPath("$.data.iIncrement").value(id)) | ||
| 391 | + .andExpect(jsonPath("$.data.bDeleted").value(true)) | ||
| 392 | + .andExpect(jsonPath("$.data.sProcedureName").doesNotExist()) | ||
| 393 | + .andExpect(jsonPath("$.data.sDisplayType").doesNotExist()) | ||
| 394 | + .andExpect(jsonPath("$.data.sModuleNameZh").doesNotExist()); | ||
| 395 | + } | ||
| 396 | + | ||
| 397 | + @Test | ||
| 398 | + void put_ignoresProcedureNameField_doesNotChange() throws Exception { | ||
| 399 | + String origProc = "sp_put_keep_" + System.nanoTime(); | ||
| 400 | + Integer id = insertExisting(origProc, null); | ||
| 401 | + // 手工拼一个含 sProcedureName 的请求体(DTO 没声明该字段,Jackson 默认忽略) | ||
| 402 | + String body = """ | ||
| 403 | + { | ||
| 404 | + "sDisplayType": "系统配置", | ||
| 405 | + "sProcedureName": "hijack", | ||
| 406 | + "sModuleType": "USR_REVISED", | ||
| 407 | + "sManageDeptEn": "OPS", | ||
| 408 | + "bShowPermission": true, | ||
| 409 | + "sModuleNameZh": "用户管理(修订)", | ||
| 410 | + "iSortOrder": 5 | ||
| 411 | + } | ||
| 412 | + """; | ||
| 413 | + mockMvc.perform(put("/api/modules/" + id) | ||
| 414 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 415 | + .content(body)) | ||
| 416 | + .andExpect(status().isOk()) | ||
| 417 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 418 | + .andExpect(jsonPath("$.data.sProcedureName").value(origProc)); | ||
| 419 | + | ||
| 420 | + assertThat(moduleMapper.selectById(id).getSProcedureName()).isEqualTo(origProc); | ||
| 421 | + } | ||
| 422 | + | ||
| 423 | + // ============================================================ | ||
| 424 | + // REQ-MOD-004 GET 系列 | ||
| 425 | + // ============================================================ | ||
| 426 | + | ||
| 427 | + @Test | ||
| 428 | + void get_emptyKeyword_returnsAllUndeletedAsTree() throws Exception { | ||
| 429 | + // 插入一个 root + 一个 child;不带 keyword 的 GET 应能看到二者 | ||
| 430 | + Integer rootId = insertExisting("sp_get_root_" + System.nanoTime(), null); | ||
| 431 | + Integer childId = insertExisting("sp_get_child_" + System.nanoTime(), rootId); | ||
| 432 | + | ||
| 433 | + mockMvc.perform(get("/api/modules")) | ||
| 434 | + .andExpect(status().isOk()) | ||
| 435 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 436 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + rootId + ")]").exists()) | ||
| 437 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + rootId + ")].children[?(@.iIncrement==" + childId + ")]").exists()); | ||
| 438 | + } | ||
| 439 | + | ||
| 440 | + @Test | ||
| 441 | + void get_keyword_filtersByModuleNameZhWithAncestors() throws Exception { | ||
| 442 | + // grandparent("系统配置") -> parent("用户管理") -> child("登录认证") | ||
| 443 | + ModuleEntity gp = new ModuleEntity(); | ||
| 444 | + gp.setSDisplayType("前端业务"); gp.setSProcedureName("sp_get_kw_gp_" + System.nanoTime()); | ||
| 445 | + gp.setSModuleType("MOD"); gp.setSManageDeptEn("IT"); gp.setBShowPermission(false); | ||
| 446 | + gp.setSModuleNameZh("系统配置-keyword test"); gp.setIParentId(null); gp.setISortOrder(0); | ||
| 447 | + gp.setBDeleted(false); gp.setTCreateDate(LocalDateTime.now()); | ||
| 448 | + moduleMapper.insert(gp); | ||
| 449 | + | ||
| 450 | + ModuleEntity p = new ModuleEntity(); | ||
| 451 | + p.setSDisplayType("前端业务"); p.setSProcedureName("sp_get_kw_p_" + System.nanoTime()); | ||
| 452 | + p.setSModuleType("MOD"); p.setSManageDeptEn("IT"); p.setBShowPermission(false); | ||
| 453 | + p.setSModuleNameZh("用户管理-keyword test"); p.setIParentId(gp.getIIncrement()); p.setISortOrder(0); | ||
| 454 | + p.setBDeleted(false); p.setTCreateDate(LocalDateTime.now()); | ||
| 455 | + moduleMapper.insert(p); | ||
| 456 | + | ||
| 457 | + ModuleEntity c = new ModuleEntity(); | ||
| 458 | + c.setSDisplayType("前端业务"); c.setSProcedureName("sp_get_kw_c_" + System.nanoTime()); | ||
| 459 | + c.setSModuleType("MOD"); c.setSManageDeptEn("IT"); c.setBShowPermission(false); | ||
| 460 | + c.setSModuleNameZh("唯一登录认证关键词"); c.setIParentId(p.getIIncrement()); c.setISortOrder(0); | ||
| 461 | + c.setBDeleted(false); c.setTCreateDate(LocalDateTime.now()); | ||
| 462 | + moduleMapper.insert(c); | ||
| 463 | + | ||
| 464 | + mockMvc.perform(get("/api/modules").param("keyword", "唯一登录认证关键词")) | ||
| 465 | + .andExpect(status().isOk()) | ||
| 466 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 467 | + // 命中 child + 全部祖先:grandparent 在 root 数组中 | ||
| 468 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")]").exists()) | ||
| 469 | + // grandparent.children 含 parent | ||
| 470 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")].children[?(@.iIncrement==" + p.getIIncrement() + ")]").exists()) | ||
| 471 | + // parent.children 含 child | ||
| 472 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + gp.getIIncrement() + ")].children[?(@.iIncrement==" + p.getIIncrement() + ")].children[?(@.iIncrement==" + c.getIIncrement() + ")]").exists()); | ||
| 473 | + } | ||
| 474 | + | ||
| 475 | + @Test | ||
| 476 | + void get_keywordNoMatch_returnsEmptyArray() throws Exception { | ||
| 477 | + insertExisting("sp_get_nm_" + System.nanoTime(), null); | ||
| 478 | + | ||
| 479 | + mockMvc.perform(get("/api/modules").param("keyword", "绝对不存在的关键词xyz")) | ||
| 480 | + .andExpect(status().isOk()) | ||
| 481 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 482 | + .andExpect(jsonPath("$.data").isArray()) | ||
| 483 | + .andExpect(jsonPath("$.data.length()").value(0)); | ||
| 484 | + } | ||
| 485 | + | ||
| 486 | + @Test | ||
| 487 | + void get_keywordTooLong_returns40010() throws Exception { | ||
| 488 | + String longKw = "a".repeat(51); | ||
| 489 | + mockMvc.perform(get("/api/modules").param("keyword", longKw)) | ||
| 490 | + .andExpect(status().isOk()) | ||
| 491 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 492 | + } | ||
| 493 | + | ||
| 494 | + @Test | ||
| 495 | + void get_softDeletedNotInResult() throws Exception { | ||
| 496 | + Integer id = insertExisting("sp_get_sd_" + System.nanoTime(), null); | ||
| 497 | + // 软删除该模块 | ||
| 498 | + ModuleEntity patch = new ModuleEntity(); | ||
| 499 | + patch.setIIncrement(id); | ||
| 500 | + patch.setBDeleted(true); | ||
| 501 | + moduleMapper.updateById(patch); | ||
| 502 | + | ||
| 503 | + mockMvc.perform(get("/api/modules")) | ||
| 504 | + .andExpect(status().isOk()) | ||
| 505 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")]").doesNotExist()); | ||
| 506 | + } | ||
| 507 | + | ||
| 508 | + @Test | ||
| 509 | + void get_responseExcludesInternalFields() throws Exception { | ||
| 510 | + insertExisting("sp_get_priv_" + System.nanoTime(), null); | ||
| 511 | + | ||
| 512 | + mockMvc.perform(get("/api/modules")) | ||
| 513 | + .andExpect(status().isOk()) | ||
| 514 | + // 树节点不应包含内部字段 | ||
| 515 | + .andExpect(jsonPath("$.data[0].sProcedureName").doesNotExist()) | ||
| 516 | + .andExpect(jsonPath("$.data[0].sModuleType").doesNotExist()) | ||
| 517 | + .andExpect(jsonPath("$.data[0].bShowPermission").doesNotExist()) | ||
| 518 | + .andExpect(jsonPath("$.data[0].tCreateDate").doesNotExist()) | ||
| 519 | + .andExpect(jsonPath("$.data[0].bDeleted").doesNotExist()); | ||
| 520 | + } | ||
| 521 | + | ||
| 522 | + @Test | ||
| 523 | + void get_leafNodeChildrenIsEmptyArrayNotNull() throws Exception { | ||
| 524 | + Integer id = insertExisting("sp_get_leaf_" + System.nanoTime(), null); | ||
| 525 | + | ||
| 526 | + mockMvc.perform(get("/api/modules")) | ||
| 527 | + .andExpect(status().isOk()) | ||
| 528 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")].children").isArray()) | ||
| 529 | + .andExpect(jsonPath("$.data[?(@.iIncrement==" + id + ")].children.length()").value(0)); | ||
| 530 | + } | ||
| 531 | +} |
backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.Set; | ||
| 10 | + | ||
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 12 | + | ||
| 13 | +class ModuleCreateDTOValidationTest { | ||
| 14 | + | ||
| 15 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 16 | + private final Validator validator = FACTORY.getValidator(); | ||
| 17 | + | ||
| 18 | + private ModuleCreateDTO valid() { | ||
| 19 | + ModuleCreateDTO d = new ModuleCreateDTO(); | ||
| 20 | + d.setSDisplayType("前端业务"); | ||
| 21 | + d.setSProcedureName("sp_audit_user"); | ||
| 22 | + d.setSModuleType("USR"); | ||
| 23 | + d.setSManageDeptEn("IT"); | ||
| 24 | + d.setBShowPermission(false); | ||
| 25 | + d.setSModuleNameZh("用户管理"); | ||
| 26 | + d.setIParentId(null); | ||
| 27 | + d.setISortOrder(0); | ||
| 28 | + return d; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @Test | ||
| 32 | + void allValidFields_yieldsNoViolations() { | ||
| 33 | + Set<ConstraintViolation<ModuleCreateDTO>> v = validator.validate(valid()); | ||
| 34 | + assertThat(v).isEmpty(); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + @Test | ||
| 38 | + void blankRequiredFields_yieldsViolations() { | ||
| 39 | + ModuleCreateDTO d = new ModuleCreateDTO(); | ||
| 40 | + // 全部不填,触发 5 个 @NotBlank | ||
| 41 | + Set<ConstraintViolation<ModuleCreateDTO>> v = validator.validate(d); | ||
| 42 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 43 | + .contains("sDisplayType", "sProcedureName", "sModuleType", "sManageDeptEn", "sModuleNameZh"); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + @Test | ||
| 47 | + void invalidDisplayTypeEnum_yieldsViolation() { | ||
| 48 | + ModuleCreateDTO d = valid(); | ||
| 49 | + d.setSDisplayType("非法值"); | ||
| 50 | + Set<ConstraintViolation<ModuleCreateDTO>> v = validator.validate(d); | ||
| 51 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sDisplayType"); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void overSizedFields_yieldsViolations() { | ||
| 56 | + ModuleCreateDTO d = valid(); | ||
| 57 | + d.setSProcedureName("a".repeat(101)); | ||
| 58 | + d.setSModuleType("a".repeat(51)); | ||
| 59 | + Set<ConstraintViolation<ModuleCreateDTO>> v = validator.validate(d); | ||
| 60 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 61 | + .contains("sProcedureName", "sModuleType"); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @Test | ||
| 65 | + void negativeSortOrder_yieldsViolation() { | ||
| 66 | + ModuleCreateDTO d = valid(); | ||
| 67 | + d.setISortOrder(-1); | ||
| 68 | + Set<ConstraintViolation<ModuleCreateDTO>> v = validator.validate(d); | ||
| 69 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("iSortOrder"); | ||
| 70 | + } | ||
| 71 | +} |
backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.Set; | ||
| 10 | + | ||
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 12 | + | ||
| 13 | +class ModuleUpdateDTOValidationTest { | ||
| 14 | + | ||
| 15 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 16 | + private final Validator validator = FACTORY.getValidator(); | ||
| 17 | + | ||
| 18 | + private ModuleUpdateDTO valid() { | ||
| 19 | + ModuleUpdateDTO d = new ModuleUpdateDTO(); | ||
| 20 | + d.setSDisplayType("前端业务"); | ||
| 21 | + d.setSModuleType("USR"); | ||
| 22 | + d.setSManageDeptEn("IT"); | ||
| 23 | + d.setBShowPermission(true); | ||
| 24 | + d.setSModuleNameZh("用户管理(修订)"); | ||
| 25 | + d.setIParentId(null); | ||
| 26 | + d.setISortOrder(0); | ||
| 27 | + return d; | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + @Test | ||
| 31 | + void allValidFields_yieldsNoViolations() { | ||
| 32 | + Set<ConstraintViolation<ModuleUpdateDTO>> v = validator.validate(valid()); | ||
| 33 | + assertThat(v).isEmpty(); | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + @Test | ||
| 37 | + void blankRequiredFields_yieldsViolations() { | ||
| 38 | + ModuleUpdateDTO d = new ModuleUpdateDTO(); | ||
| 39 | + Set<ConstraintViolation<ModuleUpdateDTO>> v = validator.validate(d); | ||
| 40 | + // 5 个 @NotBlank:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh | ||
| 41 | + // (bShowPermission / iParentId / iSortOrder 可空) | ||
| 42 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 43 | + .contains("sDisplayType", "sModuleType", "sManageDeptEn", "sModuleNameZh"); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + @Test | ||
| 47 | + void invalidDisplayTypeEnum_yieldsViolation() { | ||
| 48 | + ModuleUpdateDTO d = valid(); | ||
| 49 | + d.setSDisplayType("非法值"); | ||
| 50 | + Set<ConstraintViolation<ModuleUpdateDTO>> v = validator.validate(d); | ||
| 51 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sDisplayType"); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void negativeSortOrder_yieldsViolation() { | ||
| 56 | + ModuleUpdateDTO d = valid(); | ||
| 57 | + d.setISortOrder(-1); | ||
| 58 | + Set<ConstraintViolation<ModuleUpdateDTO>> v = validator.validate(d); | ||
| 59 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("iSortOrder"); | ||
| 60 | + } | ||
| 61 | +} |
backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.mapper; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | ||
| 4 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 5 | +import org.junit.jupiter.api.Test; | ||
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 8 | +import org.springframework.test.annotation.Rollback; | ||
| 9 | +import org.springframework.test.context.ActiveProfiles; | ||
| 10 | +import org.springframework.transaction.annotation.Transactional; | ||
| 11 | + | ||
| 12 | +import java.time.LocalDateTime; | ||
| 13 | + | ||
| 14 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 15 | + | ||
| 16 | +@SpringBootTest | ||
| 17 | +@ActiveProfiles("test") | ||
| 18 | +@Transactional | ||
| 19 | +@Rollback | ||
| 20 | +class ModuleMapperIT { | ||
| 21 | + | ||
| 22 | + @Autowired ModuleMapper moduleMapper; | ||
| 23 | + | ||
| 24 | + @Test | ||
| 25 | + void insertAndSelectById_persistsAllFields() { | ||
| 26 | + ModuleEntity e = new ModuleEntity(); | ||
| 27 | + e.setSDisplayType("前端业务"); | ||
| 28 | + e.setSProcedureName("sp_audit_test_" + System.nanoTime()); | ||
| 29 | + e.setSModuleType("USR"); | ||
| 30 | + e.setSManageDeptEn("IT"); | ||
| 31 | + e.setBShowPermission(false); | ||
| 32 | + e.setSModuleNameZh("测试模块"); | ||
| 33 | + e.setIParentId(null); | ||
| 34 | + e.setISortOrder(0); | ||
| 35 | + e.setBDeleted(false); | ||
| 36 | + e.setTCreateDate(LocalDateTime.now()); | ||
| 37 | + | ||
| 38 | + int rows = moduleMapper.insert(e); | ||
| 39 | + assertThat(rows).isEqualTo(1); | ||
| 40 | + assertThat(e.getIIncrement()).isNotNull().isPositive(); | ||
| 41 | + | ||
| 42 | + ModuleEntity loaded = moduleMapper.selectById(e.getIIncrement()); | ||
| 43 | + assertThat(loaded).isNotNull(); | ||
| 44 | + assertThat(loaded.getSDisplayType()).isEqualTo("前端业务"); | ||
| 45 | + assertThat(loaded.getSProcedureName()).isEqualTo(e.getSProcedureName()); | ||
| 46 | + assertThat(loaded.getSModuleType()).isEqualTo("USR"); | ||
| 47 | + assertThat(loaded.getSManageDeptEn()).isEqualTo("IT"); | ||
| 48 | + assertThat(loaded.getBShowPermission()).isFalse(); | ||
| 49 | + assertThat(loaded.getSModuleNameZh()).isEqualTo("测试模块"); | ||
| 50 | + assertThat(loaded.getIParentId()).isNull(); | ||
| 51 | + assertThat(loaded.getISortOrder()).isZero(); | ||
| 52 | + assertThat(loaded.getBDeleted()).isFalse(); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + void selectCountByProcedureName_returnsExisting() { | ||
| 57 | + String name = "sp_audit_uniq_" + System.nanoTime(); | ||
| 58 | + ModuleEntity e = new ModuleEntity(); | ||
| 59 | + e.setSDisplayType("接口"); | ||
| 60 | + e.setSProcedureName(name); | ||
| 61 | + e.setSModuleType("MOD"); | ||
| 62 | + e.setSManageDeptEn("IT"); | ||
| 63 | + e.setBShowPermission(false); | ||
| 64 | + e.setSModuleNameZh("唯一性检测"); | ||
| 65 | + e.setISortOrder(0); | ||
| 66 | + e.setBDeleted(false); | ||
| 67 | + e.setTCreateDate(LocalDateTime.now()); | ||
| 68 | + moduleMapper.insert(e); | ||
| 69 | + | ||
| 70 | + Long count = moduleMapper.selectCount( | ||
| 71 | + new LambdaQueryWrapper<ModuleEntity>() | ||
| 72 | + .eq(ModuleEntity::getSProcedureName, name) | ||
| 73 | + .eq(ModuleEntity::getBDeleted, false) | ||
| 74 | + ); | ||
| 75 | + assertThat(count).isEqualTo(1L); | ||
| 76 | + } | ||
| 77 | +} |
backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.mod.service; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.Wrapper; | ||
| 4 | +import com.xly.erp.common.exception.BizException; | ||
| 5 | +import com.xly.erp.common.response.ErrorCode; | ||
| 6 | +import com.xly.erp.module.mod.dto.ModuleCreateDTO; | ||
| 7 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 8 | +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | ||
| 9 | +import com.xly.erp.module.mod.entity.ModuleEntity; | ||
| 10 | +import com.xly.erp.module.mod.mapper.ModuleMapper; | ||
| 11 | +import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; | ||
| 12 | +import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | ||
| 13 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 14 | +import com.xly.erp.module.mod.vo.ModuleVO; | ||
| 15 | +import org.junit.jupiter.api.Test; | ||
| 16 | +import org.junit.jupiter.api.extension.ExtendWith; | ||
| 17 | +import org.mockito.ArgumentCaptor; | ||
| 18 | +import org.mockito.ArgumentMatchers; | ||
| 19 | +import org.mockito.InjectMocks; | ||
| 20 | +import org.mockito.Mock; | ||
| 21 | +import org.mockito.junit.jupiter.MockitoExtension; | ||
| 22 | +import org.springframework.dao.DuplicateKeyException; | ||
| 23 | + | ||
| 24 | +import java.time.LocalDateTime; | ||
| 25 | +import java.util.Arrays; | ||
| 26 | +import java.util.Collections; | ||
| 27 | +import java.util.List; | ||
| 28 | + | ||
| 29 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 30 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 31 | +import static org.mockito.ArgumentMatchers.any; | ||
| 32 | +import static org.mockito.ArgumentMatchers.isNull; | ||
| 33 | +import static org.mockito.Mockito.never; | ||
| 34 | +import static org.mockito.Mockito.verify; | ||
| 35 | +import static org.mockito.Mockito.when; | ||
| 36 | + | ||
| 37 | +@ExtendWith(MockitoExtension.class) | ||
| 38 | +class ModuleServiceImplTest { | ||
| 39 | + | ||
| 40 | + @Mock ModuleMapper moduleMapper; | ||
| 41 | + | ||
| 42 | + @InjectMocks ModuleServiceImpl service; | ||
| 43 | + | ||
| 44 | + private ModuleCreateDTO baseDto() { | ||
| 45 | + ModuleCreateDTO d = new ModuleCreateDTO(); | ||
| 46 | + d.setSDisplayType("前端业务"); | ||
| 47 | + d.setSProcedureName("sp_audit_user"); | ||
| 48 | + d.setSModuleType("USR"); | ||
| 49 | + d.setSManageDeptEn("IT"); | ||
| 50 | + d.setBShowPermission(false); | ||
| 51 | + d.setSModuleNameZh("用户管理"); | ||
| 52 | + return d; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + void create_rootModule_returnsVOWithGeneratedId() { | ||
| 57 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 58 | + when(moduleMapper.insert((ModuleEntity) any())).thenAnswer(inv -> { | ||
| 59 | + ModuleEntity e = inv.getArgument(0); | ||
| 60 | + e.setIIncrement(123); | ||
| 61 | + return 1; | ||
| 62 | + }); | ||
| 63 | + | ||
| 64 | + ModuleVO vo = service.create(baseDto()); | ||
| 65 | + | ||
| 66 | + assertThat(vo.getIIncrement()).isEqualTo(123); | ||
| 67 | + assertThat(vo.getSProcedureName()).isEqualTo("sp_audit_user"); | ||
| 68 | + assertThat(vo.getBShowPermission()).isFalse(); | ||
| 69 | + assertThat(vo.getISortOrder()).isZero(); | ||
| 70 | + assertThat(vo.getBDeleted()).isFalse(); | ||
| 71 | + assertThat(vo.getTCreateDate()).isNotNull(); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + @Test | ||
| 75 | + void create_childModule_validatesParentExists() { | ||
| 76 | + ModuleCreateDTO d = baseDto(); | ||
| 77 | + d.setIParentId(7); | ||
| 78 | + | ||
| 79 | + ModuleEntity parent = new ModuleEntity(); | ||
| 80 | + parent.setIIncrement(7); | ||
| 81 | + parent.setBDeleted(false); | ||
| 82 | + when(moduleMapper.selectById(7)).thenReturn(parent); | ||
| 83 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 84 | + when(moduleMapper.insert((ModuleEntity) any())).thenAnswer(inv -> { | ||
| 85 | + ModuleEntity e = inv.getArgument(0); | ||
| 86 | + e.setIIncrement(8); | ||
| 87 | + return 1; | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + ModuleVO vo = service.create(d); | ||
| 91 | + assertThat(vo.getIIncrement()).isEqualTo(8); | ||
| 92 | + assertThat(vo.getIParentId()).isEqualTo(7); | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + @Test | ||
| 96 | + void create_parentNotFound_throwsBizException40411() { | ||
| 97 | + ModuleCreateDTO d = baseDto(); | ||
| 98 | + d.setIParentId(999999); | ||
| 99 | + when(moduleMapper.selectById(999999)).thenReturn(null); | ||
| 100 | + | ||
| 101 | + assertThatThrownBy(() -> service.create(d)) | ||
| 102 | + .isInstanceOf(BizException.class) | ||
| 103 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 104 | + .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()); | ||
| 105 | + verify(moduleMapper, never()).insert((ModuleEntity) any()); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + @Test | ||
| 109 | + void create_parentSoftDeleted_throwsBizException40411() { | ||
| 110 | + ModuleCreateDTO d = baseDto(); | ||
| 111 | + d.setIParentId(5); | ||
| 112 | + | ||
| 113 | + ModuleEntity deleted = new ModuleEntity(); | ||
| 114 | + deleted.setIIncrement(5); | ||
| 115 | + deleted.setBDeleted(true); | ||
| 116 | + when(moduleMapper.selectById(5)).thenReturn(deleted); | ||
| 117 | + | ||
| 118 | + assertThatThrownBy(() -> service.create(d)) | ||
| 119 | + .isInstanceOf(BizException.class) | ||
| 120 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 121 | + .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + @Test | ||
| 125 | + void create_duplicateProcedureName_preCheck_throwsBizException40911() { | ||
| 126 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(1L); | ||
| 127 | + | ||
| 128 | + assertThatThrownBy(() -> service.create(baseDto())) | ||
| 129 | + .isInstanceOf(BizException.class) | ||
| 130 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 131 | + .isEqualTo(ErrorCode.MOD_PROC_NAME_DUP.getCode()); | ||
| 132 | + verify(moduleMapper, never()).insert((ModuleEntity) any()); | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + @Test | ||
| 136 | + void create_duplicateProcedureName_concurrentInsert_throwsBizException40911() { | ||
| 137 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 138 | + when(moduleMapper.insert((ModuleEntity) any())) | ||
| 139 | + .thenThrow(new DuplicateKeyException("uk_procedure_name")); | ||
| 140 | + | ||
| 141 | + assertThatThrownBy(() -> service.create(baseDto())) | ||
| 142 | + .isInstanceOf(BizException.class) | ||
| 143 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 144 | + .isEqualTo(ErrorCode.MOD_PROC_NAME_DUP.getCode()); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + // ============================================================ | ||
| 148 | + // REQ-MOD-002 update 系列 | ||
| 149 | + // ============================================================ | ||
| 150 | + | ||
| 151 | + private ModuleUpdateDTO updateDto() { | ||
| 152 | + ModuleUpdateDTO d = new ModuleUpdateDTO(); | ||
| 153 | + d.setSDisplayType("系统配置"); | ||
| 154 | + d.setSModuleType("USR_REVISED"); | ||
| 155 | + d.setSManageDeptEn("OPS"); | ||
| 156 | + d.setBShowPermission(true); | ||
| 157 | + d.setSModuleNameZh("用户管理(修订)"); | ||
| 158 | + d.setIParentId(null); | ||
| 159 | + d.setISortOrder(5); | ||
| 160 | + return d; | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + private ModuleEntity existingTarget(int id) { | ||
| 164 | + ModuleEntity t = new ModuleEntity(); | ||
| 165 | + t.setIIncrement(id); | ||
| 166 | + t.setSDisplayType("前端业务"); | ||
| 167 | + t.setSProcedureName("sp_audit_existing"); | ||
| 168 | + t.setSModuleType("USR"); | ||
| 169 | + t.setSManageDeptEn("IT"); | ||
| 170 | + t.setBShowPermission(false); | ||
| 171 | + t.setSModuleNameZh("用户管理"); | ||
| 172 | + t.setIParentId(null); | ||
| 173 | + t.setISortOrder(0); | ||
| 174 | + t.setBDeleted(false); | ||
| 175 | + t.setTCreateDate(LocalDateTime.of(2026, 1, 1, 0, 0)); | ||
| 176 | + t.setSCreatedBy("admin"); | ||
| 177 | + return t; | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + @Test | ||
| 181 | + void update_targetNotFound_throws40421() { | ||
| 182 | + when(moduleMapper.selectById(10)).thenReturn(null); | ||
| 183 | + | ||
| 184 | + assertThatThrownBy(() -> service.update(10, updateDto())) | ||
| 185 | + .isInstanceOf(BizException.class) | ||
| 186 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 187 | + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + @Test | ||
| 191 | + void update_targetSoftDeleted_throws40421() { | ||
| 192 | + ModuleEntity t = existingTarget(11); | ||
| 193 | + t.setBDeleted(true); | ||
| 194 | + when(moduleMapper.selectById(11)).thenReturn(t); | ||
| 195 | + | ||
| 196 | + assertThatThrownBy(() -> service.update(11, updateDto())) | ||
| 197 | + .isInstanceOf(BizException.class) | ||
| 198 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 199 | + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + @Test | ||
| 203 | + void update_parentSelfReference_throws40921() { | ||
| 204 | + ModuleEntity t = existingTarget(12); | ||
| 205 | + when(moduleMapper.selectById(12)).thenReturn(t); | ||
| 206 | + ModuleUpdateDTO d = updateDto(); | ||
| 207 | + d.setIParentId(12); | ||
| 208 | + | ||
| 209 | + assertThatThrownBy(() -> service.update(12, d)) | ||
| 210 | + .isInstanceOf(BizException.class) | ||
| 211 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 212 | + .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode()); | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + @Test | ||
| 216 | + void update_parentNotFound_throws40411() { | ||
| 217 | + ModuleEntity t = existingTarget(13); | ||
| 218 | + when(moduleMapper.selectById(13)).thenReturn(t); | ||
| 219 | + when(moduleMapper.selectById(999999)).thenReturn(null); | ||
| 220 | + ModuleUpdateDTO d = updateDto(); | ||
| 221 | + d.setIParentId(999999); | ||
| 222 | + | ||
| 223 | + assertThatThrownBy(() -> service.update(13, d)) | ||
| 224 | + .isInstanceOf(BizException.class) | ||
| 225 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 226 | + .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()); | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + @Test | ||
| 230 | + void update_parentIsDescendant_throws40921() { | ||
| 231 | + // 三层结构: 14(target) <- 20(child) <- 30(grand) | ||
| 232 | + ModuleEntity target = existingTarget(14); | ||
| 233 | + ModuleEntity child = existingTarget(20); | ||
| 234 | + child.setIParentId(14); | ||
| 235 | + ModuleEntity grand = existingTarget(30); | ||
| 236 | + grand.setIParentId(20); | ||
| 237 | + | ||
| 238 | + when(moduleMapper.selectById(14)).thenReturn(target); | ||
| 239 | + when(moduleMapper.selectById(30)).thenReturn(grand); | ||
| 240 | + when(moduleMapper.selectById(20)).thenReturn(child); | ||
| 241 | + | ||
| 242 | + ModuleUpdateDTO d = updateDto(); | ||
| 243 | + d.setIParentId(30); // 想把 14 的父设成 30,但 30 是 14 的孙子 → 环路 | ||
| 244 | + | ||
| 245 | + assertThatThrownBy(() -> service.update(14, d)) | ||
| 246 | + .isInstanceOf(BizException.class) | ||
| 247 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 248 | + .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode()); | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + @Test | ||
| 252 | + void update_full_returnsVOWithUpdatedFields() { | ||
| 253 | + ModuleEntity target = existingTarget(15); | ||
| 254 | + when(moduleMapper.selectById(15)).thenReturn(target); | ||
| 255 | + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); | ||
| 256 | + | ||
| 257 | + ModuleVO vo = service.update(15, updateDto()); | ||
| 258 | + | ||
| 259 | + ArgumentCaptor<ModuleEntity> cap = ArgumentCaptor.forClass(ModuleEntity.class); | ||
| 260 | + verify(moduleMapper).updateById(cap.capture()); | ||
| 261 | + ModuleEntity saved = cap.getValue(); | ||
| 262 | + | ||
| 263 | + // 已修改字段 | ||
| 264 | + assertThat(saved.getSDisplayType()).isEqualTo("系统配置"); | ||
| 265 | + assertThat(saved.getSModuleType()).isEqualTo("USR_REVISED"); | ||
| 266 | + assertThat(saved.getSManageDeptEn()).isEqualTo("OPS"); | ||
| 267 | + assertThat(saved.getSModuleNameZh()).isEqualTo("用户管理(修订)"); | ||
| 268 | + assertThat(saved.getBShowPermission()).isTrue(); | ||
| 269 | + assertThat(saved.getISortOrder()).isEqualTo(5); | ||
| 270 | + assertThat(saved.getIParentId()).isNull(); | ||
| 271 | + // 保持原值 | ||
| 272 | + assertThat(saved.getIIncrement()).isEqualTo(15); | ||
| 273 | + assertThat(saved.getSProcedureName()).isEqualTo("sp_audit_existing"); | ||
| 274 | + assertThat(saved.getTCreateDate()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0)); | ||
| 275 | + assertThat(saved.getSCreatedBy()).isEqualTo("admin"); | ||
| 276 | + assertThat(saved.getBDeleted()).isFalse(); | ||
| 277 | + | ||
| 278 | + assertThat(vo.getSDisplayType()).isEqualTo("系统配置"); | ||
| 279 | + assertThat(vo.getSProcedureName()).isEqualTo("sp_audit_existing"); | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + @Test | ||
| 283 | + void update_partialNullFields_keepsOriginalValues() { | ||
| 284 | + ModuleEntity target = existingTarget(16); | ||
| 285 | + target.setBShowPermission(true); | ||
| 286 | + target.setISortOrder(99); | ||
| 287 | + when(moduleMapper.selectById(16)).thenReturn(target); | ||
| 288 | + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); | ||
| 289 | + | ||
| 290 | + ModuleUpdateDTO d = updateDto(); | ||
| 291 | + d.setBShowPermission(null); | ||
| 292 | + d.setISortOrder(null); | ||
| 293 | + | ||
| 294 | + service.update(16, d); | ||
| 295 | + | ||
| 296 | + ArgumentCaptor<ModuleEntity> cap = ArgumentCaptor.forClass(ModuleEntity.class); | ||
| 297 | + verify(moduleMapper).updateById(cap.capture()); | ||
| 298 | + ModuleEntity saved = cap.getValue(); | ||
| 299 | + assertThat(saved.getBShowPermission()).isTrue(); // 原值保留 | ||
| 300 | + assertThat(saved.getISortOrder()).isEqualTo(99); // 原值保留 | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + @Test | ||
| 304 | + void update_clearParent_setsParentToNull() { | ||
| 305 | + ModuleEntity target = existingTarget(17); | ||
| 306 | + target.setIParentId(7); // 原本有父 | ||
| 307 | + when(moduleMapper.selectById(17)).thenReturn(target); | ||
| 308 | + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); | ||
| 309 | + | ||
| 310 | + ModuleUpdateDTO d = updateDto(); | ||
| 311 | + d.setIParentId(null); // 显式清空 | ||
| 312 | + | ||
| 313 | + service.update(17, d); | ||
| 314 | + | ||
| 315 | + ArgumentCaptor<ModuleEntity> cap = ArgumentCaptor.forClass(ModuleEntity.class); | ||
| 316 | + verify(moduleMapper).updateById(cap.capture()); | ||
| 317 | + assertThat(cap.getValue().getIParentId()).isNull(); | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + // ============================================================ | ||
| 321 | + // REQ-MOD-003 delete 系列 | ||
| 322 | + // ============================================================ | ||
| 323 | + | ||
| 324 | + @Test | ||
| 325 | + void delete_targetNotFound_throws40421() { | ||
| 326 | + when(moduleMapper.selectById(20)).thenReturn(null); | ||
| 327 | + | ||
| 328 | + assertThatThrownBy(() -> service.delete(20)) | ||
| 329 | + .isInstanceOf(BizException.class) | ||
| 330 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 331 | + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + @Test | ||
| 335 | + void delete_targetAlreadyDeleted_throws40421() { | ||
| 336 | + ModuleEntity t = existingTarget(21); | ||
| 337 | + t.setBDeleted(true); | ||
| 338 | + when(moduleMapper.selectById(21)).thenReturn(t); | ||
| 339 | + | ||
| 340 | + assertThatThrownBy(() -> service.delete(21)) | ||
| 341 | + .isInstanceOf(BizException.class) | ||
| 342 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 343 | + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + @Test | ||
| 347 | + void delete_hasUndeletedChildren_throws40912() { | ||
| 348 | + ModuleEntity t = existingTarget(22); | ||
| 349 | + when(moduleMapper.selectById(22)).thenReturn(t); | ||
| 350 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(3L); | ||
| 351 | + | ||
| 352 | + assertThatThrownBy(() -> service.delete(22)) | ||
| 353 | + .isInstanceOf(BizException.class) | ||
| 354 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 355 | + .isEqualTo(ErrorCode.MOD_HAS_REFERENCES.getCode()); | ||
| 356 | + } | ||
| 357 | + | ||
| 358 | + @Test | ||
| 359 | + void delete_leafModule_writesSoftDeleteFields_returnsResult() { | ||
| 360 | + ModuleEntity t = existingTarget(23); | ||
| 361 | + when(moduleMapper.selectById(23)).thenReturn(t); | ||
| 362 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 363 | + when(moduleMapper.update((ModuleEntity) any(), (Wrapper<ModuleEntity>) any())).thenReturn(1); | ||
| 364 | + | ||
| 365 | + ModuleDeleteResultVO vo = service.delete(23); | ||
| 366 | + | ||
| 367 | + assertThat(vo.getIIncrement()).isEqualTo(23); | ||
| 368 | + assertThat(vo.getBDeleted()).isTrue(); | ||
| 369 | + // SET 列由 LambdaUpdateWrapper 声明,entity 第一参数为 null(避免 iParentId.IGNORED 策略副作用) | ||
| 370 | + // 实际 SQL SET 子句的列覆盖由 IT (delete_preservesOtherFields_onChildModule) 验证 | ||
| 371 | + verify(moduleMapper).update((ModuleEntity) isNull(), (Wrapper<ModuleEntity>) any()); | ||
| 372 | + } | ||
| 373 | + | ||
| 374 | + @Test | ||
| 375 | + void delete_softDeletedChildren_doesNotBlock() { | ||
| 376 | + ModuleEntity t = existingTarget(24); | ||
| 377 | + when(moduleMapper.selectById(24)).thenReturn(t); | ||
| 378 | + // 子全部已软删除 → selectCount(bDeleted=0 过滤) 返回 0 | ||
| 379 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 380 | + when(moduleMapper.update((ModuleEntity) any(), (Wrapper<ModuleEntity>) any())).thenReturn(1); | ||
| 381 | + | ||
| 382 | + ModuleDeleteResultVO vo = service.delete(24); | ||
| 383 | + assertThat(vo.getBDeleted()).isTrue(); | ||
| 384 | + } | ||
| 385 | + | ||
| 386 | + @Test | ||
| 387 | + void delete_concurrentRace_throws40421() { | ||
| 388 | + ModuleEntity t = existingTarget(25); | ||
| 389 | + when(moduleMapper.selectById(25)).thenReturn(t); | ||
| 390 | + when(moduleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 391 | + // update 影响行数 0 → 视为并发删除 | ||
| 392 | + when(moduleMapper.update((ModuleEntity) any(), (Wrapper<ModuleEntity>) any())).thenReturn(0); | ||
| 393 | + | ||
| 394 | + assertThatThrownBy(() -> service.delete(25)) | ||
| 395 | + .isInstanceOf(BizException.class) | ||
| 396 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 397 | + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + // ============================================================ | ||
| 401 | + // REQ-MOD-004 tree 系列 | ||
| 402 | + // ============================================================ | ||
| 403 | + | ||
| 404 | + private ModuleEntity buildModule(int id, Integer parentId, String name, int sortOrder) { | ||
| 405 | + ModuleEntity e = new ModuleEntity(); | ||
| 406 | + e.setIIncrement(id); | ||
| 407 | + e.setIParentId(parentId); | ||
| 408 | + e.setSModuleNameZh(name); | ||
| 409 | + e.setSDisplayType("前端业务"); | ||
| 410 | + e.setSManageDeptEn("IT"); | ||
| 411 | + e.setSProcedureName("sp_" + id); | ||
| 412 | + e.setSModuleType("MOD"); | ||
| 413 | + e.setBShowPermission(false); | ||
| 414 | + e.setISortOrder(sortOrder); | ||
| 415 | + e.setBDeleted(false); | ||
| 416 | + e.setTCreateDate(LocalDateTime.now()); | ||
| 417 | + return e; | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + @Test | ||
| 421 | + void tree_emptyDb_returnsEmptyList() { | ||
| 422 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); | ||
| 423 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | ||
| 424 | + assertThat(result).isEmpty(); | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + @Test | ||
| 428 | + void tree_singleRoot_returnsOneNodeWithEmptyChildren() { | ||
| 429 | + when(moduleMapper.selectList(any(Wrapper.class))) | ||
| 430 | + .thenReturn(Arrays.asList(buildModule(1, null, "系统配置", 0))); | ||
| 431 | + | ||
| 432 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | ||
| 433 | + assertThat(result).hasSize(1); | ||
| 434 | + assertThat(result.get(0).getIIncrement()).isEqualTo(1); | ||
| 435 | + assertThat(result.get(0).getChildren()).isNotNull().isEmpty(); | ||
| 436 | + } | ||
| 437 | + | ||
| 438 | + @Test | ||
| 439 | + void tree_multiLevel_buildsNestedStructureSortedByISortOrder() { | ||
| 440 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | ||
| 441 | + buildModule(1, null, "RootB", 2), | ||
| 442 | + buildModule(2, null, "RootA", 1), | ||
| 443 | + buildModule(3, 1, "ChildOfB", 0) | ||
| 444 | + )); | ||
| 445 | + | ||
| 446 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | ||
| 447 | + | ||
| 448 | + // 根节点按 sortOrder 升序:RootA (sort=1) 在前,RootB (sort=2) 在后 | ||
| 449 | + assertThat(result).hasSize(2); | ||
| 450 | + assertThat(result.get(0).getIIncrement()).isEqualTo(2); // RootA | ||
| 451 | + assertThat(result.get(1).getIIncrement()).isEqualTo(1); // RootB | ||
| 452 | + // RootB 的 children 含 ChildOfB | ||
| 453 | + assertThat(result.get(1).getChildren()).hasSize(1); | ||
| 454 | + assertThat(result.get(1).getChildren().get(0).getIIncrement()).isEqualTo(3); | ||
| 455 | + // RootA 是叶子 | ||
| 456 | + assertThat(result.get(0).getChildren()).isEmpty(); | ||
| 457 | + } | ||
| 458 | + | ||
| 459 | + @Test | ||
| 460 | + void tree_keywordHit_includesAncestorChain() { | ||
| 461 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | ||
| 462 | + buildModule(1, null, "系统配置", 0), | ||
| 463 | + buildModule(2, 1, "用户管理", 0), | ||
| 464 | + buildModule(3, 2, "登录", 0), | ||
| 465 | + buildModule(99, null, "无关模块", 0) | ||
| 466 | + )); | ||
| 467 | + | ||
| 468 | + ModuleQueryDTO q = new ModuleQueryDTO(); | ||
| 469 | + q.setKeyword("登录"); | ||
| 470 | + List<ModuleTreeNodeVO> result = service.tree(q); | ||
| 471 | + | ||
| 472 | + // 应返回 1 → 2 → 3 三层链;不含 99 | ||
| 473 | + assertThat(result).hasSize(1); | ||
| 474 | + ModuleTreeNodeVO root = result.get(0); | ||
| 475 | + assertThat(root.getIIncrement()).isEqualTo(1); | ||
| 476 | + assertThat(root.getChildren()).hasSize(1); | ||
| 477 | + ModuleTreeNodeVO mid = root.getChildren().get(0); | ||
| 478 | + assertThat(mid.getIIncrement()).isEqualTo(2); | ||
| 479 | + assertThat(mid.getChildren()).hasSize(1); | ||
| 480 | + assertThat(mid.getChildren().get(0).getIIncrement()).isEqualTo(3); | ||
| 481 | + } | ||
| 482 | + | ||
| 483 | + @Test | ||
| 484 | + void tree_keywordNoMatch_returnsEmptyList() { | ||
| 485 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | ||
| 486 | + buildModule(1, null, "系统配置", 0), | ||
| 487 | + buildModule(2, null, "权限分配", 0) | ||
| 488 | + )); | ||
| 489 | + | ||
| 490 | + ModuleQueryDTO q = new ModuleQueryDTO(); | ||
| 491 | + q.setKeyword("不存在的关键词"); | ||
| 492 | + List<ModuleTreeNodeVO> result = service.tree(q); | ||
| 493 | + assertThat(result).isEmpty(); | ||
| 494 | + } | ||
| 495 | + | ||
| 496 | + @Test | ||
| 497 | + void tree_softDeletedExcluded_passesBDeletedZeroToMapper() { | ||
| 498 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); | ||
| 499 | + | ||
| 500 | + service.tree(new ModuleQueryDTO()); | ||
| 501 | + | ||
| 502 | + // 验证调用了 selectList 一次(mapper 端用 wrapper.eq(bDeleted, false) 已是 service 实现细节, | ||
| 503 | + // 这里仅验证 service 调用了 selectList,wrapper 形状由 IT 在真实 DB 上 cross-check) | ||
| 504 | + verify(moduleMapper).selectList(any(Wrapper.class)); | ||
| 505 | + } | ||
| 506 | +} |
backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.erp.module.usr.dto.LoginDTO; | ||
| 5 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 6 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 7 | +import com.xly.erp.module.usr.security.InMemoryLoginAttemptStore; | ||
| 8 | +import com.xly.erp.module.usr.security.JwtTokenProvider; | ||
| 9 | +import io.jsonwebtoken.Claims; | ||
| 10 | +import org.junit.jupiter.api.BeforeEach; | ||
| 11 | +import org.junit.jupiter.api.Test; | ||
| 12 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 13 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | ||
| 14 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 15 | +import org.springframework.http.MediaType; | ||
| 16 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 17 | +import org.springframework.test.annotation.Rollback; | ||
| 18 | +import org.springframework.test.context.ActiveProfiles; | ||
| 19 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 20 | +import org.springframework.test.web.servlet.MvcResult; | ||
| 21 | +import org.springframework.transaction.annotation.Transactional; | ||
| 22 | + | ||
| 23 | +import java.time.LocalDateTime; | ||
| 24 | + | ||
| 25 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 26 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
| 27 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 28 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 29 | + | ||
| 30 | +@SpringBootTest | ||
| 31 | +@AutoConfigureMockMvc | ||
| 32 | +@ActiveProfiles("test") | ||
| 33 | +@Transactional | ||
| 34 | +@Rollback | ||
| 35 | +class LoginControllerIT { | ||
| 36 | + | ||
| 37 | + @Autowired MockMvc mockMvc; | ||
| 38 | + @Autowired ObjectMapper objectMapper; | ||
| 39 | + @Autowired UserMapper userMapper; | ||
| 40 | + @Autowired PasswordEncoder passwordEncoder; | ||
| 41 | + @Autowired InMemoryLoginAttemptStore attemptStore; | ||
| 42 | + @Autowired JwtTokenProvider jwtTokenProvider; | ||
| 43 | + | ||
| 44 | + private String userName; | ||
| 45 | + | ||
| 46 | + @BeforeEach | ||
| 47 | + void setUp() { | ||
| 48 | + userName = "login_" + System.nanoTime(); | ||
| 49 | + // 每个测试在 store 里清干净(store 是 Spring singleton 跨测试存活) | ||
| 50 | + attemptStore.clear(userName); | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + private Integer insertUser(String pw) { | ||
| 54 | + UserEntity u = new UserEntity(); | ||
| 55 | + u.setSUserNo("uno_" + System.nanoTime()); | ||
| 56 | + u.setSUserName(userName); | ||
| 57 | + u.setSUserType("普通用户"); | ||
| 58 | + u.setSLanguage("zh"); | ||
| 59 | + u.setBCanModifyDocs(false); | ||
| 60 | + u.setSPasswordHash(passwordEncoder.encode(pw)); | ||
| 61 | + u.setBDeleted(false); | ||
| 62 | + u.setTCreateDate(LocalDateTime.now()); | ||
| 63 | + userMapper.insert(u); | ||
| 64 | + return u.getIIncrement(); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + private LoginDTO loginDto(String name, String pw) { | ||
| 68 | + LoginDTO d = new LoginDTO(); | ||
| 69 | + d.setSUserName(name); | ||
| 70 | + d.setSPassword(pw); | ||
| 71 | + d.setSVersion("standard"); | ||
| 72 | + return d; | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } | ||
| 76 | + | ||
| 77 | + @Test | ||
| 78 | + void login_validCredentials_returns200WithToken() throws Exception { | ||
| 79 | + insertUser("666666"); | ||
| 80 | + | ||
| 81 | + mockMvc.perform(post("/api/auth/login") | ||
| 82 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 83 | + .content(json(loginDto(userName, "666666")))) | ||
| 84 | + .andExpect(status().isOk()) | ||
| 85 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 86 | + .andExpect(jsonPath("$.data.accessToken").isString()) | ||
| 87 | + .andExpect(jsonPath("$.data.expiresIn").value(7200)) | ||
| 88 | + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) | ||
| 89 | + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")); | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + @Test | ||
| 93 | + void login_jwtClaimsAreCorrect() throws Exception { | ||
| 94 | + Integer userId = insertUser("666666"); | ||
| 95 | + | ||
| 96 | + MvcResult result = mockMvc.perform(post("/api/auth/login") | ||
| 97 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 98 | + .content(json(loginDto(userName, "666666")))) | ||
| 99 | + .andExpect(status().isOk()) | ||
| 100 | + .andReturn(); | ||
| 101 | + | ||
| 102 | + String body = result.getResponse().getContentAsString(); | ||
| 103 | + String token = objectMapper.readTree(body).path("data").path("accessToken").asText(); | ||
| 104 | + assertThat(token).isNotEmpty(); | ||
| 105 | + | ||
| 106 | + Claims claims = jwtTokenProvider.parse(token); | ||
| 107 | + assertThat(claims.getSubject()).isEqualTo(userName); | ||
| 108 | + assertThat(claims.get("uid", Integer.class)).isEqualTo(userId); | ||
| 109 | + assertThat(claims.get("type", String.class)).isEqualTo("普通用户"); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + @Test | ||
| 113 | + void login_invalidUsername_returns40101() throws Exception { | ||
| 114 | + // 不插入用户 | ||
| 115 | + mockMvc.perform(post("/api/auth/login") | ||
| 116 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 117 | + .content(json(loginDto("ghost_" + System.nanoTime(), "any")))) | ||
| 118 | + .andExpect(status().isOk()) | ||
| 119 | + .andExpect(jsonPath("$.code").value(40101)); | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + @Test | ||
| 123 | + void login_wrongPassword_returns40101() throws Exception { | ||
| 124 | + insertUser("666666"); | ||
| 125 | + | ||
| 126 | + mockMvc.perform(post("/api/auth/login") | ||
| 127 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 128 | + .content(json(loginDto(userName, "wrong_password")))) | ||
| 129 | + .andExpect(status().isOk()) | ||
| 130 | + .andExpect(jsonPath("$.code").value(40101)); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + @Test | ||
| 134 | + void login_softDeletedUser_returns40101() throws Exception { | ||
| 135 | + Integer userId = insertUser("666666"); | ||
| 136 | + UserEntity patch = new UserEntity(); | ||
| 137 | + patch.setIIncrement(userId); | ||
| 138 | + patch.setBDeleted(true); | ||
| 139 | + userMapper.updateById(patch); | ||
| 140 | + | ||
| 141 | + mockMvc.perform(post("/api/auth/login") | ||
| 142 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 143 | + .content(json(loginDto(userName, "666666")))) | ||
| 144 | + .andExpect(status().isOk()) | ||
| 145 | + .andExpect(jsonPath("$.code").value(40101)); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + @Test | ||
| 149 | + void login_missingPassword_returns40010() throws Exception { | ||
| 150 | + LoginDTO dto = loginDto(userName, "any"); | ||
| 151 | + dto.setSPassword(null); | ||
| 152 | + | ||
| 153 | + mockMvc.perform(post("/api/auth/login") | ||
| 154 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 155 | + .content(json(dto))) | ||
| 156 | + .andExpect(status().isOk()) | ||
| 157 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + @Test | ||
| 161 | + void login_invalidVersion_returns40010() throws Exception { | ||
| 162 | + LoginDTO dto = loginDto(userName, "any"); | ||
| 163 | + dto.setSVersion("experimental"); | ||
| 164 | + | ||
| 165 | + mockMvc.perform(post("/api/auth/login") | ||
| 166 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 167 | + .content(json(dto))) | ||
| 168 | + .andExpect(status().isOk()) | ||
| 169 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + @Test | ||
| 173 | + void login_5thFailureLocks_returns40301() throws Exception { | ||
| 174 | + insertUser("666666"); | ||
| 175 | + | ||
| 176 | + // 4 次错误密码(不锁定) | ||
| 177 | + for (int i = 0; i < 4; i++) { | ||
| 178 | + mockMvc.perform(post("/api/auth/login") | ||
| 179 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 180 | + .content(json(loginDto(userName, "wrong_" + i)))) | ||
| 181 | + .andExpect(jsonPath("$.code").value(40101)); | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + // 第 5 次错误密码:触发锁定,返回 40301 + cooldownSeconds | ||
| 185 | + mockMvc.perform(post("/api/auth/login") | ||
| 186 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 187 | + .content(json(loginDto(userName, "wrong_5")))) | ||
| 188 | + .andExpect(status().isOk()) | ||
| 189 | + .andExpect(jsonPath("$.code").value(40301)) | ||
| 190 | + .andExpect(jsonPath("$.data.cooldownSeconds").isNumber()); | ||
| 191 | + | ||
| 192 | + // 锁定后正确密码也 40301 | ||
| 193 | + mockMvc.perform(post("/api/auth/login") | ||
| 194 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 195 | + .content(json(loginDto(userName, "666666")))) | ||
| 196 | + .andExpect(jsonPath("$.code").value(40301)); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + @Test | ||
| 200 | + void login_responseExcludesSPasswordHash() throws Exception { | ||
| 201 | + insertUser("666666"); | ||
| 202 | + | ||
| 203 | + mockMvc.perform(post("/api/auth/login") | ||
| 204 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 205 | + .content(json(loginDto(userName, "666666")))) | ||
| 206 | + .andExpect(status().isOk()) | ||
| 207 | + .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + @Test | ||
| 211 | + void login_afterLockExpiry_returns200() throws Exception { | ||
| 212 | + insertUser("666666"); | ||
| 213 | + | ||
| 214 | + // 5 次错误密码 → 锁定 | ||
| 215 | + for (int i = 0; i < 5; i++) { | ||
| 216 | + mockMvc.perform(post("/api/auth/login") | ||
| 217 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 218 | + .content(json(loginDto(userName, "wrong_" + i)))) | ||
| 219 | + .andReturn(); | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + // 验证当前确实锁定 | ||
| 223 | + mockMvc.perform(post("/api/auth/login") | ||
| 224 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 225 | + .content(json(loginDto(userName, "666666")))) | ||
| 226 | + .andExpect(jsonPath("$.code").value(40301)); | ||
| 227 | + | ||
| 228 | + // 把 lockUntil 拨到过去模拟锁定到期 | ||
| 229 | + attemptStore.expireLockForTest(userName); | ||
| 230 | + | ||
| 231 | + // 锁定到期 + 正确密码 → 200 | ||
| 232 | + mockMvc.perform(post("/api/auth/login") | ||
| 233 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 234 | + .content(json(loginDto(userName, "666666")))) | ||
| 235 | + .andExpect(status().isOk()) | ||
| 236 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 237 | + .andExpect(jsonPath("$.data.accessToken").isString()); | ||
| 238 | + } | ||
| 239 | +} |
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.erp.module.usr.dto.UserCreateDTO; | ||
| 5 | +import com.xly.erp.module.usr.dto.UserUpdateDTO; | ||
| 6 | +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; | ||
| 7 | +import com.xly.erp.module.usr.entity.StaffEntity; | ||
| 8 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 9 | +import com.xly.erp.module.usr.entity.UserPermissionEntity; | ||
| 10 | +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; | ||
| 11 | +import com.xly.erp.module.usr.mapper.StaffMapper; | ||
| 12 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 13 | +import com.xly.erp.module.usr.mapper.UserPermissionMapper; | ||
| 14 | +import org.junit.jupiter.api.Test; | ||
| 15 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 16 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | ||
| 17 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 18 | +import org.springframework.http.MediaType; | ||
| 19 | +import org.springframework.test.annotation.Rollback; | ||
| 20 | +import org.springframework.test.context.ActiveProfiles; | ||
| 21 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 22 | +import org.springframework.transaction.annotation.Transactional; | ||
| 23 | + | ||
| 24 | +import java.time.LocalDateTime; | ||
| 25 | +import java.util.List; | ||
| 26 | + | ||
| 27 | +import static com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery; | ||
| 28 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 29 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 30 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | ||
| 31 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | ||
| 32 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 33 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 34 | + | ||
| 35 | +@SpringBootTest | ||
| 36 | +@AutoConfigureMockMvc | ||
| 37 | +@ActiveProfiles("test") | ||
| 38 | +@Transactional | ||
| 39 | +@Rollback | ||
| 40 | +class UserControllerIT { | ||
| 41 | + | ||
| 42 | + @Autowired MockMvc mockMvc; | ||
| 43 | + @Autowired ObjectMapper objectMapper; | ||
| 44 | + @Autowired UserMapper userMapper; | ||
| 45 | + @Autowired StaffMapper staffMapper; | ||
| 46 | + @Autowired PermissionCategoryMapper permissionCategoryMapper; | ||
| 47 | + @Autowired UserPermissionMapper userPermissionMapper; | ||
| 48 | + | ||
| 49 | + private UserCreateDTO baseDto(String userName) { | ||
| 50 | + UserCreateDTO d = new UserCreateDTO(); | ||
| 51 | + d.setSUserNo("uno_" + System.nanoTime()); | ||
| 52 | + d.setSUserName(userName); | ||
| 53 | + d.setSUserType("普通用户"); | ||
| 54 | + d.setSLanguage("zh"); | ||
| 55 | + return d; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } | ||
| 59 | + | ||
| 60 | + private Integer insertStaff() { | ||
| 61 | + StaffEntity s = new StaffEntity(); | ||
| 62 | + s.setSStaffNo("st_" + System.nanoTime()); | ||
| 63 | + s.setSStaffName("员工A"); | ||
| 64 | + s.setBDeleted(false); | ||
| 65 | + s.setTCreateDate(LocalDateTime.now()); | ||
| 66 | + staffMapper.insert(s); | ||
| 67 | + return s.getIIncrement(); | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + private Integer insertCategory() { | ||
| 71 | + PermissionCategoryEntity p = new PermissionCategoryEntity(); | ||
| 72 | + p.setSCategoryCode("c_" + System.nanoTime()); | ||
| 73 | + p.setSCategoryName("分类"); | ||
| 74 | + p.setISortOrder(0); | ||
| 75 | + p.setBDeleted(false); | ||
| 76 | + p.setTCreateDate(LocalDateTime.now()); | ||
| 77 | + permissionCategoryMapper.insert(p); | ||
| 78 | + return p.getIIncrement(); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 82 | + void post_minimalFields_returns200() throws Exception { | ||
| 83 | + UserCreateDTO dto = baseDto("alice_" + System.nanoTime()); | ||
| 84 | + | ||
| 85 | + mockMvc.perform(post("/api/users") | ||
| 86 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 87 | + .content(json(dto))) | ||
| 88 | + .andExpect(status().isOk()) | ||
| 89 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 90 | + .andExpect(jsonPath("$.data.iIncrement").isNumber()) | ||
| 91 | + .andExpect(jsonPath("$.data.sUserName").value(dto.getSUserName())) | ||
| 92 | + .andExpect(jsonPath("$.data.bCanModifyDocs").value(false)) | ||
| 93 | + .andExpect(jsonPath("$.data.permissionCategoryIds").isArray()) | ||
| 94 | + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0)); | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + @Test | ||
| 98 | + void post_withStaffAndPermissions_returns200_andDbAssociated() throws Exception { | ||
| 99 | + Integer staffId = insertStaff(); | ||
| 100 | + Integer cat1 = insertCategory(); | ||
| 101 | + Integer cat2 = insertCategory(); | ||
| 102 | + Integer cat3 = insertCategory(); | ||
| 103 | + | ||
| 104 | + UserCreateDTO dto = baseDto("bob_" + System.nanoTime()); | ||
| 105 | + dto.setIStaffId(staffId); | ||
| 106 | + dto.setPermissionCategoryIds(List.of(cat1, cat2, cat3)); | ||
| 107 | + | ||
| 108 | + mockMvc.perform(post("/api/users") | ||
| 109 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 110 | + .content(json(dto))) | ||
| 111 | + .andExpect(status().isOk()) | ||
| 112 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 113 | + .andExpect(jsonPath("$.data.iStaffId").value(staffId)); | ||
| 114 | + | ||
| 115 | + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class) | ||
| 116 | + .eq(UserEntity::getSUserName, dto.getSUserName())); | ||
| 117 | + assertThat(u).isNotNull(); | ||
| 118 | + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) | ||
| 119 | + .eq(UserPermissionEntity::getIUserId, u.getIIncrement())); | ||
| 120 | + assertThat(upCount).isEqualTo(3L); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + @Test | ||
| 124 | + void post_duplicateUserName_returns40921() throws Exception { | ||
| 125 | + String userName = "dup_" + System.nanoTime(); | ||
| 126 | + UserCreateDTO first = baseDto(userName); | ||
| 127 | + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(first))) | ||
| 128 | + .andExpect(status().isOk()); | ||
| 129 | + | ||
| 130 | + UserCreateDTO second = baseDto(userName); | ||
| 131 | + mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json(second))) | ||
| 132 | + .andExpect(status().isOk()) | ||
| 133 | + .andExpect(jsonPath("$.code").value(40921)); | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + @Test | ||
| 137 | + void post_staffNotFound_returns40421() throws Exception { | ||
| 138 | + UserCreateDTO dto = baseDto("noStaff_" + System.nanoTime()); | ||
| 139 | + dto.setIStaffId(999999); | ||
| 140 | + mockMvc.perform(post("/api/users") | ||
| 141 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 142 | + .content(json(dto))) | ||
| 143 | + .andExpect(status().isOk()) | ||
| 144 | + .andExpect(jsonPath("$.code").value(40421)); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + @Test | ||
| 148 | + void post_permissionCategoryNotFound_returns40422() throws Exception { | ||
| 149 | + UserCreateDTO dto = baseDto("noCat_" + System.nanoTime()); | ||
| 150 | + dto.setPermissionCategoryIds(List.of(999999)); | ||
| 151 | + mockMvc.perform(post("/api/users") | ||
| 152 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 153 | + .content(json(dto))) | ||
| 154 | + .andExpect(status().isOk()) | ||
| 155 | + .andExpect(jsonPath("$.code").value(40422)); | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + @Test | ||
| 159 | + void post_passwordHashedInDb_notPlaintext() throws Exception { | ||
| 160 | + UserCreateDTO dto = baseDto("pw_" + System.nanoTime()); | ||
| 161 | + | ||
| 162 | + mockMvc.perform(post("/api/users") | ||
| 163 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 164 | + .content(json(dto))) | ||
| 165 | + .andExpect(status().isOk()); | ||
| 166 | + | ||
| 167 | + UserEntity u = userMapper.selectOne(lambdaQuery(UserEntity.class) | ||
| 168 | + .eq(UserEntity::getSUserName, dto.getSUserName())); | ||
| 169 | + assertThat(u.getSPasswordHash()) | ||
| 170 | + .satisfiesAnyOf( | ||
| 171 | + h -> assertThat(h).startsWith("$2a$"), | ||
| 172 | + h -> assertThat(h).startsWith("$2b$"), | ||
| 173 | + h -> assertThat(h).startsWith("$2y$")) | ||
| 174 | + .doesNotContain("666666"); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + @Test | ||
| 178 | + void post_responseExcludesSPasswordHash() throws Exception { | ||
| 179 | + UserCreateDTO dto = baseDto("priv_" + System.nanoTime()); | ||
| 180 | + | ||
| 181 | + mockMvc.perform(post("/api/users") | ||
| 182 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 183 | + .content(json(dto))) | ||
| 184 | + .andExpect(status().isOk()) | ||
| 185 | + .andExpect(jsonPath("$.data.sPasswordHash").doesNotExist()); | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + // ============================================================ | ||
| 189 | + // REQ-USR-002 PUT 系列 | ||
| 190 | + // ============================================================ | ||
| 191 | + | ||
| 192 | + private Integer insertUser(String userName, Integer staffId, List<Integer> categoryIds) { | ||
| 193 | + UserEntity u = new UserEntity(); | ||
| 194 | + u.setSUserNo("uno_" + System.nanoTime()); | ||
| 195 | + u.setSUserName(userName); | ||
| 196 | + u.setIStaffId(staffId); | ||
| 197 | + u.setSUserType("普通用户"); | ||
| 198 | + u.setSLanguage("zh"); | ||
| 199 | + u.setBCanModifyDocs(false); | ||
| 200 | + u.setSPasswordHash("$2a$10$origUser"); | ||
| 201 | + u.setBDeleted(false); | ||
| 202 | + u.setTCreateDate(LocalDateTime.now()); | ||
| 203 | + userMapper.insert(u); | ||
| 204 | + for (Integer cid : categoryIds) { | ||
| 205 | + UserPermissionEntity up = new UserPermissionEntity(); | ||
| 206 | + up.setIUserId(u.getIIncrement()); | ||
| 207 | + up.setICategoryId(cid); | ||
| 208 | + up.setTCreateDate(LocalDateTime.now()); | ||
| 209 | + userPermissionMapper.insert(up); | ||
| 210 | + } | ||
| 211 | + return u.getIIncrement(); | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + private UserUpdateDTO updateDto(Integer staffId, List<Integer> permissionIds) { | ||
| 215 | + UserUpdateDTO d = new UserUpdateDTO(); | ||
| 216 | + d.setIStaffId(staffId); | ||
| 217 | + d.setSUserType("超级管理员"); | ||
| 218 | + d.setSLanguage("en"); | ||
| 219 | + d.setBCanModifyDocs(true); | ||
| 220 | + d.setPermissionCategoryIds(permissionIds); | ||
| 221 | + return d; | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + @Test | ||
| 225 | + void put_validUpdate_returns200_andDbReflects() throws Exception { | ||
| 226 | + Integer staff1 = insertStaff(); | ||
| 227 | + Integer staff2 = insertStaff(); | ||
| 228 | + Integer cat1 = insertCategory(); | ||
| 229 | + Integer cat2 = insertCategory(); | ||
| 230 | + Integer cat3 = insertCategory(); | ||
| 231 | + Integer userId = insertUser("upd_" + System.nanoTime(), staff1, List.of(cat1, cat2, cat3)); | ||
| 232 | + | ||
| 233 | + Integer catNew1 = insertCategory(); | ||
| 234 | + Integer catNew2 = insertCategory(); | ||
| 235 | + | ||
| 236 | + UserUpdateDTO dto = updateDto(staff2, List.of(catNew1, catNew2)); | ||
| 237 | + | ||
| 238 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 239 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 240 | + .content(json(dto))) | ||
| 241 | + .andExpect(status().isOk()) | ||
| 242 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 243 | + .andExpect(jsonPath("$.data.iStaffId").value(staff2)) | ||
| 244 | + .andExpect(jsonPath("$.data.sUserType").value("超级管理员")) | ||
| 245 | + .andExpect(jsonPath("$.data.sLanguage").value("en")); | ||
| 246 | + | ||
| 247 | + UserEntity reloaded = userMapper.selectById(userId); | ||
| 248 | + assertThat(reloaded.getIStaffId()).isEqualTo(staff2); | ||
| 249 | + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员"); | ||
| 250 | + assertThat(reloaded.getBCanModifyDocs()).isTrue(); | ||
| 251 | + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) | ||
| 252 | + .eq(UserPermissionEntity::getIUserId, userId)); | ||
| 253 | + assertThat(upCount).isEqualTo(2L); // 原 3 删 + 新 2 插 | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + @Test | ||
| 257 | + void put_clearStaffId_setsNull() throws Exception { | ||
| 258 | + Integer staffId = insertStaff(); | ||
| 259 | + Integer userId = insertUser("clr_" + System.nanoTime(), staffId, List.of()); | ||
| 260 | + | ||
| 261 | + UserUpdateDTO dto = updateDto(null, List.of()); | ||
| 262 | + | ||
| 263 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 264 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 265 | + .content(json(dto))) | ||
| 266 | + .andExpect(status().isOk()) | ||
| 267 | + .andExpect(jsonPath("$.code").value(200)); | ||
| 268 | + | ||
| 269 | + assertThat(userMapper.selectById(userId).getIStaffId()).isNull(); | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + @Test | ||
| 273 | + void put_emptyPermissionCategoryIds_clearsAssociations() throws Exception { | ||
| 274 | + Integer cat1 = insertCategory(); | ||
| 275 | + Integer cat2 = insertCategory(); | ||
| 276 | + Integer userId = insertUser("emp_" + System.nanoTime(), null, List.of(cat1, cat2)); | ||
| 277 | + | ||
| 278 | + UserUpdateDTO dto = updateDto(null, List.of()); | ||
| 279 | + | ||
| 280 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 281 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 282 | + .content(json(dto))) | ||
| 283 | + .andExpect(status().isOk()) | ||
| 284 | + .andExpect(jsonPath("$.code").value(200)); | ||
| 285 | + | ||
| 286 | + Long upCount = userPermissionMapper.selectCount(lambdaQuery(UserPermissionEntity.class) | ||
| 287 | + .eq(UserPermissionEntity::getIUserId, userId)); | ||
| 288 | + assertThat(upCount).isZero(); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + @Test | ||
| 292 | + void put_targetNotFound_returns40431() throws Exception { | ||
| 293 | + UserUpdateDTO dto = updateDto(null, List.of()); | ||
| 294 | + mockMvc.perform(put("/api/users/999999") | ||
| 295 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 296 | + .content(json(dto))) | ||
| 297 | + .andExpect(status().isOk()) | ||
| 298 | + .andExpect(jsonPath("$.code").value(40431)); | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + @Test | ||
| 302 | + void put_staffNotFound_returns40421() throws Exception { | ||
| 303 | + Integer userId = insertUser("nost_" + System.nanoTime(), null, List.of()); | ||
| 304 | + UserUpdateDTO dto = updateDto(999999, List.of()); | ||
| 305 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 306 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 307 | + .content(json(dto))) | ||
| 308 | + .andExpect(status().isOk()) | ||
| 309 | + .andExpect(jsonPath("$.code").value(40421)); | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + @Test | ||
| 313 | + void put_permissionCategoryNotFound_returns40422() throws Exception { | ||
| 314 | + Integer userId = insertUser("noc_" + System.nanoTime(), null, List.of()); | ||
| 315 | + UserUpdateDTO dto = updateDto(null, List.of(999999)); | ||
| 316 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 317 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 318 | + .content(json(dto))) | ||
| 319 | + .andExpect(status().isOk()) | ||
| 320 | + .andExpect(jsonPath("$.code").value(40422)); | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + @Test | ||
| 324 | + void put_missingRequired_returns40010() throws Exception { | ||
| 325 | + Integer userId = insertUser("miss_" + System.nanoTime(), null, List.of()); | ||
| 326 | + UserUpdateDTO dto = updateDto(null, List.of()); | ||
| 327 | + dto.setSUserType(null); // 必填缺失 | ||
| 328 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 329 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 330 | + .content(json(dto))) | ||
| 331 | + .andExpect(status().isOk()) | ||
| 332 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 333 | + } | ||
| 334 | + | ||
| 335 | + @Test | ||
| 336 | + void put_ignoresProtectedFields_doesNotChangeUserNoOrName() throws Exception { | ||
| 337 | + String origName = "prot_" + System.nanoTime(); | ||
| 338 | + Integer userId = insertUser(origName, null, List.of()); | ||
| 339 | + String origNo = userMapper.selectById(userId).getSUserNo(); | ||
| 340 | + String origHash = userMapper.selectById(userId).getSPasswordHash(); | ||
| 341 | + | ||
| 342 | + // 手工拼 body 含保护字段 | ||
| 343 | + String body = """ | ||
| 344 | + { | ||
| 345 | + "sUserNo": "hijack", | ||
| 346 | + "sUserName": "hijack", | ||
| 347 | + "sPasswordHash": "$2a$10$hijacked", | ||
| 348 | + "iStaffId": null, | ||
| 349 | + "sUserType": "超级管理员", | ||
| 350 | + "sLanguage": "zh-TW", | ||
| 351 | + "bCanModifyDocs": true, | ||
| 352 | + "permissionCategoryIds": [] | ||
| 353 | + } | ||
| 354 | + """; | ||
| 355 | + mockMvc.perform(put("/api/users/" + userId) | ||
| 356 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 357 | + .content(body)) | ||
| 358 | + .andExpect(status().isOk()) | ||
| 359 | + .andExpect(jsonPath("$.code").value(200)); | ||
| 360 | + | ||
| 361 | + UserEntity reloaded = userMapper.selectById(userId); | ||
| 362 | + assertThat(reloaded.getSUserNo()).isEqualTo(origNo); | ||
| 363 | + assertThat(reloaded.getSUserName()).isEqualTo(origName); | ||
| 364 | + assertThat(reloaded.getSPasswordHash()).isEqualTo(origHash); | ||
| 365 | + // 但其他字段已修改 | ||
| 366 | + assertThat(reloaded.getSUserType()).isEqualTo("超级管理员"); | ||
| 367 | + assertThat(reloaded.getSLanguage()).isEqualTo("zh-TW"); | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + // ============================================================ | ||
| 371 | + // REQ-USR-003 GET 系列 | ||
| 372 | + // ============================================================ | ||
| 373 | + | ||
| 374 | + @Test | ||
| 375 | + void get_emptyKeyword_returnsAllUndeleted() throws Exception { | ||
| 376 | + Integer staffId = insertStaff(); | ||
| 377 | + insertUser("getall_a_" + System.nanoTime(), staffId, List.of()); | ||
| 378 | + insertUser("getall_b_" + System.nanoTime(), null, List.of()); | ||
| 379 | + | ||
| 380 | + mockMvc.perform(get("/api/users")) | ||
| 381 | + .andExpect(status().isOk()) | ||
| 382 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 383 | + .andExpect(jsonPath("$.data.list").isArray()) | ||
| 384 | + .andExpect(jsonPath("$.data.total").isNumber()); | ||
| 385 | + } | ||
| 386 | + | ||
| 387 | + @Test | ||
| 388 | + void get_filterByUsernameContains_returnsMatchedSubset() throws Exception { | ||
| 389 | + String alicePrefix = "filt_ali_" + System.nanoTime(); | ||
| 390 | + insertUser(alicePrefix + "_alice", null, List.of()); | ||
| 391 | + insertUser("filt_bob_" + System.nanoTime(), null, List.of()); | ||
| 392 | + | ||
| 393 | + mockMvc.perform(get("/api/users") | ||
| 394 | + .param("queryField", "username") | ||
| 395 | + .param("matchType", "contains") | ||
| 396 | + .param("queryValue", alicePrefix)) | ||
| 397 | + .andExpect(status().isOk()) | ||
| 398 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 399 | + .andExpect(jsonPath("$.data.total").value(1)) | ||
| 400 | + .andExpect(jsonPath("$.data.list[0].sUserName", org.hamcrest.Matchers.startsWith(alicePrefix))); | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + @Test | ||
| 404 | + void get_filterByStaffnameContains_returnsJoinedResults() throws Exception { | ||
| 405 | + // staff with unique sStaffName, then user referencing it | ||
| 406 | + StaffEntity s = new StaffEntity(); | ||
| 407 | + String staffName = "joined_staff_" + System.nanoTime(); | ||
| 408 | + s.setSStaffNo("st_" + System.nanoTime()); | ||
| 409 | + s.setSStaffName(staffName); | ||
| 410 | + s.setSDepartment("研发部"); | ||
| 411 | + s.setBDeleted(false); | ||
| 412 | + s.setTCreateDate(LocalDateTime.now()); | ||
| 413 | + staffMapper.insert(s); | ||
| 414 | + insertUser("for_staff_" + System.nanoTime(), s.getIIncrement(), List.of()); | ||
| 415 | + | ||
| 416 | + mockMvc.perform(get("/api/users") | ||
| 417 | + .param("queryField", "staffname") | ||
| 418 | + .param("matchType", "contains") | ||
| 419 | + .param("queryValue", staffName)) | ||
| 420 | + .andExpect(status().isOk()) | ||
| 421 | + .andExpect(jsonPath("$.data.total").value(1)) | ||
| 422 | + .andExpect(jsonPath("$.data.list[0].sStaffName").value(staffName)); | ||
| 423 | + } | ||
| 424 | + | ||
| 425 | + @Test | ||
| 426 | + void get_filterByDeletedTrue_returnsOnlyDeleted() throws Exception { | ||
| 427 | + // 插一个用户后软删 | ||
| 428 | + Integer userId = insertUser("del_" + System.nanoTime(), null, List.of()); | ||
| 429 | + UserEntity patch = new UserEntity(); | ||
| 430 | + patch.setIIncrement(userId); | ||
| 431 | + patch.setBDeleted(true); | ||
| 432 | + userMapper.updateById(patch); | ||
| 433 | + | ||
| 434 | + mockMvc.perform(get("/api/users") | ||
| 435 | + .param("queryField", "deleted") | ||
| 436 | + .param("matchType", "equals") | ||
| 437 | + .param("queryValue", "true")) | ||
| 438 | + .andExpect(status().isOk()) | ||
| 439 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 440 | + .andExpect(jsonPath("$.data.list[?(@.iIncrement==" + userId + ")]").exists()); | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + @Test | ||
| 444 | + void get_pagination_returnsCorrectSlice() throws Exception { | ||
| 445 | + for (int i = 0; i < 3; i++) { | ||
| 446 | + insertUser("page_" + i + "_" + System.nanoTime(), null, List.of()); | ||
| 447 | + } | ||
| 448 | + | ||
| 449 | + mockMvc.perform(get("/api/users") | ||
| 450 | + .param("pageNum", "1") | ||
| 451 | + .param("pageSize", "2")) | ||
| 452 | + .andExpect(status().isOk()) | ||
| 453 | + .andExpect(jsonPath("$.data.pageNum").value(1)) | ||
| 454 | + .andExpect(jsonPath("$.data.pageSize").value(2)) | ||
| 455 | + .andExpect(jsonPath("$.data.list.length()").value(2)); | ||
| 456 | + } | ||
| 457 | + | ||
| 458 | + @Test | ||
| 459 | + void get_responseExcludesInternalFields() throws Exception { | ||
| 460 | + insertUser("priv_" + System.nanoTime(), null, List.of()); | ||
| 461 | + | ||
| 462 | + mockMvc.perform(get("/api/users").param("pageSize", "5")) | ||
| 463 | + .andExpect(status().isOk()) | ||
| 464 | + .andExpect(jsonPath("$.data.list[0].sPasswordHash").doesNotExist()) | ||
| 465 | + .andExpect(jsonPath("$.data.list[0].sId").doesNotExist()) | ||
| 466 | + .andExpect(jsonPath("$.data.list[0].iStaffId").doesNotExist()) | ||
| 467 | + .andExpect(jsonPath("$.data.list[0].sBrandsId").doesNotExist()); | ||
| 468 | + } | ||
| 469 | + | ||
| 470 | + @Test | ||
| 471 | + void get_pageSizeTooLarge_returns40010() throws Exception { | ||
| 472 | + mockMvc.perform(get("/api/users").param("pageSize", "101")) | ||
| 473 | + .andExpect(status().isOk()) | ||
| 474 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 475 | + } | ||
| 476 | + | ||
| 477 | + @Test | ||
| 478 | + void get_invalidQueryField_returns40010() throws Exception { | ||
| 479 | + mockMvc.perform(get("/api/users").param("queryField", "invalid_xyz")) | ||
| 480 | + .andExpect(status().isOk()) | ||
| 481 | + .andExpect(jsonPath("$.code").value(40010)); | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + @Test | ||
| 485 | + void get_userWithoutStaff_listItemHasNullStaffFields() throws Exception { | ||
| 486 | + String userName = "nostaff_" + System.nanoTime(); | ||
| 487 | + insertUser(userName, null, List.of()); | ||
| 488 | + | ||
| 489 | + mockMvc.perform(get("/api/users") | ||
| 490 | + .param("queryField", "username") | ||
| 491 | + .param("matchType", "equals") | ||
| 492 | + .param("queryValue", userName)) | ||
| 493 | + .andExpect(status().isOk()) | ||
| 494 | + .andExpect(jsonPath("$.data.list[0].sUserName").value(userName)) | ||
| 495 | + .andExpect(jsonPath("$.data.list[0].sStaffName").doesNotExist()) | ||
| 496 | + .andExpect(jsonPath("$.data.list[0].sDepartment").doesNotExist()); | ||
| 497 | + } | ||
| 498 | +} |
backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.Set; | ||
| 10 | + | ||
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 12 | + | ||
| 13 | +class LoginDTOValidationTest { | ||
| 14 | + | ||
| 15 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 16 | + private final Validator validator = FACTORY.getValidator(); | ||
| 17 | + | ||
| 18 | + private LoginDTO valid() { | ||
| 19 | + LoginDTO d = new LoginDTO(); | ||
| 20 | + d.setSUserName("alice"); | ||
| 21 | + d.setSPassword("666666"); | ||
| 22 | + d.setSVersion("standard"); | ||
| 23 | + return d; | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + @Test | ||
| 27 | + void allValid_yieldsNoViolations() { | ||
| 28 | + Set<ConstraintViolation<LoginDTO>> v = validator.validate(valid()); | ||
| 29 | + assertThat(v).isEmpty(); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + @Test | ||
| 33 | + void blankRequiredFields_yieldsViolations() { | ||
| 34 | + LoginDTO d = new LoginDTO(); | ||
| 35 | + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d); | ||
| 36 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 37 | + .contains("sUserName", "sPassword", "sVersion"); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + @Test | ||
| 41 | + void invalidVersion_yieldsViolation() { | ||
| 42 | + LoginDTO d = valid(); | ||
| 43 | + d.setSVersion("experimental"); | ||
| 44 | + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d); | ||
| 45 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sVersion"); | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + @Test | ||
| 49 | + void overSized_yieldsViolation() { | ||
| 50 | + LoginDTO d = valid(); | ||
| 51 | + d.setSUserName("a".repeat(51)); | ||
| 52 | + d.setSPassword("p".repeat(101)); | ||
| 53 | + Set<ConstraintViolation<LoginDTO>> v = validator.validate(d); | ||
| 54 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 55 | + .contains("sUserName", "sPassword"); | ||
| 56 | + } | ||
| 57 | +} |
backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.List; | ||
| 10 | +import java.util.Set; | ||
| 11 | + | ||
| 12 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 13 | + | ||
| 14 | +class UserCreateDTOValidationTest { | ||
| 15 | + | ||
| 16 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 17 | + private final Validator validator = FACTORY.getValidator(); | ||
| 18 | + | ||
| 19 | + private UserCreateDTO valid() { | ||
| 20 | + UserCreateDTO d = new UserCreateDTO(); | ||
| 21 | + d.setSUserNo("u001"); | ||
| 22 | + d.setSUserName("alice"); | ||
| 23 | + d.setSUserType("普通用户"); | ||
| 24 | + d.setSLanguage("zh"); | ||
| 25 | + d.setBCanModifyDocs(false); | ||
| 26 | + d.setPermissionCategoryIds(List.of()); | ||
| 27 | + return d; | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + @Test | ||
| 31 | + void allValidFields_yieldsNoViolations() { | ||
| 32 | + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(valid()); | ||
| 33 | + assertThat(v).isEmpty(); | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + @Test | ||
| 37 | + void blankRequiredFields_yieldsViolations() { | ||
| 38 | + UserCreateDTO d = new UserCreateDTO(); | ||
| 39 | + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d); | ||
| 40 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 41 | + .contains("sUserNo", "sUserName", "sUserType", "sLanguage"); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @Test | ||
| 45 | + void invalidUserTypeEnum_yieldsViolation() { | ||
| 46 | + UserCreateDTO d = valid(); | ||
| 47 | + d.setSUserType("非法值"); | ||
| 48 | + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d); | ||
| 49 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType"); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Test | ||
| 53 | + void invalidLanguageEnum_yieldsViolation() { | ||
| 54 | + UserCreateDTO d = valid(); | ||
| 55 | + d.setSLanguage("fr"); | ||
| 56 | + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d); | ||
| 57 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage"); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @Test | ||
| 61 | + void overSizedFields_yieldsViolations() { | ||
| 62 | + UserCreateDTO d = valid(); | ||
| 63 | + d.setSUserNo("a".repeat(51)); | ||
| 64 | + d.setSUserName("a".repeat(51)); | ||
| 65 | + Set<ConstraintViolation<UserCreateDTO>> v = validator.validate(d); | ||
| 66 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 67 | + .contains("sUserNo", "sUserName"); | ||
| 68 | + } | ||
| 69 | +} |
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.Set; | ||
| 10 | + | ||
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 12 | + | ||
| 13 | +class UserQueryDTOValidationTest { | ||
| 14 | + | ||
| 15 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 16 | + private final Validator validator = FACTORY.getValidator(); | ||
| 17 | + | ||
| 18 | + private UserQueryDTO valid() { | ||
| 19 | + UserQueryDTO d = new UserQueryDTO(); | ||
| 20 | + d.setPageNum(1); | ||
| 21 | + d.setPageSize(20); | ||
| 22 | + d.setQueryField("username"); | ||
| 23 | + d.setMatchType("contains"); | ||
| 24 | + d.setQueryValue("alice"); | ||
| 25 | + return d; | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + @Test | ||
| 29 | + void allValid_yieldsNoViolations() { | ||
| 30 | + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(valid()); | ||
| 31 | + assertThat(v).isEmpty(); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @Test | ||
| 35 | + void pageSizeTooLarge_yieldsViolation() { | ||
| 36 | + UserQueryDTO d = valid(); | ||
| 37 | + d.setPageSize(101); | ||
| 38 | + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d); | ||
| 39 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize"); | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + @Test | ||
| 43 | + void pageSizeTooSmall_yieldsViolation() { | ||
| 44 | + UserQueryDTO d = valid(); | ||
| 45 | + d.setPageSize(0); | ||
| 46 | + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d); | ||
| 47 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("pageSize"); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + @Test | ||
| 51 | + void queryFieldInvalidEnum_yieldsViolation() { | ||
| 52 | + UserQueryDTO d = valid(); | ||
| 53 | + d.setQueryField("invalid_field"); | ||
| 54 | + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d); | ||
| 55 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryField"); | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + @Test | ||
| 59 | + void queryValueOverSized_yieldsViolation() { | ||
| 60 | + UserQueryDTO d = valid(); | ||
| 61 | + d.setQueryValue("a".repeat(101)); | ||
| 62 | + Set<ConstraintViolation<UserQueryDTO>> v = validator.validate(d); | ||
| 63 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("queryValue"); | ||
| 64 | + } | ||
| 65 | +} |
backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | ||
| 2 | + | ||
| 3 | +import jakarta.validation.ConstraintViolation; | ||
| 4 | +import jakarta.validation.Validation; | ||
| 5 | +import jakarta.validation.Validator; | ||
| 6 | +import jakarta.validation.ValidatorFactory; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import java.util.List; | ||
| 10 | +import java.util.Set; | ||
| 11 | + | ||
| 12 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 13 | + | ||
| 14 | +class UserUpdateDTOValidationTest { | ||
| 15 | + | ||
| 16 | + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); | ||
| 17 | + private final Validator validator = FACTORY.getValidator(); | ||
| 18 | + | ||
| 19 | + private UserUpdateDTO valid() { | ||
| 20 | + UserUpdateDTO d = new UserUpdateDTO(); | ||
| 21 | + d.setSUserType("超级管理员"); | ||
| 22 | + d.setSLanguage("en"); | ||
| 23 | + d.setBCanModifyDocs(true); | ||
| 24 | + d.setIStaffId(7); | ||
| 25 | + d.setPermissionCategoryIds(List.of(1, 2)); | ||
| 26 | + return d; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Test | ||
| 30 | + void allValidFields_yieldsNoViolations() { | ||
| 31 | + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(valid()); | ||
| 32 | + assertThat(v).isEmpty(); | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + @Test | ||
| 36 | + void blankRequiredFields_yieldsViolations() { | ||
| 37 | + UserUpdateDTO d = new UserUpdateDTO(); | ||
| 38 | + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d); | ||
| 39 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()) | ||
| 40 | + .contains("sUserType", "sLanguage"); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + @Test | ||
| 44 | + void invalidUserTypeEnum_yieldsViolation() { | ||
| 45 | + UserUpdateDTO d = valid(); | ||
| 46 | + d.setSUserType("非法值"); | ||
| 47 | + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d); | ||
| 48 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sUserType"); | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + @Test | ||
| 52 | + void invalidLanguageEnum_yieldsViolation() { | ||
| 53 | + UserUpdateDTO d = valid(); | ||
| 54 | + d.setSLanguage("fr"); | ||
| 55 | + Set<ConstraintViolation<UserUpdateDTO>> v = validator.validate(d); | ||
| 56 | + assertThat(v).extracting(cv -> cv.getPropertyPath().toString()).contains("sLanguage"); | ||
| 57 | + } | ||
| 58 | +} |
backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.metadata.IPage; | ||
| 4 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | ||
| 5 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 6 | +import com.xly.erp.module.usr.entity.StaffEntity; | ||
| 7 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 8 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 9 | +import org.junit.jupiter.api.Test; | ||
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 11 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 12 | +import org.springframework.test.annotation.Rollback; | ||
| 13 | +import org.springframework.test.context.ActiveProfiles; | ||
| 14 | +import org.springframework.transaction.annotation.Transactional; | ||
| 15 | + | ||
| 16 | +import java.time.LocalDateTime; | ||
| 17 | + | ||
| 18 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 19 | + | ||
| 20 | +@SpringBootTest | ||
| 21 | +@ActiveProfiles("test") | ||
| 22 | +@Transactional | ||
| 23 | +@Rollback | ||
| 24 | +class UserMapperSearchIT { | ||
| 25 | + | ||
| 26 | + @Autowired UserMapper userMapper; | ||
| 27 | + @Autowired StaffMapper staffMapper; | ||
| 28 | + | ||
| 29 | + private Integer insertStaff(String name) { | ||
| 30 | + StaffEntity s = new StaffEntity(); | ||
| 31 | + s.setSStaffNo("st_" + System.nanoTime()); | ||
| 32 | + s.setSStaffName(name); | ||
| 33 | + s.setSDepartment("研发部"); | ||
| 34 | + s.setBDeleted(false); | ||
| 35 | + s.setTCreateDate(LocalDateTime.now()); | ||
| 36 | + staffMapper.insert(s); | ||
| 37 | + return s.getIIncrement(); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + private Integer insertUser(String userName, Integer staffId) { | ||
| 41 | + UserEntity u = new UserEntity(); | ||
| 42 | + u.setSUserNo("uno_" + System.nanoTime()); | ||
| 43 | + u.setSUserName(userName); | ||
| 44 | + u.setIStaffId(staffId); | ||
| 45 | + u.setSUserType("普通用户"); | ||
| 46 | + u.setSLanguage("zh"); | ||
| 47 | + u.setBCanModifyDocs(false); | ||
| 48 | + u.setSPasswordHash("$2a$10$x"); | ||
| 49 | + u.setBDeleted(false); | ||
| 50 | + u.setTCreateDate(LocalDateTime.now()); | ||
| 51 | + userMapper.insert(u); | ||
| 52 | + return u.getIIncrement(); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + void searchUsers_emptyFilter_returnsAllUndeletedAsPage() { | ||
| 57 | + Integer staffId = insertStaff("张三"); | ||
| 58 | + insertUser("alice_" + System.nanoTime(), staffId); | ||
| 59 | + insertUser("bob_" + System.nanoTime(), null); | ||
| 60 | + | ||
| 61 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 62 | + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, null); | ||
| 63 | + | ||
| 64 | + assertThat(result.getTotal()).isGreaterThanOrEqualTo(2L); | ||
| 65 | + assertThat(result.getRecords()).extracting(UserListItemVO::getSUserName) | ||
| 66 | + .anyMatch(n -> n.startsWith("alice_") || n.startsWith("bob_")); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + @Test | ||
| 70 | + void searchUsers_filterByUserName_filtersCorrectly() { | ||
| 71 | + String alicePrefix = "ali_" + System.nanoTime(); | ||
| 72 | + insertUser(alicePrefix + "_alice", null); | ||
| 73 | + insertUser("bob_unmatch_" + System.nanoTime(), null); | ||
| 74 | + | ||
| 75 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 76 | + query.setQueryField("username"); | ||
| 77 | + query.setMatchType("contains"); | ||
| 78 | + query.setQueryValue(alicePrefix); | ||
| 79 | + | ||
| 80 | + // column 由 service 层映射;mapper IT 直接传 "u.sUserName" | ||
| 81 | + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, "u.sUserName"); | ||
| 82 | + | ||
| 83 | + assertThat(result.getRecords()).hasSize(1); | ||
| 84 | + assertThat(result.getRecords().get(0).getSUserName()).startsWith(alicePrefix); | ||
| 85 | + } | ||
| 86 | +} |
backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; | ||
| 4 | +import com.xly.erp.module.usr.entity.StaffEntity; | ||
| 5 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 6 | +import com.xly.erp.module.usr.entity.UserPermissionEntity; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 9 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 10 | +import org.springframework.test.annotation.Rollback; | ||
| 11 | +import org.springframework.test.context.ActiveProfiles; | ||
| 12 | +import org.springframework.transaction.annotation.Transactional; | ||
| 13 | + | ||
| 14 | +import java.time.LocalDateTime; | ||
| 15 | + | ||
| 16 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 17 | + | ||
| 18 | +@SpringBootTest | ||
| 19 | +@ActiveProfiles("test") | ||
| 20 | +@Transactional | ||
| 21 | +@Rollback | ||
| 22 | +class UsrMappersIT { | ||
| 23 | + | ||
| 24 | + @Autowired UserMapper userMapper; | ||
| 25 | + @Autowired StaffMapper staffMapper; | ||
| 26 | + @Autowired PermissionCategoryMapper permissionCategoryMapper; | ||
| 27 | + @Autowired UserPermissionMapper userPermissionMapper; | ||
| 28 | + | ||
| 29 | + @Test | ||
| 30 | + void staff_insertAndSelect() { | ||
| 31 | + StaffEntity s = new StaffEntity(); | ||
| 32 | + s.setSStaffNo("staff_" + System.nanoTime()); | ||
| 33 | + s.setSStaffName("张三"); | ||
| 34 | + s.setSDepartment("研发部"); | ||
| 35 | + s.setBDeleted(false); | ||
| 36 | + s.setTCreateDate(LocalDateTime.now()); | ||
| 37 | + assertThat(staffMapper.insert(s)).isEqualTo(1); | ||
| 38 | + assertThat(s.getIIncrement()).isPositive(); | ||
| 39 | + StaffEntity loaded = staffMapper.selectById(s.getIIncrement()); | ||
| 40 | + assertThat(loaded.getSStaffName()).isEqualTo("张三"); | ||
| 41 | + assertThat(loaded.getSDepartment()).isEqualTo("研发部"); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @Test | ||
| 45 | + void permissionCategory_insertAndSelect() { | ||
| 46 | + PermissionCategoryEntity p = new PermissionCategoryEntity(); | ||
| 47 | + p.setSCategoryCode("cat_" + System.nanoTime()); | ||
| 48 | + p.setSCategoryName("基础权限"); | ||
| 49 | + p.setISortOrder(0); | ||
| 50 | + p.setBDeleted(false); | ||
| 51 | + p.setTCreateDate(LocalDateTime.now()); | ||
| 52 | + assertThat(permissionCategoryMapper.insert(p)).isEqualTo(1); | ||
| 53 | + PermissionCategoryEntity loaded = permissionCategoryMapper.selectById(p.getIIncrement()); | ||
| 54 | + assertThat(loaded.getSCategoryName()).isEqualTo("基础权限"); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + @Test | ||
| 58 | + void user_insertAndSelect() { | ||
| 59 | + UserEntity u = new UserEntity(); | ||
| 60 | + u.setSUserNo("u_" + System.nanoTime()); | ||
| 61 | + u.setSUserName("alice_" + System.nanoTime()); | ||
| 62 | + u.setSUserType("普通用户"); | ||
| 63 | + u.setSLanguage("zh"); | ||
| 64 | + u.setBCanModifyDocs(false); | ||
| 65 | + u.setSPasswordHash("$2a$10$abcdefghijklmnopqrstuv"); | ||
| 66 | + u.setBDeleted(false); | ||
| 67 | + u.setTCreateDate(LocalDateTime.now()); | ||
| 68 | + assertThat(userMapper.insert(u)).isEqualTo(1); | ||
| 69 | + UserEntity loaded = userMapper.selectById(u.getIIncrement()); | ||
| 70 | + assertThat(loaded.getSUserName()).startsWith("alice_"); | ||
| 71 | + assertThat(loaded.getSUserType()).isEqualTo("普通用户"); | ||
| 72 | + assertThat(loaded.getSLanguage()).isEqualTo("zh"); | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + @Test | ||
| 76 | + void userPermission_insertAndSelect_requiresValidUserAndCategory() { | ||
| 77 | + // 先建合法 user + category(FK 兜底) | ||
| 78 | + UserEntity u = new UserEntity(); | ||
| 79 | + u.setSUserNo("upu_" + System.nanoTime()); | ||
| 80 | + u.setSUserName("upa_" + System.nanoTime()); | ||
| 81 | + u.setSUserType("普通用户"); | ||
| 82 | + u.setSLanguage("zh"); | ||
| 83 | + u.setBCanModifyDocs(false); | ||
| 84 | + u.setSPasswordHash("$2a$10$x"); | ||
| 85 | + u.setBDeleted(false); | ||
| 86 | + u.setTCreateDate(LocalDateTime.now()); | ||
| 87 | + userMapper.insert(u); | ||
| 88 | + | ||
| 89 | + PermissionCategoryEntity p = new PermissionCategoryEntity(); | ||
| 90 | + p.setSCategoryCode("upc_" + System.nanoTime()); | ||
| 91 | + p.setSCategoryName("upcat"); | ||
| 92 | + p.setISortOrder(0); | ||
| 93 | + p.setBDeleted(false); | ||
| 94 | + p.setTCreateDate(LocalDateTime.now()); | ||
| 95 | + permissionCategoryMapper.insert(p); | ||
| 96 | + | ||
| 97 | + UserPermissionEntity up = new UserPermissionEntity(); | ||
| 98 | + up.setIUserId(u.getIIncrement()); | ||
| 99 | + up.setICategoryId(p.getIIncrement()); | ||
| 100 | + up.setTCreateDate(LocalDateTime.now()); | ||
| 101 | + assertThat(userPermissionMapper.insert(up)).isEqualTo(1); | ||
| 102 | + UserPermissionEntity loaded = userPermissionMapper.selectById(up.getIIncrement()); | ||
| 103 | + assertThat(loaded.getIUserId()).isEqualTo(u.getIIncrement()); | ||
| 104 | + assertThat(loaded.getICategoryId()).isEqualTo(p.getIIncrement()); | ||
| 105 | + } | ||
| 106 | +} |
backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import org.junit.jupiter.api.BeforeEach; | ||
| 4 | +import org.junit.jupiter.api.Test; | ||
| 5 | + | ||
| 6 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 7 | + | ||
| 8 | +class InMemoryLoginAttemptStoreTest { | ||
| 9 | + | ||
| 10 | + private InMemoryLoginAttemptStore store; | ||
| 11 | + | ||
| 12 | + @BeforeEach | ||
| 13 | + void setUp() { | ||
| 14 | + store = new InMemoryLoginAttemptStore(); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + @Test | ||
| 18 | + void cooldown_initial_returnsZero() { | ||
| 19 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + @Test | ||
| 23 | + void recordFailure_under5_doesNotLock() { | ||
| 24 | + for (int i = 0; i < 4; i++) { | ||
| 25 | + store.recordFailure("alice"); | ||
| 26 | + } | ||
| 27 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + @Test | ||
| 31 | + void recordFailure_at5_triggersLock_cooldownPositive() { | ||
| 32 | + for (int i = 0; i < 5; i++) { | ||
| 33 | + store.recordFailure("alice"); | ||
| 34 | + } | ||
| 35 | + long cd = store.cooldownSeconds("alice"); | ||
| 36 | + assertThat(cd).isGreaterThan(0L); | ||
| 37 | + assertThat(cd).isLessThanOrEqualTo(15L * 60L); // 锁定 15 min | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + @Test | ||
| 41 | + void clear_resetsCount() { | ||
| 42 | + for (int i = 0; i < 5; i++) { | ||
| 43 | + store.recordFailure("alice"); | ||
| 44 | + } | ||
| 45 | + store.clear("alice"); | ||
| 46 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + @Test | ||
| 50 | + void cooldown_afterExpiry_resetsCount() { | ||
| 51 | + for (int i = 0; i < 5; i++) { | ||
| 52 | + store.recordFailure("alice"); | ||
| 53 | + } | ||
| 54 | + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); | ||
| 55 | + | ||
| 56 | + // 把锁定时间拨到过去 → 模拟到期 | ||
| 57 | + store.expireLockForTest("alice"); | ||
| 58 | + | ||
| 59 | + // 到期后 cooldownSeconds 应返回 0 + record 清空 | ||
| 60 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 61 | + | ||
| 62 | + // 验证 reset 真的清空了 count——再 recordFailure 4 次仍未锁定 | ||
| 63 | + for (int i = 0; i < 4; i++) { | ||
| 64 | + store.recordFailure("alice"); | ||
| 65 | + } | ||
| 66 | + assertThat(store.cooldownSeconds("alice")).isZero(); | ||
| 67 | + | ||
| 68 | + // 第 5 次 record 后再次锁定(业务规则 4 完整重启) | ||
| 69 | + store.recordFailure("alice"); | ||
| 70 | + assertThat(store.cooldownSeconds("alice")).isGreaterThan(0L); | ||
| 71 | + } | ||
| 72 | +} |
backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | ||
| 2 | + | ||
| 3 | +import io.jsonwebtoken.Claims; | ||
| 4 | +import io.jsonwebtoken.ExpiredJwtException; | ||
| 5 | +import io.jsonwebtoken.Jwts; | ||
| 6 | +import io.jsonwebtoken.security.Keys; | ||
| 7 | +import org.junit.jupiter.api.Test; | ||
| 8 | + | ||
| 9 | +import javax.crypto.SecretKey; | ||
| 10 | +import java.nio.charset.StandardCharsets; | ||
| 11 | +import java.time.Instant; | ||
| 12 | +import java.util.Date; | ||
| 13 | + | ||
| 14 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 15 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 16 | + | ||
| 17 | +class JwtTokenProviderTest { | ||
| 18 | + | ||
| 19 | + // 测试专用 fake secret——与 .env.local 生产 JWT_SECRET 无关。 | ||
| 20 | + // 32 字节(256 bit)随机 hex,仅用于单元测试隔离。 | ||
| 21 | + private static final String SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | ||
| 22 | + private static final long EXPIRES_IN = 7200L; | ||
| 23 | + | ||
| 24 | + @Test | ||
| 25 | + void signAndParse_returnsClaims() { | ||
| 26 | + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN); | ||
| 27 | + | ||
| 28 | + String token = provider.sign(42, "alice", "普通用户"); | ||
| 29 | + | ||
| 30 | + Claims claims = provider.parse(token); | ||
| 31 | + assertThat(claims.getSubject()).isEqualTo("alice"); | ||
| 32 | + assertThat(claims.get("uid", Integer.class)).isEqualTo(42); | ||
| 33 | + assertThat(claims.get("type", String.class)).isEqualTo("普通用户"); | ||
| 34 | + assertThat(claims.getExpiration().getTime() - claims.getIssuedAt().getTime()) | ||
| 35 | + .isEqualTo(EXPIRES_IN * 1000L); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @Test | ||
| 39 | + void parseExpiredToken_throwsExpiredJwtException() { | ||
| 40 | + // 用同一 secret 手工签一个已过期 token | ||
| 41 | + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); | ||
| 42 | + Instant now = Instant.now(); | ||
| 43 | + String expired = Jwts.builder() | ||
| 44 | + .subject("alice") | ||
| 45 | + .issuedAt(Date.from(now.minusSeconds(7200))) | ||
| 46 | + .expiration(Date.from(now.minusSeconds(60))) | ||
| 47 | + .signWith(key) | ||
| 48 | + .compact(); | ||
| 49 | + | ||
| 50 | + JwtTokenProvider provider = new JwtTokenProvider(SECRET, EXPIRES_IN); | ||
| 51 | + | ||
| 52 | + assertThatThrownBy(() -> provider.parse(expired)) | ||
| 53 | + .isInstanceOf(ExpiredJwtException.class); | ||
| 54 | + } | ||
| 55 | +} |
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.Wrapper; | ||
| 4 | +import com.xly.erp.common.exception.AccountLockedException; | ||
| 5 | +import com.xly.erp.common.exception.BizException; | ||
| 6 | +import com.xly.erp.common.response.ErrorCode; | ||
| 7 | +import com.xly.erp.module.usr.dto.LoginDTO; | ||
| 8 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 9 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 10 | +import com.xly.erp.module.usr.security.JwtTokenProvider; | ||
| 11 | +import com.xly.erp.module.usr.security.LoginAttemptStore; | ||
| 12 | +import com.xly.erp.module.usr.service.impl.LoginServiceImpl; | ||
| 13 | +import com.xly.erp.module.usr.vo.LoginResultVO; | ||
| 14 | +import org.junit.jupiter.api.Test; | ||
| 15 | +import org.junit.jupiter.api.extension.ExtendWith; | ||
| 16 | +import org.mockito.ArgumentCaptor; | ||
| 17 | +import org.mockito.InjectMocks; | ||
| 18 | +import org.mockito.Mock; | ||
| 19 | +import org.mockito.junit.jupiter.MockitoExtension; | ||
| 20 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 21 | + | ||
| 22 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 23 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 24 | +import static org.mockito.ArgumentMatchers.any; | ||
| 25 | +import static org.mockito.ArgumentMatchers.anyInt; | ||
| 26 | +import static org.mockito.ArgumentMatchers.anyString; | ||
| 27 | +import static org.mockito.ArgumentMatchers.eq; | ||
| 28 | +import static org.mockito.ArgumentMatchers.isNull; | ||
| 29 | +import static org.mockito.Mockito.never; | ||
| 30 | +import static org.mockito.Mockito.times; | ||
| 31 | +import static org.mockito.Mockito.verify; | ||
| 32 | +import static org.mockito.Mockito.when; | ||
| 33 | + | ||
| 34 | +@ExtendWith(MockitoExtension.class) | ||
| 35 | +class LoginServiceImplTest { | ||
| 36 | + | ||
| 37 | + @Mock UserMapper userMapper; | ||
| 38 | + @Mock PasswordEncoder passwordEncoder; | ||
| 39 | + @Mock LoginAttemptStore attemptStore; | ||
| 40 | + @Mock JwtTokenProvider jwtTokenProvider; | ||
| 41 | + | ||
| 42 | + @InjectMocks LoginServiceImpl service; | ||
| 43 | + | ||
| 44 | + private LoginDTO dto() { | ||
| 45 | + LoginDTO d = new LoginDTO(); | ||
| 46 | + d.setSUserName("alice"); | ||
| 47 | + d.setSPassword("666666"); | ||
| 48 | + d.setSVersion("standard"); | ||
| 49 | + return d; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + private UserEntity userEntity() { | ||
| 53 | + UserEntity u = new UserEntity(); | ||
| 54 | + u.setIIncrement(42); | ||
| 55 | + u.setSUserNo("u001"); | ||
| 56 | + u.setSUserName("alice"); | ||
| 57 | + u.setSUserType("普通用户"); | ||
| 58 | + u.setSLanguage("zh"); | ||
| 59 | + u.setSPasswordHash("$2a$10$hash"); | ||
| 60 | + u.setBDeleted(false); | ||
| 61 | + return u; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @Test | ||
| 65 | + void login_validCredentials_returnsTokenAndClearsFailCount() { | ||
| 66 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); | ||
| 67 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); | ||
| 68 | + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(true); | ||
| 69 | + when(jwtTokenProvider.sign(42, "alice", "普通用户")).thenReturn("jwt.token.value"); | ||
| 70 | + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); | ||
| 71 | + | ||
| 72 | + LoginResultVO vo = service.login(dto()); | ||
| 73 | + | ||
| 74 | + assertThat(vo.getAccessToken()).isEqualTo("jwt.token.value"); | ||
| 75 | + assertThat(vo.getExpiresIn()).isEqualTo(7200L); | ||
| 76 | + assertThat(vo.getUser().getIIncrement()).isEqualTo(42); | ||
| 77 | + assertThat(vo.getUser().getSUserName()).isEqualTo("alice"); | ||
| 78 | + assertThat(vo.getUser().getSUserType()).isEqualTo("普通用户"); | ||
| 79 | + verify(attemptStore).clear("alice"); | ||
| 80 | + verify(userMapper).update(isNull(), any(Wrapper.class)); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + @Test | ||
| 84 | + void login_userNotFound_returns40101_recordsFailure() { | ||
| 85 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); | ||
| 86 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); | ||
| 87 | + | ||
| 88 | + assertThatThrownBy(() -> service.login(dto())) | ||
| 89 | + .isInstanceOf(BizException.class) | ||
| 90 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 91 | + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); | ||
| 92 | + | ||
| 93 | + verify(attemptStore).recordFailure("alice"); | ||
| 94 | + verify(userMapper, never()).update(any(), any(Wrapper.class)); | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + @Test | ||
| 98 | + void login_passwordMismatch_returns40101_recordsFailure() { | ||
| 99 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); | ||
| 100 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); | ||
| 101 | + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); | ||
| 102 | + | ||
| 103 | + assertThatThrownBy(() -> service.login(dto())) | ||
| 104 | + .isInstanceOf(BizException.class) | ||
| 105 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 106 | + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); | ||
| 107 | + | ||
| 108 | + verify(attemptStore).recordFailure("alice"); | ||
| 109 | + verify(userMapper, never()).update(any(), any(Wrapper.class)); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + @Test | ||
| 113 | + void login_accountLocked_throwsAccountLockedException_withCooldown() { | ||
| 114 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(540L); | ||
| 115 | + | ||
| 116 | + assertThatThrownBy(() -> service.login(dto())) | ||
| 117 | + .isInstanceOf(AccountLockedException.class) | ||
| 118 | + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) | ||
| 119 | + .isEqualTo(540L); | ||
| 120 | + | ||
| 121 | + verify(userMapper, never()).selectOne(any(Wrapper.class)); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + @Test | ||
| 125 | + void login_5thFailureTriggersLock_throwsAccountLockedException() { | ||
| 126 | + when(attemptStore.cooldownSeconds("alice")) | ||
| 127 | + .thenReturn(0L) // 入口检查通过 | ||
| 128 | + .thenReturn(900L); // recordFailure 后再查时已锁定 | ||
| 129 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); | ||
| 130 | + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); | ||
| 131 | + | ||
| 132 | + assertThatThrownBy(() -> service.login(dto())) | ||
| 133 | + .isInstanceOf(AccountLockedException.class) | ||
| 134 | + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) | ||
| 135 | + .isEqualTo(900L); | ||
| 136 | + | ||
| 137 | + verify(attemptStore).recordFailure("alice"); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + @Test | ||
| 141 | + void login_successUpdatesTLastLoginDate_viaSetClause() { | ||
| 142 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); | ||
| 143 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); | ||
| 144 | + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); | ||
| 145 | + when(jwtTokenProvider.sign(anyInt(), anyString(), anyString())).thenReturn("token"); | ||
| 146 | + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); | ||
| 147 | + | ||
| 148 | + service.login(dto()); | ||
| 149 | + | ||
| 150 | + // 验证 update(entity=null, wrapper=non-null),对应 LambdaUpdateWrapper.set(tLastLoginDate, now) | ||
| 151 | + verify(userMapper, times(1)).update(isNull(), any(Wrapper.class)); | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + @Test | ||
| 155 | + void login_userSoftDeleted_returns40101() { | ||
| 156 | + // selectOne 已过滤 bDeleted=0 → 软删用户返回 null(与 not found 等效路径) | ||
| 157 | + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); | ||
| 158 | + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); | ||
| 159 | + | ||
| 160 | + assertThatThrownBy(() -> service.login(dto())) | ||
| 161 | + .isInstanceOf(BizException.class) | ||
| 162 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 163 | + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); | ||
| 164 | + } | ||
| 165 | +} |
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.conditions.Wrapper; | ||
| 4 | +import com.baomidou.mybatisplus.core.metadata.IPage; | ||
| 5 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | ||
| 6 | +import com.xly.erp.common.exception.BizException; | ||
| 7 | +import com.xly.erp.common.response.ErrorCode; | ||
| 8 | +import com.xly.erp.common.response.PageResult; | ||
| 9 | +import com.xly.erp.module.usr.dto.UserCreateDTO; | ||
| 10 | +import com.xly.erp.module.usr.dto.UserQueryDTO; | ||
| 11 | +import com.xly.erp.module.usr.dto.UserUpdateDTO; | ||
| 12 | +import com.xly.erp.module.usr.entity.PermissionCategoryEntity; | ||
| 13 | +import com.xly.erp.module.usr.entity.StaffEntity; | ||
| 14 | +import com.xly.erp.module.usr.entity.UserEntity; | ||
| 15 | +import com.xly.erp.module.usr.entity.UserPermissionEntity; | ||
| 16 | +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; | ||
| 17 | +import com.xly.erp.module.usr.mapper.StaffMapper; | ||
| 18 | +import com.xly.erp.module.usr.mapper.UserMapper; | ||
| 19 | +import com.xly.erp.module.usr.mapper.UserPermissionMapper; | ||
| 20 | +import com.xly.erp.module.usr.service.impl.UserServiceImpl; | ||
| 21 | +import com.xly.erp.module.usr.vo.UserListItemVO; | ||
| 22 | +import com.xly.erp.module.usr.vo.UserVO; | ||
| 23 | +import org.junit.jupiter.api.Test; | ||
| 24 | +import org.junit.jupiter.api.extension.ExtendWith; | ||
| 25 | +import org.mockito.ArgumentCaptor; | ||
| 26 | +import org.mockito.InjectMocks; | ||
| 27 | +import org.mockito.Mock; | ||
| 28 | +import org.mockito.junit.jupiter.MockitoExtension; | ||
| 29 | +import org.springframework.dao.DuplicateKeyException; | ||
| 30 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 31 | + | ||
| 32 | +import java.util.List; | ||
| 33 | + | ||
| 34 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 35 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 36 | +import static org.mockito.ArgumentMatchers.any; | ||
| 37 | +import static org.mockito.ArgumentMatchers.anyList; | ||
| 38 | +import static org.mockito.Mockito.never; | ||
| 39 | +import static org.mockito.Mockito.times; | ||
| 40 | +import static org.mockito.Mockito.verify; | ||
| 41 | +import static org.mockito.Mockito.when; | ||
| 42 | + | ||
| 43 | +@ExtendWith(MockitoExtension.class) | ||
| 44 | +class UserServiceImplTest { | ||
| 45 | + | ||
| 46 | + @Mock UserMapper userMapper; | ||
| 47 | + @Mock StaffMapper staffMapper; | ||
| 48 | + @Mock PermissionCategoryMapper permissionCategoryMapper; | ||
| 49 | + @Mock UserPermissionMapper userPermissionMapper; | ||
| 50 | + @Mock PasswordEncoder passwordEncoder; | ||
| 51 | + | ||
| 52 | + @InjectMocks UserServiceImpl service; | ||
| 53 | + | ||
| 54 | + private UserCreateDTO baseDto() { | ||
| 55 | + UserCreateDTO d = new UserCreateDTO(); | ||
| 56 | + d.setSUserNo("u001"); | ||
| 57 | + d.setSUserName("alice"); | ||
| 58 | + d.setSUserType("普通用户"); | ||
| 59 | + d.setSLanguage("zh"); | ||
| 60 | + return d; | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + @Test | ||
| 64 | + void create_minimalFields_returnsVOWithBCryptHash() { | ||
| 65 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 66 | + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$mockhash"); | ||
| 67 | + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { | ||
| 68 | + UserEntity u = inv.getArgument(0); | ||
| 69 | + u.setIIncrement(101); | ||
| 70 | + return 1; | ||
| 71 | + }); | ||
| 72 | + | ||
| 73 | + UserVO vo = service.create(baseDto()); | ||
| 74 | + | ||
| 75 | + assertThat(vo.getIIncrement()).isEqualTo(101); | ||
| 76 | + assertThat(vo.getSUserName()).isEqualTo("alice"); | ||
| 77 | + assertThat(vo.getBCanModifyDocs()).isFalse(); | ||
| 78 | + assertThat(vo.getPermissionCategoryIds()).isEmpty(); | ||
| 79 | + | ||
| 80 | + ArgumentCaptor<UserEntity> cap = ArgumentCaptor.forClass(UserEntity.class); | ||
| 81 | + verify(userMapper).insert(cap.capture()); | ||
| 82 | + UserEntity saved = cap.getValue(); | ||
| 83 | + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$mockhash"); | ||
| 84 | + assertThat(saved.getBDeleted()).isFalse(); | ||
| 85 | + assertThat(saved.getTCreateDate()).isNotNull(); | ||
| 86 | + assertThat(saved.getSCreatedBy()).isNull(); | ||
| 87 | + assertThat(saved.getSBrandsId()).isNull(); | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + @Test | ||
| 91 | + void create_withStaffAndPermissions_writesAssociation() { | ||
| 92 | + UserCreateDTO d = baseDto(); | ||
| 93 | + d.setIStaffId(7); | ||
| 94 | + d.setPermissionCategoryIds(List.of(1, 2, 3)); | ||
| 95 | + | ||
| 96 | + StaffEntity staff = new StaffEntity(); | ||
| 97 | + staff.setIIncrement(7); | ||
| 98 | + staff.setBDeleted(false); | ||
| 99 | + when(staffMapper.selectById(7)).thenReturn(staff); | ||
| 100 | + | ||
| 101 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 102 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of( | ||
| 103 | + cat(1), cat(2), cat(3) | ||
| 104 | + )); | ||
| 105 | + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); | ||
| 106 | + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { | ||
| 107 | + UserEntity u = inv.getArgument(0); | ||
| 108 | + u.setIIncrement(202); | ||
| 109 | + return 1; | ||
| 110 | + }); | ||
| 111 | + | ||
| 112 | + UserVO vo = service.create(d); | ||
| 113 | + | ||
| 114 | + assertThat(vo.getIIncrement()).isEqualTo(202); | ||
| 115 | + assertThat(vo.getIStaffId()).isEqualTo(7); | ||
| 116 | + assertThat(vo.getPermissionCategoryIds()).containsExactly(1, 2, 3); | ||
| 117 | + | ||
| 118 | + ArgumentCaptor<UserPermissionEntity> upCap = ArgumentCaptor.forClass(UserPermissionEntity.class); | ||
| 119 | + verify(userPermissionMapper, times(3)).insert(upCap.capture()); | ||
| 120 | + List<UserPermissionEntity> ups = upCap.getAllValues(); | ||
| 121 | + assertThat(ups).extracting(UserPermissionEntity::getIUserId).containsOnly(202); | ||
| 122 | + assertThat(ups).extracting(UserPermissionEntity::getICategoryId).containsExactly(1, 2, 3); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + @Test | ||
| 126 | + void create_duplicateUserName_throws40921() { | ||
| 127 | + // 第一次 selectCount(sUserName) 返回 1 | ||
| 128 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(1L); | ||
| 129 | + | ||
| 130 | + assertThatThrownBy(() -> service.create(baseDto())) | ||
| 131 | + .isInstanceOf(BizException.class) | ||
| 132 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 133 | + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); | ||
| 134 | + verify(userMapper, never()).insert((UserEntity) any()); | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + @Test | ||
| 138 | + void create_duplicateUserNo_throws40921() { | ||
| 139 | + // 第一次 (sUserName) 返回 0;第二次 (sUserNo) 返回 1 | ||
| 140 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L, 1L); | ||
| 141 | + | ||
| 142 | + assertThatThrownBy(() -> service.create(baseDto())) | ||
| 143 | + .isInstanceOf(BizException.class) | ||
| 144 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 145 | + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); | ||
| 146 | + verify(userMapper, never()).insert((UserEntity) any()); | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + @Test | ||
| 150 | + void create_staffNotFound_throws40421() { | ||
| 151 | + UserCreateDTO d = baseDto(); | ||
| 152 | + d.setIStaffId(999999); | ||
| 153 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 154 | + when(staffMapper.selectById(999999)).thenReturn(null); | ||
| 155 | + | ||
| 156 | + assertThatThrownBy(() -> service.create(d)) | ||
| 157 | + .isInstanceOf(BizException.class) | ||
| 158 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 159 | + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + @Test | ||
| 163 | + void create_staffSoftDeleted_throws40421() { | ||
| 164 | + UserCreateDTO d = baseDto(); | ||
| 165 | + d.setIStaffId(5); | ||
| 166 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 167 | + StaffEntity deleted = new StaffEntity(); | ||
| 168 | + deleted.setIIncrement(5); | ||
| 169 | + deleted.setBDeleted(true); | ||
| 170 | + when(staffMapper.selectById(5)).thenReturn(deleted); | ||
| 171 | + | ||
| 172 | + assertThatThrownBy(() -> service.create(d)) | ||
| 173 | + .isInstanceOf(BizException.class) | ||
| 174 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 175 | + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + @Test | ||
| 179 | + void create_permissionCategoryNotFound_throws40422() { | ||
| 180 | + UserCreateDTO d = baseDto(); | ||
| 181 | + d.setPermissionCategoryIds(List.of(1, 999999)); | ||
| 182 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 183 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(1))); // 只返回 1 条,缺 999999 | ||
| 184 | + | ||
| 185 | + assertThatThrownBy(() -> service.create(d)) | ||
| 186 | + .isInstanceOf(BizException.class) | ||
| 187 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 188 | + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()); | ||
| 189 | + verify(userMapper, never()).insert((UserEntity) any()); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + @Test | ||
| 193 | + void create_emptyPermissionCategoryIds_doesNotInsertAssociation() { | ||
| 194 | + UserCreateDTO d = baseDto(); | ||
| 195 | + d.setPermissionCategoryIds(List.of()); | ||
| 196 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 197 | + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); | ||
| 198 | + when(userMapper.insert((UserEntity) any())).thenAnswer(inv -> { | ||
| 199 | + ((UserEntity) inv.getArgument(0)).setIIncrement(303); | ||
| 200 | + return 1; | ||
| 201 | + }); | ||
| 202 | + | ||
| 203 | + service.create(d); | ||
| 204 | + | ||
| 205 | + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any()); | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + @Test | ||
| 209 | + void create_concurrentDuplicate_dupKeyException_mappedTo40921() { | ||
| 210 | + when(userMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | ||
| 211 | + when(passwordEncoder.encode("666666")).thenReturn("$2a$10$h"); | ||
| 212 | + when(userMapper.insert((UserEntity) any())) | ||
| 213 | + .thenThrow(new DuplicateKeyException("uk_user_name")); | ||
| 214 | + | ||
| 215 | + assertThatThrownBy(() -> service.create(baseDto())) | ||
| 216 | + .isInstanceOf(BizException.class) | ||
| 217 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 218 | + .isEqualTo(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()); | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + private static PermissionCategoryEntity cat(int id) { | ||
| 222 | + PermissionCategoryEntity p = new PermissionCategoryEntity(); | ||
| 223 | + p.setIIncrement(id); | ||
| 224 | + p.setBDeleted(false); | ||
| 225 | + return p; | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + // ============================================================ | ||
| 229 | + // REQ-USR-002 update 系列 | ||
| 230 | + // ============================================================ | ||
| 231 | + | ||
| 232 | + private UserUpdateDTO updateDto() { | ||
| 233 | + UserUpdateDTO d = new UserUpdateDTO(); | ||
| 234 | + d.setIStaffId(7); | ||
| 235 | + d.setSUserType("超级管理员"); | ||
| 236 | + d.setSLanguage("en"); | ||
| 237 | + d.setBCanModifyDocs(true); | ||
| 238 | + d.setPermissionCategoryIds(List.of(10, 20)); | ||
| 239 | + return d; | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + private UserEntity existingTargetUser(int id) { | ||
| 243 | + UserEntity u = new UserEntity(); | ||
| 244 | + u.setIIncrement(id); | ||
| 245 | + u.setSUserNo("u_orig"); | ||
| 246 | + u.setSUserName("alice_orig"); | ||
| 247 | + u.setIStaffId(3); | ||
| 248 | + u.setSUserType("普通用户"); | ||
| 249 | + u.setSLanguage("zh"); | ||
| 250 | + u.setBCanModifyDocs(false); | ||
| 251 | + u.setSPasswordHash("$2a$10$origHash"); | ||
| 252 | + u.setBDeleted(false); | ||
| 253 | + u.setSCreatedBy("system"); | ||
| 254 | + return u; | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + @Test | ||
| 258 | + void update_targetNotFound_throws40431() { | ||
| 259 | + when(userMapper.selectById(99)).thenReturn(null); | ||
| 260 | + | ||
| 261 | + assertThatThrownBy(() -> service.update(99, updateDto())) | ||
| 262 | + .isInstanceOf(BizException.class) | ||
| 263 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 264 | + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode()); | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + @Test | ||
| 268 | + void update_targetSoftDeleted_throws40431() { | ||
| 269 | + UserEntity target = existingTargetUser(100); | ||
| 270 | + target.setBDeleted(true); | ||
| 271 | + when(userMapper.selectById(100)).thenReturn(target); | ||
| 272 | + | ||
| 273 | + assertThatThrownBy(() -> service.update(100, updateDto())) | ||
| 274 | + .isInstanceOf(BizException.class) | ||
| 275 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 276 | + .isEqualTo(ErrorCode.USR_NOT_FOUND.getCode()); | ||
| 277 | + } | ||
| 278 | + | ||
| 279 | + @Test | ||
| 280 | + void update_staffNotFound_throws40421() { | ||
| 281 | + UserEntity target = existingTargetUser(101); | ||
| 282 | + when(userMapper.selectById(101)).thenReturn(target); | ||
| 283 | + UserUpdateDTO d = updateDto(); | ||
| 284 | + d.setIStaffId(999999); | ||
| 285 | + when(staffMapper.selectById(999999)).thenReturn(null); | ||
| 286 | + | ||
| 287 | + assertThatThrownBy(() -> service.update(101, d)) | ||
| 288 | + .isInstanceOf(BizException.class) | ||
| 289 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 290 | + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + @Test | ||
| 294 | + void update_staffSoftDeleted_throws40421() { | ||
| 295 | + UserEntity target = existingTargetUser(102); | ||
| 296 | + when(userMapper.selectById(102)).thenReturn(target); | ||
| 297 | + UserUpdateDTO d = updateDto(); | ||
| 298 | + d.setIStaffId(8); | ||
| 299 | + StaffEntity staff = new StaffEntity(); | ||
| 300 | + staff.setIIncrement(8); | ||
| 301 | + staff.setBDeleted(true); | ||
| 302 | + when(staffMapper.selectById(8)).thenReturn(staff); | ||
| 303 | + | ||
| 304 | + assertThatThrownBy(() -> service.update(102, d)) | ||
| 305 | + .isInstanceOf(BizException.class) | ||
| 306 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 307 | + .isEqualTo(ErrorCode.STAFF_NOT_FOUND.getCode()); | ||
| 308 | + } | ||
| 309 | + | ||
| 310 | + @Test | ||
| 311 | + void update_permissionCategoryNotFound_throws40422() { | ||
| 312 | + UserEntity target = existingTargetUser(103); | ||
| 313 | + when(userMapper.selectById(103)).thenReturn(target); | ||
| 314 | + UserUpdateDTO d = updateDto(); | ||
| 315 | + d.setIStaffId(null); // 跳过 staff 校验 | ||
| 316 | + d.setPermissionCategoryIds(List.of(10, 999999)); | ||
| 317 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10))); | ||
| 318 | + | ||
| 319 | + assertThatThrownBy(() -> service.update(103, d)) | ||
| 320 | + .isInstanceOf(BizException.class) | ||
| 321 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 322 | + .isEqualTo(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()); | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + @Test | ||
| 326 | + void update_full_returnsVOWithUpdatedFields_andRebuildsPermissions() { | ||
| 327 | + UserEntity target = existingTargetUser(104); | ||
| 328 | + when(userMapper.selectById(104)).thenReturn(target); | ||
| 329 | + StaffEntity staff = new StaffEntity(); | ||
| 330 | + staff.setIIncrement(7); | ||
| 331 | + staff.setBDeleted(false); | ||
| 332 | + when(staffMapper.selectById(7)).thenReturn(staff); | ||
| 333 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); | ||
| 334 | + | ||
| 335 | + UserVO vo = service.update(104, updateDto()); | ||
| 336 | + | ||
| 337 | + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class); | ||
| 338 | + verify(userMapper).updateById(userCap.capture()); | ||
| 339 | + UserEntity saved = userCap.getValue(); | ||
| 340 | + // 已修改字段 | ||
| 341 | + assertThat(saved.getIStaffId()).isEqualTo(7); | ||
| 342 | + assertThat(saved.getSUserType()).isEqualTo("超级管理员"); | ||
| 343 | + assertThat(saved.getSLanguage()).isEqualTo("en"); | ||
| 344 | + assertThat(saved.getBCanModifyDocs()).isTrue(); | ||
| 345 | + // 保留字段 | ||
| 346 | + assertThat(saved.getSUserNo()).isEqualTo("u_orig"); | ||
| 347 | + assertThat(saved.getSUserName()).isEqualTo("alice_orig"); | ||
| 348 | + assertThat(saved.getSPasswordHash()).isEqualTo("$2a$10$origHash"); | ||
| 349 | + assertThat(saved.getSCreatedBy()).isEqualTo("system"); | ||
| 350 | + | ||
| 351 | + // 关联表先删后插 | ||
| 352 | + verify(userPermissionMapper).delete(any(Wrapper.class)); | ||
| 353 | + ArgumentCaptor<UserPermissionEntity> upCap = ArgumentCaptor.forClass(UserPermissionEntity.class); | ||
| 354 | + verify(userPermissionMapper, times(2)).insert(upCap.capture()); | ||
| 355 | + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getICategoryId) | ||
| 356 | + .containsExactly(10, 20); | ||
| 357 | + assertThat(upCap.getAllValues()).extracting(UserPermissionEntity::getIUserId) | ||
| 358 | + .containsOnly(104); | ||
| 359 | + | ||
| 360 | + assertThat(vo.getPermissionCategoryIds()).containsExactly(10, 20); | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + @Test | ||
| 364 | + void update_partialNullBCanModifyDocs_keepsOriginal() { | ||
| 365 | + UserEntity target = existingTargetUser(105); | ||
| 366 | + target.setBCanModifyDocs(true); // 原值 true | ||
| 367 | + when(userMapper.selectById(105)).thenReturn(target); | ||
| 368 | + StaffEntity staff = new StaffEntity(); | ||
| 369 | + staff.setIIncrement(7); | ||
| 370 | + staff.setBDeleted(false); | ||
| 371 | + when(staffMapper.selectById(7)).thenReturn(staff); | ||
| 372 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); | ||
| 373 | + | ||
| 374 | + UserUpdateDTO d = updateDto(); | ||
| 375 | + d.setBCanModifyDocs(null); // 期望保留原值 | ||
| 376 | + | ||
| 377 | + service.update(105, d); | ||
| 378 | + | ||
| 379 | + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class); | ||
| 380 | + verify(userMapper).updateById(userCap.capture()); | ||
| 381 | + assertThat(userCap.getValue().getBCanModifyDocs()).isTrue(); | ||
| 382 | + } | ||
| 383 | + | ||
| 384 | + @Test | ||
| 385 | + void update_clearStaffId_setsToNull() { | ||
| 386 | + UserEntity target = existingTargetUser(106); | ||
| 387 | + target.setIStaffId(3); // 原本有 | ||
| 388 | + when(userMapper.selectById(106)).thenReturn(target); | ||
| 389 | + when(permissionCategoryMapper.selectBatchIds(anyList())).thenReturn(List.of(cat(10), cat(20))); | ||
| 390 | + | ||
| 391 | + UserUpdateDTO d = updateDto(); | ||
| 392 | + d.setIStaffId(null); // 显式清空 | ||
| 393 | + | ||
| 394 | + service.update(106, d); | ||
| 395 | + | ||
| 396 | + ArgumentCaptor<UserEntity> userCap = ArgumentCaptor.forClass(UserEntity.class); | ||
| 397 | + verify(userMapper).updateById(userCap.capture()); | ||
| 398 | + assertThat(userCap.getValue().getIStaffId()).isNull(); | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + @Test | ||
| 402 | + void update_emptyPermissionCategoryIds_clearsAllAssociations() { | ||
| 403 | + UserEntity target = existingTargetUser(107); | ||
| 404 | + when(userMapper.selectById(107)).thenReturn(target); | ||
| 405 | + StaffEntity staff = new StaffEntity(); | ||
| 406 | + staff.setIIncrement(7); | ||
| 407 | + staff.setBDeleted(false); | ||
| 408 | + when(staffMapper.selectById(7)).thenReturn(staff); | ||
| 409 | + | ||
| 410 | + UserUpdateDTO d = updateDto(); | ||
| 411 | + d.setPermissionCategoryIds(List.of()); // 清空全部 | ||
| 412 | + | ||
| 413 | + service.update(107, d); | ||
| 414 | + | ||
| 415 | + verify(userPermissionMapper).delete(any(Wrapper.class)); | ||
| 416 | + verify(userPermissionMapper, never()).insert((UserPermissionEntity) any()); | ||
| 417 | + } | ||
| 418 | + | ||
| 419 | + // ============================================================ | ||
| 420 | + // REQ-USR-003 search 系列 | ||
| 421 | + // ============================================================ | ||
| 422 | + | ||
| 423 | + @Test | ||
| 424 | + void search_emptyDb_returnsEmptyPage() { | ||
| 425 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 426 | + IPage<UserListItemVO> emptyPage = new Page<>(1, 20); | ||
| 427 | + emptyPage.setTotal(0L); | ||
| 428 | + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); | ||
| 429 | + | ||
| 430 | + PageResult<UserListItemVO> result = service.search(query); | ||
| 431 | + assertThat(result.getTotal()).isZero(); | ||
| 432 | + assertThat(result.getList()).isEmpty(); | ||
| 433 | + } | ||
| 434 | + | ||
| 435 | + @Test | ||
| 436 | + void search_invalidQueryField_throws40010() { | ||
| 437 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 438 | + query.setQueryField("invalid_field"); | ||
| 439 | + | ||
| 440 | + assertThatThrownBy(() -> service.search(query)) | ||
| 441 | + .isInstanceOf(BizException.class) | ||
| 442 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 443 | + .isEqualTo(ErrorCode.PARAM_INVALID.getCode()); | ||
| 444 | + } | ||
| 445 | + | ||
| 446 | + @Test | ||
| 447 | + void search_invalidMatchType_throws40010() { | ||
| 448 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 449 | + query.setMatchType("like"); // 非白名单 | ||
| 450 | + | ||
| 451 | + assertThatThrownBy(() -> service.search(query)) | ||
| 452 | + .isInstanceOf(BizException.class) | ||
| 453 | + .extracting(e -> ((BizException) e).getCode()) | ||
| 454 | + .isEqualTo(ErrorCode.PARAM_INVALID.getCode()); | ||
| 455 | + } | ||
| 456 | + | ||
| 457 | + @Test | ||
| 458 | + void search_passesMappedColumnToMapper() { | ||
| 459 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 460 | + query.setQueryField("username"); | ||
| 461 | + query.setQueryValue("alice"); | ||
| 462 | + IPage<UserListItemVO> emptyPage = new Page<>(1, 20); | ||
| 463 | + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); | ||
| 464 | + | ||
| 465 | + service.search(query); | ||
| 466 | + | ||
| 467 | + ArgumentCaptor<String> colCap = ArgumentCaptor.forClass(String.class); | ||
| 468 | + verify(userMapper).searchUsers(any(IPage.class), any(UserQueryDTO.class), colCap.capture()); | ||
| 469 | + assertThat(colCap.getValue()).isEqualTo("u.sUserName"); | ||
| 470 | + } | ||
| 471 | + | ||
| 472 | + @Test | ||
| 473 | + void search_appliesDefaultPagination_whenNullPageNumOrSize() { | ||
| 474 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 475 | + query.setPageNum(null); | ||
| 476 | + query.setPageSize(null); | ||
| 477 | + IPage<UserListItemVO> emptyPage = new Page<>(1, 20); | ||
| 478 | + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); | ||
| 479 | + | ||
| 480 | + service.search(query); | ||
| 481 | + | ||
| 482 | + ArgumentCaptor<IPage> pageCap = ArgumentCaptor.forClass(IPage.class); | ||
| 483 | + verify(userMapper).searchUsers(pageCap.capture(), any(UserQueryDTO.class), any()); | ||
| 484 | + IPage<?> page = pageCap.getValue(); | ||
| 485 | + assertThat(page.getCurrent()).isEqualTo(1L); | ||
| 486 | + assertThat(page.getSize()).isEqualTo(20L); | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + @Test | ||
| 490 | + void search_deletedQueryValueTrue_normalizedToOne() { | ||
| 491 | + UserQueryDTO query = new UserQueryDTO(); | ||
| 492 | + query.setQueryField("deleted"); | ||
| 493 | + query.setQueryValue("true"); | ||
| 494 | + query.setMatchType("equals"); | ||
| 495 | + IPage<UserListItemVO> emptyPage = new Page<>(1, 20); | ||
| 496 | + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage); | ||
| 497 | + | ||
| 498 | + service.search(query); | ||
| 499 | + | ||
| 500 | + ArgumentCaptor<UserQueryDTO> cap = ArgumentCaptor.forClass(UserQueryDTO.class); | ||
| 501 | + verify(userMapper).searchUsers(any(IPage.class), cap.capture(), any()); | ||
| 502 | + assertThat(cap.getValue().getQueryValue()).isEqualTo("1"); | ||
| 503 | + } | ||
| 504 | +} |
backend/src/test/resources/application-test.yml
0 → 100644
docs/08-模块任务管理.md
| @@ -58,19 +58,19 @@ | @@ -58,19 +58,19 @@ | ||
| 58 | - module_mod 模块管理 | 58 | - module_mod 模块管理 |
| 59 | - 依赖: — | 59 | - 依赖: — |
| 60 | - 路径: backend/src/main/java/com/xly/erp/module/mod/, frontend/src/pages/mod/ | 60 | - 路径: backend/src/main/java/com/xly/erp/module/mod/, frontend/src/pages/mod/ |
| 61 | - - MR: — | 61 | + - MR: !1 |
| 62 | - 功能: | 62 | - 功能: |
| 63 | - - [ ] REQ-MOD-001 模块新增 | ||
| 64 | - - [ ] REQ-MOD-002 模块修改 | ||
| 65 | - - [ ] REQ-MOD-003 模块删除 | ||
| 66 | - - [ ] REQ-MOD-004 模块查询 | 63 | + - [x] REQ-MOD-001 模块新增 |
| 64 | + - [x] REQ-MOD-002 模块修改 | ||
| 65 | + - [x] REQ-MOD-003 模块删除 | ||
| 66 | + - [x] REQ-MOD-004 模块查询 | ||
| 67 | 67 | ||
| 68 | - module_usr 用户管理 | 68 | - module_usr 用户管理 |
| 69 | - 依赖: — | 69 | - 依赖: — |
| 70 | - 路径: backend/src/main/java/com/xly/erp/module/usr/, frontend/src/pages/usr/ | 70 | - 路径: backend/src/main/java/com/xly/erp/module/usr/, frontend/src/pages/usr/ |
| 71 | - MR: — | 71 | - MR: — |
| 72 | - 功能: | 72 | - 功能: |
| 73 | - - [ ] REQ-USR-001 用户新增 | ||
| 74 | - - [ ] REQ-USR-002 用户修改 | ||
| 75 | - - [ ] REQ-USR-003 用户查询 | ||
| 76 | - - [ ] REQ-USR-004 用户登录 | 73 | + - [x] REQ-USR-001 用户新增 |
| 74 | + - [x] REQ-USR-002 用户修改 | ||
| 75 | + - [x] REQ-USR-003 用户查询 | ||
| 76 | + - [x] REQ-USR-004 用户登录 |
docs/superpowers/module-reports/2026-05-06-module_mod.md
0 → 100644
| 1 | +--- | ||
| 2 | +module_id: module_mod | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +git_range: ace5841..c64a026 (24 commits) | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# 模块完成报告 — module_mod 模块管理 | ||
| 8 | + | ||
| 9 | +## ① 模块信息 | ||
| 10 | +- 模块 ID: module_mod | ||
| 11 | +- 模块名: 模块管理(业务模块定义 / 树形菜单 / 权限分组基础单位) | ||
| 12 | +- 开发区间: ace5841(master, plan phase done)→ c64a026(test-gate evidence),共 24 个 commits | ||
| 13 | + | ||
| 14 | +## ② REQ 完成清单 | ||
| 15 | + | ||
| 16 | +- [x] REQ-MOD-001 — 模块新增(`POST /api/modules`) | ||
| 17 | + - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-001.md | ||
| 18 | + - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-001.md | ||
| 19 | + - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md | ||
| 20 | +- [x] REQ-MOD-002 — 模块修改(`PUT /api/modules/{id}`) | ||
| 21 | + - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md | ||
| 22 | + - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-002.md | ||
| 23 | + - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md | ||
| 24 | +- [x] REQ-MOD-003 — 模块软删除(`DELETE /api/modules/{id}`) | ||
| 25 | + - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-003.md | ||
| 26 | + - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-003.md | ||
| 27 | + - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md | ||
| 28 | +- [x] REQ-MOD-004 — 模块树查询(`GET /api/modules?keyword=`) | ||
| 29 | + - spec: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md | ||
| 30 | + - plan: docs/superpowers/plans/2026-05-06-REQ-MOD-004.md | ||
| 31 | + - review: docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md | ||
| 32 | + | ||
| 33 | +## ③ 文件变更表 | ||
| 34 | + | ||
| 35 | +| 文件 | 操作 | 说明 | | ||
| 36 | +|---|---|---| | ||
| 37 | +| backend/pom.xml | A | Spring Boot 3.2.5 父 pom + 依赖(mybatis-plus 3.5.7、flyway 10、hutool、mapstruct、test)+ surefire includes 修复(让 *IT.java 跑入 mvn test) | | ||
| 38 | +| backend/src/main/java/com/xly/erp/ErpApplication.java | A | Spring Boot 启动类 + @MapperScan | | ||
| 39 | +| backend/src/main/resources/application.yml | A | DB / Flyway / MyBatis-Plus 配置;从 .env.local 注入凭据 | | ||
| 40 | +| backend/src/main/resources/application-test.yml | A | test profile:Flyway baseline-on-migrate=true / baseline-version=1 | | ||
| 41 | +| backend/src/main/java/com/xly/erp/common/response/ApiResponse.java | A | 统一响应 `{code, message, data, timestamp}` + 静态工厂 | | ||
| 42 | +| backend/src/main/java/com/xly/erp/common/response/ErrorCode.java | A | 错误码枚举:SUCCESS / PARAM_INVALID / MOD_PARENT_NOT_FOUND / MOD_NOT_FOUND / MOD_PROC_NAME_DUP / MOD_HAS_REFERENCES / MOD_PARENT_LOOP / INTERNAL_ERROR | | ||
| 43 | +| backend/src/main/java/com/xly/erp/common/exception/BizException.java | A | 业务异常(带 ErrorCode) | | ||
| 44 | +| backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | A | @RestControllerAdvice:BizException → ApiResponse.fail(code),MethodArgumentNotValidException → 40010,Exception → 50000,**不回显堆栈** | | ||
| 45 | +| backend/src/main/java/com/xly/erp/config/SecurityConfig.java | A | SecurityFilterChain permitAll(占位,REQ-USR-004 时收紧为 JWT) | | ||
| 46 | +| backend/src/main/java/com/xly/erp/config/JacksonConfig.java | A | Jackson 字段访问可见性配置(解决 Lombok getter `iIncrement` 被 Introspector 解析为 `IIncrement` 导致 JSON key 错位) | | ||
| 47 | +| backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java | A | tModule 实体;保留匈牙利前缀;iParentId 用 `FieldStrategy.IGNORED` 让 update 时显式 NULL 写入 | | ||
| 48 | +| backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java | A | extends BaseMapper<ModuleEntity>;只用 BaseMapper 默认 SQL | | ||
| 49 | +| backend/src/main/resources/mapper/mod/ModuleMapper.xml | A | 空骨架(预留扩展) | | ||
| 50 | +| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java | A | POST 入参 + Bean Validation(@NotBlank / @Pattern 枚举 / @Size / @Min) | | ||
| 51 | +| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java | A | PUT 入参;剥除 sProcedureName(不可改) | | ||
| 52 | +| backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java | A | GET 入参 keyword(@Size max=50) | | ||
| 53 | +| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java | A | 创建/修改返回 VO(11 字段)+ 静态 from(ModuleEntity) | | ||
| 54 | +| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java | A | 删除返回精简 VO(iIncrement + bDeleted) | | ||
| 55 | +| backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java | A | 树查询节点 VO(7 字段 + children=[]) | | ||
| 56 | +| backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java | A | 接口:create / update / delete / tree | | ||
| 57 | +| backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java | A | 4 业务方法:create(唯一性预检 + DuplicateKey 兜底)/ update(父 FK 校验 + 环路 walk-up depth=5)/ delete(子模块引用检查 + LambdaUpdateWrapper.set 显式 SET 列)/ tree(内存树构造 + keyword 模糊 + 祖先链 walk-up) | | ||
| 58 | +| backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java | A | 4 端点:POST / PUT / DELETE / GET | | ||
| 59 | +| backend/src/test/java/com/xly/erp/ErpApplicationTest.java | A | contextLoads + Flyway baseline | | ||
| 60 | +| backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java | A | 5 单测(含 7 个错误码常量断言) | | ||
| 61 | +| backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java | A | 4 单测(standaloneSetup) | | ||
| 62 | +| backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java | A | 1 集成(permitAll 验证) | | ||
| 63 | +| backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java | A | 5 单测(Bean Validation) | | ||
| 64 | +| backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java | A | 4 单测(Bean Validation) | | ||
| 65 | +| backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java | A | 2 集成(@SpringBootTest + insert/select) | | ||
| 66 | +| backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java | A | 26 单测(Mockito + ArgumentCaptor):6 create / 8 update / 6 delete / 6 tree | | ||
| 67 | +| backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java | A | 28 集成(MockMvc + @Transactional rollback):6 POST / 8 PUT / 7 DELETE / 7 GET | | ||
| 68 | +| backend/src/test/resources/application-test.yml | A | test profile 副本 | | ||
| 69 | +| docs/08-模块任务管理.md | M | § 二 module_mod 4 个 REQ 全部勾选 | | ||
| 70 | +| docs/superpowers/specs/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份功能规格 | | ||
| 71 | +| docs/superpowers/plans/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份任务级实现计划 | | ||
| 72 | +| docs/superpowers/reviews/2026-05-06-REQ-MOD-00{1..4}.md | A | 4 份 AI 审阅报告 | | ||
| 73 | +| docs/superpowers/module-reports/module_mod-test-gate.md | A | 本模块 test-gate 闸门证据 | | ||
| 74 | + | ||
| 75 | +## ④ 数据库使用表 | ||
| 76 | + | ||
| 77 | +- 读: `tModule`(4 个 REQ 都读:create 唯一性预检 / update 父校验 + 后代环路 / delete 子模块引用检查 + 状态校验 / tree selectList) | ||
| 78 | +- 写: `tModule`(create insert / update updateById / delete update bDeleted=1 软删除) | ||
| 79 | + | ||
| 80 | +本模块不读 / 不写其他表。FK 关系:tModule 自引用 `iParentId → iIncrement`(V1 由 db-init 创建)。 | ||
| 81 | + | ||
| 82 | +## ⑤ 测试结果 | ||
| 83 | + | ||
| 84 | +- `scripts/test.sh` 最终:green | ||
| 85 | +- 通过: 76 / 失败: 0 / 跳过: 0 | ||
| 86 | +- 覆盖率: 未启用 JaCoCo 等覆盖率工具(docs/04 § 零 技术栈表未列入);后续模块若需要可作为软规则 S1 引入 | ||
| 87 | + | ||
| 88 | +测试分布: | ||
| 89 | +- 单元测试 49(ApiResponse 5 + GlobalExceptionHandler 4 + DTO Validation 5+4 + ServiceImpl 26 + 其他 5) | ||
| 90 | +- 集成测试 31(ApplicationTest 1 + SecurityConfig 1 + ModuleMapperIT 2 + ModuleControllerIT 28) | ||
| 91 | + | ||
| 92 | +`./scripts/test.sh` 流程:setup-test-db.sh DROP+CREATE → mvn build → mvn lint(compile) → mvn test → frontend skip → e2e 略 → reset DB。耗时 ~15s。 | ||
| 93 | + | ||
| 94 | +## ⑥ 本模块新增 Migration | ||
| 95 | + | ||
| 96 | +—(本模块未引入 schema 改动;tModule 由 V1__initial_schema.sql 在 A4 阶段创建) | ||
| 97 | + | ||
| 98 | +## ⑦ 跨模块改动清单(软规则 S2) | ||
| 99 | + | ||
| 100 | +—(本模块为 module_mod 独立工作,未触碰其他模块的代码 / schema;hook `log-cross-module.sh` 未生成存根,docs/superpowers/module-reports/module_mod-cross-module.md 不存在) | ||
| 101 | + | ||
| 102 | +## ⑧ 偏离 spec 清单 | ||
| 103 | + | ||
| 104 | +- **REQ-MOD-001** → 顺手新增了计划外的 `JacksonConfig.java`(spec / plan 文件清单未列出)。原因:项目沿用 docs/03 匈牙利前缀命名(`iIncrement`),Lombok 生成的 `getIIncrement()` 经 JavaBeans Introspector 解析为属性 `IIncrement`(首两字符全大写时保留),导致 JSON key 输出 `"IIncrement"` 而非期望的 `"iIncrement"`,破坏 API 契约。配置 Jackson 用字段直接读取属性名修复。已在 review 报告 § Nice-to-have 披露。 | ||
| 105 | +- **REQ-MOD-001** → spec 要求 Controller / Service / Mapper / DTO / VO 关键类贴 `// REQ-MOD-001` 标签;实现中只在 Controller / SecurityConfig 上写注释,其他类未贴。原因:实施时疏漏,已在 review § Nice-to-have 记录,不影响功能。 | ||
| 106 | +- **REQ-MOD-002** → 在 ModuleEntity#iParentId 上加了 `FieldStrategy.IGNORED` 让 NULL 写入生效(spec 未规划 entity 层改动)。原因:MyBatis-Plus 默认 update 跳过 null,导致 `setIParentId(null)` 不能写入 SQL。这是 entity 全局行为变更,已在 review 报告标注为 Nice-to-have(未来其他 service 走 partial updateById 路径会埋雷)。 | ||
| 107 | +- **REQ-MOD-003** → round 1 review 发现 high 隐患:iParentId.IGNORED 副作用导致 delete 时 entity-driven update 静默清空父引用。round 2 fix commit (2419659) 改用 `LambdaUpdateWrapper.set(...)` 显式 SET,并补 IT `delete_preservesOtherFields_onChildModule` 钉死回归。 | ||
| 108 | +- **REQ-MOD-004** → 顺手修复了 `backend/pom.xml` 中 maven-surefire-plugin 默认 includes 不含 `*IT.java` 的配置缺陷。原因:之前 REQ-MOD-001/002/003 的 IT 测试在 `mvn test` 全量阶段根本未被发现 / 执行(仅在单独 `-Dtest=xxx` 下运行过),所有 verify approval 都基于不完整覆盖。修复后所有 IT 进入 mvn test,本模块累计 76 测试全部参与全量。已在 review 报告披露。 | ||
| 109 | +- **跨 REQ — 鉴权延期**:所有 4 个 REQ 的 Controller 都加了说明性注释 `REQ-USR-004 完成后追加 @PreAuthorize(...)`,contract 要求的 `MOD:CREATE/UPDATE/DELETE/READ` 权限在本模块**未启用**。这是计划性技术债(docs/02 § 三关键说明 + 各 REQ spec § 边界都明确点到),由 REQ-USR-004 首次落地登录上下文时统一收紧 SecurityConfig。 | ||
| 110 | +- **跨 REQ — 多租户字段 / sCreatedBy 留 NULL**:所有写入路径中 `sBrandsId / sSubsidiaryId / sCreatedBy` 都落 NULL(spec 明确点到,等 REQ-USR-004 引入登录上下文 / 多租户上下文后回填)。 | ||
| 111 | + | ||
| 112 | +## ⑨ AI reviewer 报告汇总 | ||
| 113 | + | ||
| 114 | +- REQ-MOD-001: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md) | ||
| 115 | +- REQ-MOD-002: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md) | ||
| 116 | +- REQ-MOD-003: round 1 — request-changes → round 2 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md,修复 commit 2419659) | ||
| 117 | +- REQ-MOD-004: round 1 — approve(link: docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md) | ||
| 118 | + | ||
| 119 | +## ⑩ 已知问题 | ||
| 120 | + | ||
| 121 | +1. **鉴权延期**:4 个端点目前 SecurityConfig permitAll;任何客户端无 token 也能调通。**REQ-USR-004 必须回头**给本模块端点加 `@PreAuthorize` + 把 `permitAll()` 改为 `authenticated()`,并补端到端鉴权 IT。 | ||
| 122 | +2. **多租户字段 NULL**:tModule 中 `sBrandsId / sSubsidiaryId / sCreatedBy` 列在所有写入路径都为 NULL。REQ-USR-004 后需要:(a) 引入多租户拦截器从 SecurityContext 注入;(b) 写一个 V_n migration 给历史 NULL 行回填默认值;(c) 收紧 schema 把这几列改为 NOT NULL(视业务决策)。 | ||
| 123 | +3. **iParentId 的 `FieldStrategy.IGNORED` 全局副作用**:本期所有 update 路径都走 load-then-modify 全量回填模式,所以安全;但未来若新增"只更新单字段"的 partial update 路径需特别注意——直接调 `updateById(patchEntity)` 会把 iParentId 静默清空。建议未来用 `LambdaUpdateWrapper.set(...)` 模式(参考 REQ-MOD-003 delete 实现)。 | ||
| 124 | +4. **REQ-MOD-004 同级排序 IT 缺失**:spec § 验收 #7 同级排序仅在单测覆盖,端到端 IT 未直接断言。Jackson 默认保留 List 顺序,风险低,但理论上有序列化层差异的可能。下一模块或 docs sweep 时补一个端到端用例。 | ||
| 125 | +5. **keyword 通配符未转义**:spec § 边界已声明本期不处理 `%` / `_`;业务模块名通常不含这些字符。后续若需要严格语义可在 service 层加 `escape \`。 | ||
| 126 | +6. **覆盖率工具未引入**:docs/04 § 零 技术栈未列 JaCoCo / SonarQube;本期 76 测试由人工分析覆盖。 | ||
| 127 | +7. **docs/05 § REQ-MOD-003 `data.references` 描述与实现不一致**:契约写 40912 响应附 `data.references` 列出阻塞引用清单,但实现只返回错误码 + 文案;BizException 没有 data 通道。后续 docs sweep 或 REQ-USR-004 引入完整 data payload 时统一对齐。 | ||
| 128 | + | ||
| 129 | +## ⑪ 下一模块预览 | ||
| 130 | + | ||
| 131 | +按 docs/02 § 二 顺序,下一个模块是 **module_usr 用户管理**(4 个 REQ:USR-001 用户新增 / USR-002 用户修改 / USR-003 用户查询 / USR-004 用户登录)。 | ||
| 132 | + | ||
| 133 | +关键关注点: | ||
| 134 | +- REQ-USR-004 是项目 SecurityConfig + JWT + 登录上下文 + 多租户拦截器**首次落地**点,会同时收紧 module_mod 的 4 个 permitAll 端点。 | ||
| 135 | +- REQ-USR-001 写 tUser + tUserPermission 关联表(多对多),需联动 tStaff(员工选)+ tPermissionCategory(权限分类只读字典)。tStaff 与 tPermissionCategory 当前**无增删改接口**,作为只读字典——若实施过程中发现需要增删改,可能要拆出新模块(docs/03 § tStaff / § tPermissionCategory 业务注记已点到此问题)。 | ||
| 136 | +- REQ-USR-003 查询接口的列表 VO 包含跨表 JOIN(员工名 / 部门来自 tStaff),需要在 ModuleMapper 模式之外引入 selectJoinList 或 自定义 ResultMap。 | ||
| 137 | + | ||
| 138 | +## ⑫ MR 链接 | ||
| 139 | + | ||
| 140 | +http://git.xlyprint.cn/zhuzc/test2/merge_requests/1 (!1) |
docs/superpowers/module-reports/module_mod-test-gate.md
0 → 100644
| 1 | +## Local test gate — module_mod | ||
| 2 | + | ||
| 3 | +执行时间: 2026-05-06T20:07:28+08:00 | ||
| 4 | + | ||
| 5 | +### scripts/test.sh (subagent) | ||
| 6 | +- 子会话: aba8704557f137ad2 | ||
| 7 | +- 命令: `./scripts/test.sh`(setup-test-db.sh DROP+CREATE → mvn build/lint/test → frontend skip → e2e 略 → setup-test-db.sh reset) | ||
| 8 | +- 退出码: 0 | ||
| 9 | +- 通过: 76 / 失败: 0 | ||
| 10 | +- 关键 stdout (≤30 行): | ||
| 11 | + | ||
| 12 | +``` | ||
| 13 | +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.046 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest | ||
| 14 | +[INFO] | ||
| 15 | +[INFO] Results: | ||
| 16 | +[INFO] | ||
| 17 | +[INFO] Tests run: 76, Failures: 0, Errors: 0, Skipped: 0 | ||
| 18 | +[INFO] | ||
| 19 | +[INFO] ------------------------------------------------------------------------ | ||
| 20 | +[INFO] BUILD SUCCESS | ||
| 21 | +[INFO] ------------------------------------------------------------------------ | ||
| 22 | +[INFO] Total time: 15.358 s | ||
| 23 | +[INFO] Finished at: 2026-05-06T20:07:28+08:00 | ||
| 24 | +[INFO] ------------------------------------------------------------------------ | ||
| 25 | +[test.sh] skip frontend test | ||
| 26 | +[test.sh] 5/6 E2E | ||
| 27 | +[test.sh] e2e 略 | ||
| 28 | +[test.sh] 6/6 reset test db | ||
| 29 | +[setup-test-db] 即将 DROP + CREATE `xlyweberp_vibe_erp_test` on 118.178.19.35:3318 | ||
| 30 | +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts | ||
| 31 | +[test.sh] GREEN | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +结论: green |
docs/superpowers/module-reports/module_usr-test-gate.md
0 → 100644
| 1 | +## Local test gate — module_usr | ||
| 2 | + | ||
| 3 | +执行时间: 2026-05-07T09:33:12+08:00 | ||
| 4 | + | ||
| 5 | +### scripts/test.sh (subagent) | ||
| 6 | +- 子会话: a04f12f4a6f932a7d | ||
| 7 | +- 命令: `./scripts/test.sh`(setup-test-db.sh DROP+CREATE → mvn build/lint/test → frontend skip → e2e 略 → setup-test-db.sh reset) | ||
| 8 | +- 退出码: 0 | ||
| 9 | +- 通过: 172 / 失败: 0 | ||
| 10 | +- 关键 stdout (≤30 行): | ||
| 11 | + | ||
| 12 | +``` | ||
| 13 | +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest | ||
| 14 | +[INFO] | ||
| 15 | +[INFO] Results: | ||
| 16 | +[INFO] | ||
| 17 | +[INFO] Tests run: 172, Failures: 0, Errors: 0, Skipped: 0 | ||
| 18 | +[INFO] | ||
| 19 | +[INFO] ------------------------------------------------------------------------ | ||
| 20 | +[INFO] BUILD SUCCESS | ||
| 21 | +[INFO] ------------------------------------------------------------------------ | ||
| 22 | +[INFO] Total time: 25.464 s | ||
| 23 | +[INFO] Finished at: 2026-05-07T09:33:12+08:00 | ||
| 24 | +[INFO] ------------------------------------------------------------------------ | ||
| 25 | +[test.sh] skip frontend test | ||
| 26 | +[test.sh] 5/6 E2E | ||
| 27 | +[test.sh] e2e 略 | ||
| 28 | +[test.sh] 6/6 reset test db | ||
| 29 | +[setup-test-db] 即将 DROP + CREATE `xlyweberp_vibe_erp_test` on 118.178.19.35:3318 | ||
| 30 | +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts | ||
| 31 | +[test.sh] GREEN | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +结论: green |
docs/superpowers/plans/2026-05-06-REQ-MOD-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-001.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-001 模块新增 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `POST /api/modules` 接口,把一条新业务模块写入 `tModule`,并顺带建立服务于本 REQ 的最小 Spring Boot 骨架。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 标准三层(Controller → Service → Mapper)。Spring Boot 3 + MyBatis-Plus + Flyway 自动 apply migrations。鉴权暂以 `SecurityConfig` permitAll 占位,REQ-USR-004 时回头收紧。统一响应 `ApiResponse<T>`、统一异常 `BizException` + `GlobalExceptionHandler`。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** Spring Boot 3.x、MyBatis-Plus(spring-boot3-starter)、Flyway 10.x、MySQL 8、JUnit 5 + Spring Boot Test + MockMvc + Spring Security Test、Hutool、MapStruct(可选)、Maven 3.9。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(`tModule` 已由 A4 `V1__initial_schema.sql` 创建)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- `backend/pom.xml` — 创建(Spring Boot 父 pom + 依赖清单) | ||
| 26 | +- `backend/src/main/java/com/xly/erp/ErpApplication.java` — 创建(启动类) | ||
| 27 | +- `backend/src/main/resources/application.yml` — 创建(DB / Flyway / MyBatis-Plus / 端口配置,从环境变量注入) | ||
| 28 | +- `backend/src/main/resources/application-test.yml` — 创建(测试 profile,连 DB_SCHEMA test 库) | ||
| 29 | +- `backend/src/main/java/com/xly/erp/common/response/ApiResponse.java` — 创建(统一响应包装) | ||
| 30 | +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 创建(错误码枚举) | ||
| 31 | +- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — 创建(业务异常) | ||
| 32 | +- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — 创建(@RestControllerAdvice) | ||
| 33 | +- `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 创建(permitAll 临时配置) | ||
| 34 | +- `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — 创建(分页插件,本 REQ 不用,REQ-MOD-004 用,本任务先建空骨架) | ||
| 35 | +- `backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java` — 创建(@TableName("tModule")) | ||
| 36 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 创建(继承 BaseMapper) | ||
| 37 | +- `backend/src/main/resources/mapper/mod/ModuleMapper.xml` — 创建(空骨架,本 REQ 仅用 BaseMapper 默认 SQL) | ||
| 38 | +- `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java` — 创建(入参 + Bean Validation) | ||
| 39 | +- `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java` — 创建(出参) | ||
| 40 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 创建(接口) | ||
| 41 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 创建(实现,含 create 方法) | ||
| 42 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 创建(POST /api/modules) | ||
| 43 | +- `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 创建(单元) | ||
| 44 | +- `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` — 创建(单元) | ||
| 45 | +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 创建(@SpringBootTest 集成) | ||
| 46 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 创建(单元 + Mockito) | ||
| 47 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 创建(MockMvc + @SpringBootTest) | ||
| 48 | +- `backend/src/test/resources/application-test.yml` — 创建(测试 profile 副本,便于 IDE 跑测试) | ||
| 49 | +- `.env.local` — 不修改(已有 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_SCHEMA / JWT_SECRET) | ||
| 50 | + | ||
| 51 | +--- | ||
| 52 | + | ||
| 53 | +## 任务步骤 | ||
| 54 | + | ||
| 55 | +### Task 1: 引导 Spring Boot 项目骨架(compile + boot 通过) | ||
| 56 | + | ||
| 57 | +**Files:** | ||
| 58 | +- Create: `backend/pom.xml` | ||
| 59 | +- Create: `backend/src/main/java/com/xly/erp/ErpApplication.java` | ||
| 60 | +- Create: `backend/src/main/resources/application.yml` | ||
| 61 | +- Create: `backend/src/main/resources/application-test.yml` | ||
| 62 | +- Create: `backend/src/test/resources/application-test.yml`(与上一行同内容,Spring Boot 测试时从 test/resources 优先加载) | ||
| 63 | +- Create: `backend/src/main/java/com/xly/erp/ErpApplicationTest.java`(验证 context 启动) | ||
| 64 | + | ||
| 65 | +**API shape:** | ||
| 66 | +- `ErpApplication` 主类标 `@SpringBootApplication`、`@MapperScan("com.xly.erp.**.mapper")` | ||
| 67 | +- `application.yml` 通过 `${DB_HOST}` / `${DB_PORT}` / `${DB_USER}` / `${DB_PASSWORD}` / `${DB_SCHEMA}` / `${JWT_SECRET}` 占位(值由本地启动脚本或 IDE EnvFile 插件从 `.env.local` 注入;详见 docs/04 § 3.5) | ||
| 68 | +- 启用 Flyway:`spring.flyway.locations=classpath:db/migration` —— 但项目 migrations 在 `sql/migrations/`;改为 `spring.flyway.locations=filesystem:../sql/migrations` 让 backend 直接复用仓库根的 V*.sql | ||
| 69 | + | ||
| 70 | +**pom.xml 锁定依赖**(写死,不让 TDD 自由选): | ||
| 71 | +- `spring-boot-starter-parent` 3.2.x | ||
| 72 | +- `spring-boot-starter-web`、`-validation`、`-security`、`-test` | ||
| 73 | +- `spring-security-test` | ||
| 74 | +- `mybatis-plus-spring-boot3-starter` 最新稳定版(写明确版本号,本任务由 TDD 确认 mvn central 最新可用) | ||
| 75 | +- `flyway-core` + `flyway-mysql` 10.x | ||
| 76 | +- `mysql-connector-j` 8.x | ||
| 77 | +- `org.projectlombok:lombok`(编译期)+ `lombok-mapstruct-binding` | ||
| 78 | +- `org.mapstruct:mapstruct` 1.5.x + `mapstruct-processor` | ||
| 79 | +- `cn.hutool:hutool-all` 5.x | ||
| 80 | +- Java 编译目标 17 | ||
| 81 | + | ||
| 82 | +- [ ] **Step 1.1 写测试** | ||
| 83 | + - 文件: `backend/src/test/java/com/xly/erp/ErpApplicationTest.java` | ||
| 84 | + - 测试名: `ErpApplicationTest#contextLoads` | ||
| 85 | + - 内容: 空 `@SpringBootTest @ActiveProfiles("test")` + 空 test 方法 | ||
| 86 | + - 意图: ApplicationContext 能在 test profile 下成功启动 + Flyway 能 apply V1(apply 到现有 test schema 应是 no-op,因为表已存在;Flyway 跑完会建 `flyway_schema_history` 表 + 注册 V1 已 baseline) | ||
| 87 | + - 子会话确认 FAIL(项目还没建好) | ||
| 88 | + | ||
| 89 | +- [ ] **Step 1.2 创建 pom.xml** | ||
| 90 | + - 仅声明依赖、Java 17、surefire 配置 | ||
| 91 | + - `mvn -B -f backend/pom.xml dependency:resolve` 应通过 | ||
| 92 | + | ||
| 93 | +- [ ] **Step 1.3 创建 ErpApplication + application.yml** | ||
| 94 | + - `ErpApplication` 仅做启动 + `@MapperScan` | ||
| 95 | + - `application.yml` 主键:`server.port=8080`、`spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}`、datasource、mybatis-plus、flyway | ||
| 96 | + - `application-test.yml`:override `spring.flyway.baseline-on-migrate=true` + `spring.flyway.baseline-version=0`(让 Flyway 把当前已存在的 schema 作为 baseline,再追 apply) | ||
| 97 | + - 子会话 `cd backend && mvn -B test -Dtest=ErpApplicationTest#contextLoads -DSPRING_PROFILES_ACTIVE=test` 应 PASS | ||
| 98 | + | ||
| 99 | +- [ ] **Step 1.4 提交** | ||
| 100 | + - `git add backend/pom.xml backend/src/main/java/com/xly/erp/ErpApplication.java backend/src/main/resources/application*.yml backend/src/test/resources/application*.yml backend/src/test/java/com/xly/erp/ErpApplicationTest.java` | ||
| 101 | + - `git commit -m "chore(mod): bootstrap spring boot skeleton REQ-MOD-001"` | ||
| 102 | + | ||
| 103 | +--- | ||
| 104 | + | ||
| 105 | +### Task 2: 统一响应与错误码(ApiResponse + ErrorCode) | ||
| 106 | + | ||
| 107 | +**Files:** | ||
| 108 | +- Create: `backend/src/main/java/com/xly/erp/common/response/ApiResponse.java` | ||
| 109 | +- Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 110 | +- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 111 | + | ||
| 112 | +**API shape:** | ||
| 113 | +- `ApiResponse<T>`:字段 `int code`、`String message`、`T data`、`long timestamp`;静态工厂 `ok(T data)`、`ok(String message, T data)`、`fail(ErrorCode ec)`、`fail(int code, String message)` | ||
| 114 | +- `ErrorCode` 枚举(int code, String message)至少包含: | ||
| 115 | + - `SUCCESS(200, "操作成功")` | ||
| 116 | + - `PARAM_INVALID(40010, "参数错误")` | ||
| 117 | + - `MODULE_PARENT_NOT_FOUND(40411, "父模块不存在或已删除")` | ||
| 118 | + - `MODULE_PROCEDURE_NAME_DUPLICATE(40911, "存储过程名称已存在")` | ||
| 119 | + - `INTERNAL_ERROR(50000, "服务器内部错误")` | ||
| 120 | + | ||
| 121 | +**锁定常量**(跨模块复用,写死): | ||
| 122 | +``` | ||
| 123 | +SUCCESS = 200 | ||
| 124 | +PARAM_INVALID = 40010 | ||
| 125 | +MOD_PARENT_NOT_FOUND = 40411 | ||
| 126 | +MOD_PROC_NAME_DUP = 40911 | ||
| 127 | +INTERNAL_ERROR = 50000 | ||
| 128 | +``` | ||
| 129 | + | ||
| 130 | +- [ ] **Step 2.1 写失败测试** | ||
| 131 | + - 测试名: `ApiResponseTest#ok_setsCode200AndDataAndTimestamp`、`ApiResponseTest#fail_mapsErrorCodeFields` | ||
| 132 | + - 意图: `ok(data)` → code=200,message="操作成功",data=入参,timestamp 在合理范围;`fail(ErrorCode.PARAM_INVALID)` → code=40010,message="参数错误" | ||
| 133 | + - 子会话 PASS:FAIL(class 不存在) | ||
| 134 | + | ||
| 135 | +- [ ] **Step 2.2 实现 ApiResponse + ErrorCode** | ||
| 136 | + - 用 Lombok `@Data` 减少样板,但保留显式工厂方法 | ||
| 137 | + - 子会话 PASS | ||
| 138 | + | ||
| 139 | +- [ ] **Step 2.3 提交** | ||
| 140 | + - `git commit -m "feat(common): unified ApiResponse and ErrorCode REQ-MOD-001"` | ||
| 141 | + | ||
| 142 | +--- | ||
| 143 | + | ||
| 144 | +### Task 3: 业务异常 + 全局异常处理器 | ||
| 145 | + | ||
| 146 | +**Files:** | ||
| 147 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java` | ||
| 148 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` | ||
| 149 | +- Test: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java`(用 MockMvc + 一个临时 controller 触发各类异常) | ||
| 150 | + | ||
| 151 | +**API shape:** | ||
| 152 | +- `BizException extends RuntimeException`:构造 `BizException(ErrorCode ec)`、`BizException(ErrorCode ec, String detail)`;getter: `code()`, `message()` | ||
| 153 | +- `GlobalExceptionHandler` 处理: | ||
| 154 | + - `BizException` → `ApiResponse.fail(ec)` 200 OK(业务错误也走 200 + 内部 code,docs/05 § 全局约定) | ||
| 155 | + - `MethodArgumentNotValidException`(@Valid 失败)→ 提取 fieldError,组合 `ApiResponse.fail(PARAM_INVALID, "<field>: <reason>")` | ||
| 156 | + - `Exception`(兜底)→ `ApiResponse.fail(INTERNAL_ERROR)` + log error;**响应不含堆栈**(docs/04 § 1.4) | ||
| 157 | + | ||
| 158 | +- [ ] **Step 3.1 写失败测试** | ||
| 159 | + - 测试名(4 个): | ||
| 160 | + - `GlobalExceptionHandlerTest#bizException_returns200WithBizCode` | ||
| 161 | + - `GlobalExceptionHandlerTest#validationException_returns200WithParamInvalidCode` | ||
| 162 | + - `GlobalExceptionHandlerTest#uncaughtException_returns200WithInternalErrorCode` | ||
| 163 | + - `GlobalExceptionHandlerTest#response_doesNotContainStackTrace` | ||
| 164 | + - 测试方式: `@WebMvcTest(controllers=DummyController.class)` 内置一个 `DummyController` 故意抛各类异常,断言 `MockMvc.perform(...).andExpect(jsonPath("$.code").value(...))` | ||
| 165 | + - 子会话: FAIL | ||
| 166 | + | ||
| 167 | +- [ ] **Step 3.2 实现 BizException + Handler** | ||
| 168 | + - 子会话: PASS | ||
| 169 | + | ||
| 170 | +- [ ] **Step 3.3 提交** | ||
| 171 | + - `git commit -m "feat(common): biz exception and global handler REQ-MOD-001"` | ||
| 172 | + | ||
| 173 | +--- | ||
| 174 | + | ||
| 175 | +### Task 4: SecurityConfig(permitAll 占位) | ||
| 176 | + | ||
| 177 | +**Files:** | ||
| 178 | +- Create: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` | ||
| 179 | +- Test: `backend/src/test/java/com/xly/erp/config/SecurityConfigTest.java` | ||
| 180 | + | ||
| 181 | +**API shape:** | ||
| 182 | +- `SecurityFilterChain securityFilterChain(HttpSecurity http)`:禁用 CSRF(API 项目)、禁用默认表单登录、所有 `/api/**` permitAll | ||
| 183 | +- 显式注释:`// REQ-USR-004 完成后改为 .authenticated() + JWT filter` | ||
| 184 | + | ||
| 185 | +- [ ] **Step 4.1 写失败测试** | ||
| 186 | + - 测试名: `SecurityConfigTest#anyApiEndpoint_isPermittedWithoutAuth` | ||
| 187 | + - 测试方式: `@SpringBootTest @AutoConfigureMockMvc` + 在 controller package 临时加一个 `/api/__ping` GET(**或**复用 Task 3 的 `DummyController`),无 token 请求应返回 200 而非 401/403 | ||
| 188 | + - 子会话: FAIL(默认 Spring Security 401) | ||
| 189 | + | ||
| 190 | +- [ ] **Step 4.2 实现 SecurityConfig** | ||
| 191 | + - 子会话: PASS | ||
| 192 | + | ||
| 193 | +- [ ] **Step 4.3 提交** | ||
| 194 | + - `git commit -m "feat(config): permitAll security skeleton REQ-MOD-001"` | ||
| 195 | + | ||
| 196 | +--- | ||
| 197 | + | ||
| 198 | +### Task 5: ModuleEntity + ModuleMapper(Mapper 集成测试落库) | ||
| 199 | + | ||
| 200 | +**Files:** | ||
| 201 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java` | ||
| 202 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | ||
| 203 | +- Create: `backend/src/main/resources/mapper/mod/ModuleMapper.xml`(仅头部 `<mapper namespace="...ModuleMapper"/>`,预留扩展) | ||
| 204 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | ||
| 205 | + | ||
| 206 | +**API shape:** | ||
| 207 | +- `@TableName("tModule")` ;字段名严格匹配 docs/03(保留匈牙利前缀,使用 `@TableField` 显式映射),主键 `iIncrement` 用 `@TableId(type = IdType.AUTO)` | ||
| 208 | +- 列 ↔ Java 属性命名采用**保留前缀**(如 `iIncrement` Java 字段名也叫 `iIncrement`,方便对照 schema),与 docs/04 § 1.2 命名约定中的"小驼峰"略有出入——本约束**优先**:DB schema SSoT 是 docs/03,Java 字段直接复用列名以避免双向映射歧义。Lombok `@Data`。 | ||
| 209 | +- `ModuleMapper extends BaseMapper<ModuleEntity>`,本 REQ 仅用 `insert` + `selectById` + `selectCount`(条件);不写自定义 SQL | ||
| 210 | + | ||
| 211 | +- [ ] **Step 5.1 写失败测试** | ||
| 212 | + - 测试名: `ModuleMapperIT#insertAndSelectById_persistsAllFields`、`ModuleMapperIT#selectCountByProcedureName_returnsExisting` | ||
| 213 | + - 测试方式: `@SpringBootTest @ActiveProfiles("test")` + `@Transactional`(自动回滚),DI `ModuleMapper`,构造 entity 调 `insert`,再 `selectById` 断言全部字段;用 `selectCount(new LambdaQueryWrapper<ModuleEntity>().eq(ModuleEntity::getSProcedureName, ...))` 验证唯一查询 | ||
| 214 | + - 子会话: FAIL | ||
| 215 | + | ||
| 216 | +- [ ] **Step 5.2 实现 Entity + Mapper** | ||
| 217 | + - 子会话: PASS(DDL 已存在,直接 insert) | ||
| 218 | + | ||
| 219 | +- [ ] **Step 5.3 提交** | ||
| 220 | + - `git commit -m "feat(mod): module entity and mapper REQ-MOD-001"` | ||
| 221 | + | ||
| 222 | +--- | ||
| 223 | + | ||
| 224 | +### Task 6: DTO + VO | ||
| 225 | + | ||
| 226 | +**Files:** | ||
| 227 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleCreateDTO.java` | ||
| 228 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleVO.java` | ||
| 229 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleCreateDTOValidationTest.java` | ||
| 230 | + | ||
| 231 | +**API shape:** | ||
| 232 | + | ||
| 233 | +`ModuleCreateDTO` 字段(带 Bean Validation): | ||
| 234 | +- `@NotBlank @Pattern(regexp="^(手机端|前端业务|系统配置|接口)$") String sDisplayType` | ||
| 235 | +- `@NotBlank @Size(max=100) String sProcedureName` | ||
| 236 | +- `@NotBlank @Size(max=50) String sModuleType` | ||
| 237 | +- `@NotBlank @Size(max=50) String sManageDeptEn` | ||
| 238 | +- `Boolean bShowPermission`(可空,service 层 default false) | ||
| 239 | +- `@NotBlank @Size(max=100) String sModuleNameZh` | ||
| 240 | +- `Integer iParentId`(可空) | ||
| 241 | +- `@Min(0) Integer iSortOrder`(可空,default 0) | ||
| 242 | + | ||
| 243 | +`ModuleVO` 字段(11 个,见 spec § 输出):`iIncrement`, `sDisplayType`, `sProcedureName`, `sModuleType`, `sManageDeptEn`, `bShowPermission`, `sModuleNameZh`, `iParentId`, `iSortOrder`, `tCreateDate`, `bDeleted`。 | ||
| 244 | + | ||
| 245 | +> Entity → VO 的转换写在 `ModuleVO` 的静态工厂 `from(ModuleEntity)` 里,不引入 MapStruct 单独 mapper 类(YAGNI;REQ 多了再抽)。 | ||
| 246 | + | ||
| 247 | +- [ ] **Step 6.1 写失败测试** | ||
| 248 | + - 测试名: `ModuleCreateDTOValidationTest#blankRequiredFields_yieldsViolations`、`ModuleCreateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation`、`ModuleCreateDTOValidationTest#allValidFields_yieldsNoViolations` | ||
| 249 | + - 测试方式: `Validation.buildDefaultValidatorFactory().getValidator()` + 断言 violation 集合 | ||
| 250 | + - 子会话: FAIL | ||
| 251 | + | ||
| 252 | +- [ ] **Step 6.2 实现 DTO + VO** | ||
| 253 | + - 子会话: PASS | ||
| 254 | + | ||
| 255 | +- [ ] **Step 6.3 提交** | ||
| 256 | + - `git commit -m "feat(mod): module create DTO and VO REQ-MOD-001"` | ||
| 257 | + | ||
| 258 | +--- | ||
| 259 | + | ||
| 260 | +### Task 7: ModuleService.create(核心业务) | ||
| 261 | + | ||
| 262 | +**Files:** | ||
| 263 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | ||
| 264 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 265 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(Mockito 单元测试,mock ModuleMapper) | ||
| 266 | + | ||
| 267 | +**API shape:** | ||
| 268 | +- `interface ModuleService { ModuleVO create(ModuleCreateDTO dto); }` | ||
| 269 | +- `@Service @RequiredArgsConstructor class ModuleServiceImpl implements ModuleService`,字段 `private final ModuleMapper moduleMapper;` | ||
| 270 | +- `create` 方法步骤(写在 plan 锁定,不让 TDD 自由发挥逻辑顺序): | ||
| 271 | + 1. 若 `dto.iParentId != null`:`moduleMapper.selectById(iParentId)`,结果为 `null` 或 `bDeleted == true` → 抛 `BizException(MOD_PARENT_NOT_FOUND)` | ||
| 272 | + 2. 唯一性预检:`moduleMapper.selectCount(LambdaQueryWrapper.eq(ModuleEntity::getSProcedureName, dto.sProcedureName).eq(ModuleEntity::getBDeleted, 0))` > 0 → 抛 `BizException(MOD_PROC_NAME_DUP)` | ||
| 273 | + 3. 构造 `ModuleEntity`:复制 dto 字段;`bShowPermission` null → `false`;`iSortOrder` null → `0`;`tCreateDate = LocalDateTime.now()`;`bDeleted = 0`;`sBrandsId/sSubsidiaryId/sCreatedBy/sId/tDeletedDate/sDeletedBy = null` | ||
| 274 | + 4. `moduleMapper.insert(entity)`;MyBatis-Plus 回写 `iIncrement` 到 entity | ||
| 275 | + 5. 捕获 `DuplicateKeyException`(并发下 uk_procedure_name 兜底)→ 抛 `BizException(MOD_PROC_NAME_DUP)` | ||
| 276 | + 6. `return ModuleVO.from(entity)` | ||
| 277 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 278 | + | ||
| 279 | +- [ ] **Step 7.1 写失败测试(6 个)** | ||
| 280 | + - `ModuleServiceImplTest#create_rootModule_returnsVOWithGeneratedId` | ||
| 281 | + - `ModuleServiceImplTest#create_childModule_validatesParentExists` | ||
| 282 | + - `ModuleServiceImplTest#create_parentNotFound_throwsBizException40411` | ||
| 283 | + - `ModuleServiceImplTest#create_parentSoftDeleted_throwsBizException40411` | ||
| 284 | + - `ModuleServiceImplTest#create_duplicateProcedureName_preCheck_throwsBizException40911` | ||
| 285 | + - `ModuleServiceImplTest#create_duplicateProcedureName_concurrentInsert_throwsBizException40911`(mock `moduleMapper.insert` 抛 `DuplicateKeyException`) | ||
| 286 | + - 测试方式: `@ExtendWith(MockitoExtension.class)` + `@InjectMocks ModuleServiceImpl` + `@Mock ModuleMapper` | ||
| 287 | + - 子会话: FAIL | ||
| 288 | + | ||
| 289 | +- [ ] **Step 7.2 实现 ModuleService + ModuleServiceImpl** | ||
| 290 | + - 子会话: PASS | ||
| 291 | + | ||
| 292 | +- [ ] **Step 7.3 提交** | ||
| 293 | + - `git commit -m "feat(mod): create module service REQ-MOD-001"` | ||
| 294 | + | ||
| 295 | +--- | ||
| 296 | + | ||
| 297 | +### Task 8: ModuleController + 端到端集成测试 | ||
| 298 | + | ||
| 299 | +**Files:** | ||
| 300 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 301 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 302 | + | ||
| 303 | +**API shape:** | ||
| 304 | +- `@RestController @RequestMapping("/api/modules")` | ||
| 305 | +- `@PostMapping public ApiResponse<ModuleVO> create(@Valid @RequestBody ModuleCreateDTO dto)` → `ApiResponse.ok(moduleService.create(dto))` | ||
| 306 | +- 类上保留注释:`// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:CREATE')")` | ||
| 307 | + | ||
| 308 | +- [ ] **Step 8.1 写失败测试(6 个验收用例)** | ||
| 309 | + - `ModuleControllerIT#post_validRootModule_returns200WithVO` | ||
| 310 | + - `ModuleControllerIT#post_validChildModule_returns200` | ||
| 311 | + - `ModuleControllerIT#post_duplicateProcedureName_returns200WithCode40911` | ||
| 312 | + - `ModuleControllerIT#post_parentNotFound_returns200WithCode40411` | ||
| 313 | + - `ModuleControllerIT#post_missingRequiredField_returns200WithCode40010` | ||
| 314 | + - `ModuleControllerIT#post_invalidDisplayTypeEnum_returns200WithCode40010` | ||
| 315 | + - 测试方式: `@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional`(自动回滚),用 `MockMvc` 发 POST,断言 `$.code` / `$.data.iIncrement` / `$.data.sProcedureName` 等 | ||
| 316 | + - 子会话: FAIL | ||
| 317 | + | ||
| 318 | +- [ ] **Step 8.2 实现 ModuleController** | ||
| 319 | + - 子会话: PASS | ||
| 320 | + | ||
| 321 | +- [ ] **Step 8.3 全量 backend 测试** | ||
| 322 | + - `cd backend && mvn -B test -DSPRING_PROFILES_ACTIVE=test` | ||
| 323 | + - 期望全绿(Task 1-8 累计 ~25 个测试方法) | ||
| 324 | + | ||
| 325 | +- [ ] **Step 8.4 提交** | ||
| 326 | + - `git commit -m "feat(mod): POST /api/modules controller REQ-MOD-001"` | ||
| 327 | + | ||
| 328 | +--- | ||
| 329 | + | ||
| 330 | +## 提交计划 | ||
| 331 | + | ||
| 332 | +- `chore(mod): bootstrap spring boot skeleton REQ-MOD-001`(覆盖 Task 1) | ||
| 333 | +- `feat(common): unified ApiResponse and ErrorCode REQ-MOD-001`(覆盖 Task 2) | ||
| 334 | +- `feat(common): biz exception and global handler REQ-MOD-001`(覆盖 Task 3) | ||
| 335 | +- `feat(config): permitAll security skeleton REQ-MOD-001`(覆盖 Task 4) | ||
| 336 | +- `feat(mod): module entity and mapper REQ-MOD-001`(覆盖 Task 5) | ||
| 337 | +- `feat(mod): module create DTO and VO REQ-MOD-001`(覆盖 Task 6) | ||
| 338 | +- `feat(mod): create module service REQ-MOD-001`(覆盖 Task 7) | ||
| 339 | +- `feat(mod): POST /api/modules controller REQ-MOD-001`(覆盖 Task 8) |
docs/superpowers/plans/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-002 模块修改 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `PUT /api/modules/{id}` 接口:复用 REQ-MOD-001 已落地的 entity / mapper / common / config 体系,仅追加 ModuleUpdateDTO + Service.update + Controller 方法 + 错误码常量。 | ||
| 12 | + | ||
| 13 | +**Architecture:** Service 层先校验目标存在性(按 PK + bDeleted=0 查),再校验 iParentId 自引用 / 父不存在 / 父是后代环路(沿父链 walk up,深度上限 5),最后 `updateById` 落库。环路检查走"自下而上"路径,O(depth) 复杂度。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用 REQ-MOD-001(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(`tModule` schema 已由 V1 提供,本 REQ 不加列)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_NOT_FOUND(40421, "模块不存在或已删除")` 和 `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` — PUT 入参(无 sProcedureName) | ||
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `update(Integer id, ModuleUpdateDTO dto): ModuleVO` | ||
| 28 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update` 方法(含父校验、环路检查、字段合并) | ||
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/{id}") ModuleVO update(...)` | ||
| 30 | +- 创建: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` — DTO Bean Validation 单测 | ||
| 31 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 `update_*` 系列单元测试(mock ModuleMapper) | ||
| 32 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 `put_*` 系列集成测试 | ||
| 33 | + | ||
| 34 | +## 任务步骤 | ||
| 35 | + | ||
| 36 | +### Task 1: 追加错误码常量 | ||
| 37 | + | ||
| 38 | +**Files:** | ||
| 39 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 40 | +- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`(追加断言) | ||
| 41 | + | ||
| 42 | +**API shape:** | ||
| 43 | +- `MOD_NOT_FOUND(40421, "模块不存在或已删除")` | ||
| 44 | +- `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` | ||
| 45 | + | ||
| 46 | +- [ ] **Step 1.1 写失败断言** | ||
| 47 | + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: | ||
| 48 | + - `assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);` | ||
| 49 | + - `assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);` | ||
| 50 | + - 子会话确认 FAIL(编译错:枚举常量不存在) | ||
| 51 | + | ||
| 52 | +- [ ] **Step 1.2 追加枚举常量** | ||
| 53 | + | ||
| 54 | +- [ ] **Step 1.3 子会话确认 ApiResponseTest 全绿(5 个测试,第 5 个含新断言)** | ||
| 55 | + | ||
| 56 | +- [ ] **Step 1.4 提交** | ||
| 57 | + - `git commit -m "feat(common): error codes for module update REQ-MOD-002"` | ||
| 58 | + | ||
| 59 | +--- | ||
| 60 | + | ||
| 61 | +### Task 2: ModuleUpdateDTO + 校验单测 | ||
| 62 | + | ||
| 63 | +**Files:** | ||
| 64 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` | ||
| 65 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` | ||
| 66 | + | ||
| 67 | +**API shape:** | ||
| 68 | +- 字段(与 REQ-MOD-001 的 `ModuleCreateDTO` 相比剥除 `sProcedureName`;其余 7 个字段、注解、长度规则**完全一致**): | ||
| 69 | + - `@NotBlank @Pattern(...) String sDisplayType` | ||
| 70 | + - `@NotBlank @Size(max=50) String sModuleType` | ||
| 71 | + - `@NotBlank @Size(max=50) String sManageDeptEn` | ||
| 72 | + - `Boolean bShowPermission`(可空) | ||
| 73 | + - `@NotBlank @Size(max=100) String sModuleNameZh` | ||
| 74 | + - `Integer iParentId`(可空) | ||
| 75 | + - `@Min(0) Integer iSortOrder`(可空) | ||
| 76 | + | ||
| 77 | +- [ ] **Step 2.1 写失败测试(4 个)** | ||
| 78 | + - `ModuleUpdateDTOValidationTest#allValidFields_yieldsNoViolations` | ||
| 79 | + - `ModuleUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(5 个 @NotBlank) | ||
| 80 | + - `ModuleUpdateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation` | ||
| 81 | + - `ModuleUpdateDTOValidationTest#negativeSortOrder_yieldsViolation` | ||
| 82 | + - 子会话: FAIL(DTO 不存在) | ||
| 83 | + | ||
| 84 | +- [ ] **Step 2.2 实现 ModuleUpdateDTO** | ||
| 85 | + - 子会话: PASS | ||
| 86 | + | ||
| 87 | +- [ ] **Step 2.3 提交** | ||
| 88 | + - `git commit -m "feat(mod): module update DTO REQ-MOD-002"` | ||
| 89 | + | ||
| 90 | +--- | ||
| 91 | + | ||
| 92 | +### Task 3: ModuleService.update — 业务逻辑(mock 单元测试) | ||
| 93 | + | ||
| 94 | +**Files:** | ||
| 95 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名) | ||
| 96 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`(实现 + 私有 helper) | ||
| 97 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 8 个测试) | ||
| 98 | + | ||
| 99 | +**API shape:** | ||
| 100 | +- `interface ModuleService` 追加:`ModuleVO update(Integer id, ModuleUpdateDTO dto)` | ||
| 101 | +- 实现步骤(写在 plan 锁定): | ||
| 102 | + 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → 抛 `BizException(MOD_NOT_FOUND)` | ||
| 103 | + 2. iParentId 校验(仅当 `dto.iParentId != null`): | ||
| 104 | + - 等于 `id` → `BizException(MOD_PARENT_LOOP)` | ||
| 105 | + - `parent = moduleMapper.selectById(dto.iParentId)`;`parent == null || parent.bDeleted` → `BizException(MOD_PARENT_NOT_FOUND)` | ||
| 106 | + - **环路检查**(沿父链 walk up,从 `dto.iParentId` 出发,最多 5 层): | ||
| 107 | + ``` | ||
| 108 | + cur = parent; depth = 1 | ||
| 109 | + while cur.iParentId != null && depth <= 5: | ||
| 110 | + if cur.iParentId == id: throw MOD_PARENT_LOOP | ||
| 111 | + cur = moduleMapper.selectById(cur.iParentId) | ||
| 112 | + if cur == null or cur.bDeleted: break // 链断在已删除节点,视为非环 | ||
| 113 | + depth += 1 | ||
| 114 | + ``` | ||
| 115 | + depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。 | ||
| 116 | + 3. 字段合并到 `target`: | ||
| 117 | + - 必填字段(5 个 @NotBlank + sDisplayType):直接覆盖(dto 为 null 不可能,validation 已挡) | ||
| 118 | + - `bShowPermission`:dto 非 null 覆盖;null 保留 `target.bShowPermission` | ||
| 119 | + - `iParentId`:dto 中存在该 key 即覆盖(含 null 设根)。**实现细节**:DTO 用 Integer,区分"未传"和"显式传 null"在 Spring MVC 反序列化层不区分(都是 Integer null)。本 REQ 取语义"传 null = 设根","key 缺失 = 设根"等价。 | ||
| 120 | + - `iSortOrder`:dto 非 null 覆盖;null 保留 | ||
| 121 | + - `sProcedureName` / `iIncrement` / `tCreateDate` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`:**完全不动**(沿用 target 上的原值) | ||
| 122 | + 4. `moduleMapper.updateById(target)` | ||
| 123 | + 5. `return ModuleVO.from(target)` | ||
| 124 | + | ||
| 125 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 126 | + | ||
| 127 | +- [ ] **Step 3.1 写失败测试(8 个)** | ||
| 128 | + - `update_targetNotFound_throws40421`:`selectById(id)` → null | ||
| 129 | + - `update_targetSoftDeleted_throws40421`:`selectById(id).bDeleted=true` | ||
| 130 | + - `update_parentSelfReference_throws40921`:`dto.iParentId == id` | ||
| 131 | + - `update_parentNotFound_throws40411`:`selectById(parentId)` → null | ||
| 132 | + - `update_parentIsDescendant_throws40921`:构造 grandparent(id)→parent→child 链,dto.iParentId=child.id | ||
| 133 | + - `update_full_returnsVOWithUpdatedFields`:mock target 与 update 路径,断言传给 `updateById` 的 entity: | ||
| 134 | + - 已修改字段:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh / iParentId / iSortOrder / bShowPermission | ||
| 135 | + - 保持原值:sProcedureName / iIncrement / tCreateDate / sCreatedBy / bDeleted | ||
| 136 | + - `update_partialNullFields_keepsOriginalValues`:dto 中 bShowPermission=null + iSortOrder=null,断言落库 entity 的对应字段保留 target 原值 | ||
| 137 | + - `update_clearParent_setsParentToNull`:dto.iParentId=null,target.iParentId 原本是 7;断言 entity.iParentId == null | ||
| 138 | + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<ModuleEntity>` 捕获 `updateById` 实参 | ||
| 139 | + - 子会话: FAIL(方法不存在) | ||
| 140 | + | ||
| 141 | +- [ ] **Step 3.2 实现 ModuleService 接口签名 + ModuleServiceImpl.update** | ||
| 142 | + | ||
| 143 | +- [ ] **Step 3.3 子会话确认全部 mock 单测通过** | ||
| 144 | + - 全量 `mvn -B test -Dtest=ModuleServiceImplTest` 应绿(含 REQ-MOD-001 的 6 个 + 新增 8 个 = 14 个) | ||
| 145 | + | ||
| 146 | +- [ ] **Step 3.4 提交** | ||
| 147 | + - `git commit -m "feat(mod): update module service REQ-MOD-002"` | ||
| 148 | + | ||
| 149 | +--- | ||
| 150 | + | ||
| 151 | +### Task 4: ModuleController PUT 端点 + 端到端 IT | ||
| 152 | + | ||
| 153 | +**Files:** | ||
| 154 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 155 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 8 个集成用例) | ||
| 156 | + | ||
| 157 | +**API shape:** | ||
| 158 | +- 类上保留 `@RequestMapping("/api/modules")` | ||
| 159 | +- 新方法: | ||
| 160 | + ``` | ||
| 161 | + @PutMapping("/{id}") | ||
| 162 | + public ApiResponse<ModuleVO> update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) | ||
| 163 | + ``` | ||
| 164 | +- 注释:`// REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")` | ||
| 165 | + | ||
| 166 | +- [ ] **Step 4.1 写失败测试(8 个)** | ||
| 167 | + - `put_validUpdate_returns200`:先用 ModuleMapper 直接 insert 一条,再 PUT 改若干字段,断言响应 + DB 字段 | ||
| 168 | + - `put_setParentToNull_clearsParent`:先建 parent + child(iParentId=parent.id),PUT child 把 iParentId=null,断言 DB 中 child.iParentId IS NULL | ||
| 169 | + - `put_targetNotFound_returns40421`:`PUT /api/modules/999999` | ||
| 170 | + - `put_parentNotFound_returns40411`:`iParentId=999999` | ||
| 171 | + - `put_parentSelfRef_returns40921`:`PUT /api/modules/{id}` body `iParentId={id}` | ||
| 172 | + - `put_parentIsDescendant_returns40921`:建 grandparent→parent→child 三层;PUT grandparent 把 iParentId=child.id | ||
| 173 | + - `put_missingRequired_returns40010`:缺 sModuleNameZh | ||
| 174 | + - `put_ignoresProcedureNameField_doesNotChange`:body 含 `"sProcedureName":"hijack"`,断言 DB 中 sProcedureName 仍为原值 | ||
| 175 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 | ||
| 176 | + - 子会话: FAIL(端点不存在) | ||
| 177 | + | ||
| 178 | +- [ ] **Step 4.2 实现 PUT 端点** | ||
| 179 | + - 子会话: PASS | ||
| 180 | + | ||
| 181 | +- [ ] **Step 4.3 跑全量 backend 测试** | ||
| 182 | + - `cd backend && mvn -B test` | ||
| 183 | + - 期望累计 22 + 4(DTO valid) + 8(service update) + 8(controller put) = 42 个,全绿 | ||
| 184 | + | ||
| 185 | +- [ ] **Step 4.4 提交** | ||
| 186 | + - `git commit -m "feat(mod): PUT /api/modules/{id} controller REQ-MOD-002"` | ||
| 187 | + | ||
| 188 | +--- | ||
| 189 | + | ||
| 190 | +## 提交计划 | ||
| 191 | + | ||
| 192 | +- `feat(common): error codes for module update REQ-MOD-002`(覆盖 Task 1) | ||
| 193 | +- `feat(mod): module update DTO REQ-MOD-002`(覆盖 Task 2) | ||
| 194 | +- `feat(mod): update module service REQ-MOD-002`(覆盖 Task 3) | ||
| 195 | +- `feat(mod): PUT /api/modules/{id} controller REQ-MOD-002`(覆盖 Task 4) |
docs/superpowers/plans/2026-05-06-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-003.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-003 模块删除 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `DELETE /api/modules/{id}`:在校验子模块未删除引用的前提下,对目标做软删除(写 bDeleted/tDeletedDate/sDeletedBy)。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 REQ-MOD-001/002 已建立的体系。Service 先 selectById 校验目标存在 + 未删除(40421),再 selectCount 子模块未删除引用(40912),最后用 `mapper.update(entity, wrapper)` 加 `bDeleted = 0` 条件做并发安全的软删除(影响 0 行视为并发删除 → 40421)。返回精简 VO。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用 REQ-MOD-001/002(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(软删除字段 `bDeleted` / `tDeletedDate` / `sDeletedBy` 在 V1 已建)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")` | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java` — 精简 VO(iIncrement + bDeleted) | ||
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `delete(Integer id): ModuleDeleteResultVO` | ||
| 28 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 delete | ||
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/{id}")` | ||
| 30 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言 | ||
| 31 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 delete 单测 | ||
| 32 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 个 DELETE 集成测试 | ||
| 33 | + | ||
| 34 | +## 任务步骤 | ||
| 35 | + | ||
| 36 | +### Task 1: 错误码 + 精简 VO | ||
| 37 | + | ||
| 38 | +**Files:** | ||
| 39 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 40 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java` | ||
| 41 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 42 | + | ||
| 43 | +**API shape:** | ||
| 44 | +- `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")` | ||
| 45 | +- `ModuleDeleteResultVO` 字段:`Integer iIncrement` + `Boolean bDeleted`(带 `@Data` + 静态工厂 `of(Integer id, Boolean deleted)`) | ||
| 46 | + | ||
| 47 | +- [ ] **Step 1.1 写失败断言** | ||
| 48 | + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: | ||
| 49 | + `assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912);` | ||
| 50 | + - 子会话 FAIL(枚举常量不存在) | ||
| 51 | + | ||
| 52 | +- [ ] **Step 1.2 追加枚举常量 + 创建 VO** | ||
| 53 | + | ||
| 54 | +- [ ] **Step 1.3 子会话验证 ApiResponseTest 全绿** | ||
| 55 | + | ||
| 56 | +- [ ] **Step 1.4 提交** | ||
| 57 | + - `git commit -m "feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003"` | ||
| 58 | + | ||
| 59 | +--- | ||
| 60 | + | ||
| 61 | +### Task 2: ModuleService.delete — 业务逻辑(mock 单元测试) | ||
| 62 | + | ||
| 63 | +**Files:** | ||
| 64 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名) | ||
| 65 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 66 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 6 个测试) | ||
| 67 | + | ||
| 68 | +**API shape:** | ||
| 69 | +- `interface ModuleService` 追加:`ModuleDeleteResultVO delete(Integer id)` | ||
| 70 | +- 实现步骤(写在 plan 锁定): | ||
| 71 | + 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → `BizException(MOD_NOT_FOUND)`(40421) | ||
| 72 | + 2. 子模块计数:`childCount = moduleMapper.selectCount(LambdaQueryWrapper.eq(iParentId, id).eq(bDeleted, false))` | ||
| 73 | + - `childCount > 0` → `BizException(MOD_HAS_REFERENCES)`(40912) | ||
| 74 | + 3. 构造**只含软删除三件套 + 主键**的更新 entity(避免触碰其他字段): | ||
| 75 | + ``` | ||
| 76 | + ModuleEntity patch = new ModuleEntity(); | ||
| 77 | + patch.setIIncrement(id); | ||
| 78 | + patch.setBDeleted(true); | ||
| 79 | + patch.setTDeletedDate(LocalDateTime.now()); | ||
| 80 | + patch.setSDeletedBy(null); // FieldStrategy 默认 NOT_NULL,会被 MP 跳过;这里靠 IGNORED 或显式 update wrapper 写入。 | ||
| 81 | + ``` | ||
| 82 | + **方式选择**:用 `moduleMapper.update(patch, new LambdaUpdateWrapper<ModuleEntity>().eq(iIncrement, id).eq(bDeleted, false))`,确保只更新仍未删除的目标,且 `update` 影响行数为并发兜底信号。 | ||
| 83 | + 4. `int affected = moduleMapper.update(...)`;`affected == 0` → `BizException(MOD_NOT_FOUND)`(视为目标在校验后被并发删除) | ||
| 84 | + 5. `return ModuleDeleteResultVO.of(id, true)` | ||
| 85 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 86 | + | ||
| 87 | +- [ ] **Step 2.1 写失败测试(6 个)** | ||
| 88 | + - `delete_targetNotFound_throws40421`:`selectById` → null | ||
| 89 | + - `delete_targetAlreadyDeleted_throws40421`:target.bDeleted=true | ||
| 90 | + - `delete_hasUndeletedChildren_throws40912`:selectCount > 0 | ||
| 91 | + - `delete_leafModule_returnsResult`:selectCount=0、update 返回 1,断言 VO + 断言传给 update 的 entity 字段(bDeleted=true、tDeletedDate 非 null、iIncrement 正确) | ||
| 92 | + - `delete_softDeletedChildren_doesNotBlock`:selectCount=0(已删子不计入),update 返回 1,断言成功 | ||
| 93 | + - `delete_concurrentRace_throws40421`:selectById 返回未删除目标,selectCount=0,update 返回 0 → 抛 40421 | ||
| 94 | + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<ModuleEntity>` 捕获 `update` 实参;用 `any(Wrapper.class)` 占位 wrapper | ||
| 95 | + - 子会话: FAIL(方法不存在) | ||
| 96 | + | ||
| 97 | +- [ ] **Step 2.2 实现 delete** | ||
| 98 | + - 注意:`moduleMapper.update(entity, wrapper)` 在 BaseMapper 里有重载,可能与 `update(entity, T)` 冲突。预期签名 `int update(@Param("et") T entity, @Param("ew") Wrapper<T> updateWrapper)`,调用为 `moduleMapper.update(patch, wrapper)`。Mockito stub 时用 `when(moduleMapper.update(any(ModuleEntity.class), any(Wrapper.class)))` 应能消歧;若仍 ambiguous,按 REQ-MOD-001 经验改用显式 cast `(ModuleEntity) any()` + `(Wrapper) any()`。 | ||
| 99 | + | ||
| 100 | +- [ ] **Step 2.3 子会话确认 ModuleServiceImplTest 全部绿** | ||
| 101 | + - 累计:6 (create) + 8 (update) + 6 (delete) = 20 | ||
| 102 | + | ||
| 103 | +- [ ] **Step 2.4 提交** | ||
| 104 | + - `git commit -m "feat(mod): delete module service REQ-MOD-003"` | ||
| 105 | + | ||
| 106 | +--- | ||
| 107 | + | ||
| 108 | +### Task 3: ModuleController DELETE 端点 + 端到端 IT | ||
| 109 | + | ||
| 110 | +**Files:** | ||
| 111 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 112 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 6 个集成用例) | ||
| 113 | + | ||
| 114 | +**API shape:** | ||
| 115 | +- 新方法: | ||
| 116 | + ``` | ||
| 117 | + @DeleteMapping("/{id}") | ||
| 118 | + public ApiResponse<ModuleDeleteResultVO> delete(@PathVariable Integer id) | ||
| 119 | + ``` | ||
| 120 | +- Javadoc:`REQ-MOD-003 模块删除 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')")` | ||
| 121 | + | ||
| 122 | +- [ ] **Step 3.1 写失败测试(6 个)** | ||
| 123 | + - `delete_validLeaf_returns200WithBDeletedTrue`:mapper.insert 一条 → DELETE → 断言 200 + data.bDeleted=true + selectById 验证 DB 中 bDeleted=true / tDeletedDate 非 null | ||
| 124 | + - `delete_targetNotFound_returns40421`:DELETE /api/modules/999999 | ||
| 125 | + - `delete_targetAlreadyDeleted_returns40421`:mapper.insert 一条并立刻把 bDeleted 置 true,DELETE 返回 40421 | ||
| 126 | + - `delete_hasUndeletedChildren_returns40912`:parent + child(bDeleted=0),DELETE parent → 40912;selectById parent 验证 bDeleted 仍 false | ||
| 127 | + - `delete_softDeletedChildren_doesNotBlock_returns200`:先 DELETE child(应成功),再 DELETE parent → 200 | ||
| 128 | + - `delete_responseVOContainsOnlyIIncrementAndBDeleted`:断言 `$.data` 路径只有 iIncrement + bDeleted 两个字段(用 jsonPath `$.data.sProcedureName` 不存在) | ||
| 129 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 | ||
| 130 | + - 子会话: FAIL(端点不存在) | ||
| 131 | + | ||
| 132 | +- [ ] **Step 3.2 实现 DELETE 端点** | ||
| 133 | + - 子会话: PASS | ||
| 134 | + | ||
| 135 | +- [ ] **Step 3.3 跑全量 backend 测试** | ||
| 136 | + - `cd backend && mvn -B test` | ||
| 137 | + - 期望累计 34 + 1(error code 断言扩展) + 6(service delete unit) + 6(controller delete IT) = 47 个,全绿 | ||
| 138 | + | ||
| 139 | +- [ ] **Step 3.4 提交** | ||
| 140 | + - `git commit -m "feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003"` | ||
| 141 | + | ||
| 142 | +--- | ||
| 143 | + | ||
| 144 | +## 提交计划 | ||
| 145 | + | ||
| 146 | +- `feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003`(覆盖 Task 1) | ||
| 147 | +- `feat(mod): delete module service REQ-MOD-003`(覆盖 Task 2) | ||
| 148 | +- `feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003`(覆盖 Task 3) |
docs/superpowers/plans/2026-05-06-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-MOD-004 模块查询 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `GET /api/modules?keyword=...`:返回未软删除模块的树形结构,可选按 `sModuleNameZh` 模糊匹配并保留命中节点的祖先链。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 一次性 selectList 拉所有未删除模块到内存,按 keyword 过滤命中集合,沿父链向上收集祖先合并到结果集,最后在内存按 iParentId 组装树。同级按 iSortOrder ASC + iIncrement ASC 排序。叶子 `children=[]`。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用 REQ-MOD-001~003。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` — 树形节点 VO(7 字段 + children 列表) | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` — 查询参数 DTO(keyword) | ||
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `tree(ModuleQueryDTO query): List<ModuleTreeNodeVO>` | ||
| 28 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 tree | ||
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping` | ||
| 30 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 tree 单测 | ||
| 31 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 GET 集成测试 | ||
| 32 | + | ||
| 33 | +## 任务步骤 | ||
| 34 | + | ||
| 35 | +### Task 1: VO + DTO | ||
| 36 | + | ||
| 37 | +**Files:** | ||
| 38 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` | ||
| 39 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` | ||
| 40 | + | ||
| 41 | +**API shape:** | ||
| 42 | +- `ModuleTreeNodeVO`:7 字段 + `List<ModuleTreeNodeVO> children`,Lombok `@Data`,含静态工厂 `from(ModuleEntity e)`(创建带空 children 列表的节点)。 | ||
| 43 | +- `ModuleQueryDTO`:单字段 `@Size(max=50) String keyword`(可空)。Bean Validation 由 controller 触发。 | ||
| 44 | + | ||
| 45 | +- [ ] **Step 1.1 创建 VO + DTO(无独立单测;Bean Validation 在 IT 层覆盖)** | ||
| 46 | + | ||
| 47 | +- [ ] **Step 1.2 提交** | ||
| 48 | + - `git commit -m "feat(mod): module tree VO + query DTO REQ-MOD-004"` | ||
| 49 | + | ||
| 50 | +--- | ||
| 51 | + | ||
| 52 | +### Task 2: ModuleService.tree — 树构建逻辑(mock 单元测试) | ||
| 53 | + | ||
| 54 | +**Files:** | ||
| 55 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | ||
| 56 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | ||
| 57 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | ||
| 58 | + | ||
| 59 | +**API shape:** | ||
| 60 | +- `interface ModuleService` 追加:`List<ModuleTreeNodeVO> tree(ModuleQueryDTO query)` | ||
| 61 | +- 实现步骤: | ||
| 62 | + 1. `all = moduleMapper.selectList(LambdaQueryWrapper.eq(BDeleted, false))` | ||
| 63 | + 2. 若 `query.keyword == null || keyword.isEmpty()` → 跳到步骤 5(用全量构树) | ||
| 64 | + 3. 否则在 `all` 内存里筛选 `hits = all.filter(e -> e.sModuleNameZh.contains(keyword))` | ||
| 65 | + 4. 对每个 hit 沿 `iParentId` 链向上收集祖先(用 `byId = all.stream().toMap(IIncrement)` 索引);合并到 `survivors = hits ∪ ancestors` | ||
| 66 | + 5. 按 iIncrement 索引 `survivors`,按 iParentId 分组子节点列表,同级按 (iSortOrder, iIncrement) ASC 排序 | ||
| 67 | + 6. 取 `iParentId == null || iParentId not in survivors` 的节点作为根(这样过滤后一些祖先链断裂的节点也会被提到根,避免孤立);递归挂 children | ||
| 68 | + 7. 返回根节点列表 | ||
| 69 | +- 标 `@Transactional(readOnly = true)` | ||
| 70 | + | ||
| 71 | +**关键不变量**(写入 plan 锁定): | ||
| 72 | +- 叶子节点 `children = new ArrayList<>()`,**不为 null** | ||
| 73 | +- 排序键稳定:`Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(ModuleTreeNodeVO::getIIncrement)` | ||
| 74 | +- 祖先链深度上限 5(与 docs/03 § tModule 注记一致);超 5 仍向上视为业务异常并记一行 `log.warn`,不抛错(不影响 200 返回) | ||
| 75 | + | ||
| 76 | +- [ ] **Step 2.1 写失败测试(6 个)** | ||
| 77 | + - `tree_emptyDb_returnsEmptyList`:mapper.selectList 返回空 → service 返回 `List.of()` | ||
| 78 | + - `tree_singleRoot_returnsOneNodeWithEmptyChildren`:1 个 root,断言返回 list 长度 1,children 是 `[]` | ||
| 79 | + - `tree_multiLevel_buildsNestedStructureSortedByISortOrder`:root(sort=2) + root(sort=1) + child of root1,断言返回顺序 [sort=1, sort=2],root1.children 包含 child | ||
| 80 | + - `tree_keywordHit_includesAncestorChain`:grandparent → parent → child(sModuleNameZh 各不同),keyword 匹配 child;断言返回 grandparent → parent → child 三层 | ||
| 81 | + - `tree_keywordNoMatch_returnsEmptyList` | ||
| 82 | + - `tree_softDeletedExcluded`(验证 mapper 调用时 wrapper 含 `eq(bDeleted, false)`;ArgumentCaptor<Wrapper>) | ||
| 83 | + - 测试方式:`@ExtendWith(MockitoExtension.class)`,构造 `List<ModuleEntity>` 喂 mock 返回值 | ||
| 84 | + - 子会话: FAIL(方法不存在) | ||
| 85 | + | ||
| 86 | +- [ ] **Step 2.2 实现 service.tree** | ||
| 87 | + - 子会话: PASS | ||
| 88 | + | ||
| 89 | +- [ ] **Step 2.3 提交** | ||
| 90 | + - `git commit -m "feat(mod): query module tree service REQ-MOD-004"` | ||
| 91 | + | ||
| 92 | +--- | ||
| 93 | + | ||
| 94 | +### Task 3: ModuleController GET 端点 + 端到端 IT | ||
| 95 | + | ||
| 96 | +**Files:** | ||
| 97 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | ||
| 98 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | ||
| 99 | + | ||
| 100 | +**API shape:** | ||
| 101 | +- 新方法: | ||
| 102 | + ``` | ||
| 103 | + @GetMapping | ||
| 104 | + public ApiResponse<List<ModuleTreeNodeVO>> tree(@Valid ModuleQueryDTO query) | ||
| 105 | + ``` | ||
| 106 | +- Javadoc:`REQ-MOD-004 模块查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")` | ||
| 107 | +- `@Valid` 触发 Bean Validation;keyword 长度超限 → MethodArgumentNotValidException → GlobalExceptionHandler → 40010 | ||
| 108 | + | ||
| 109 | +> **注意**:query 参数对象用 `@Valid ModuleQueryDTO` 时,Spring MVC 会把 `keyword` query 参数绑定到 DTO 字段(无需 @ModelAttribute,bean 类型默认走 query string)。 | ||
| 110 | + | ||
| 111 | +- [ ] **Step 3.1 写失败测试(7 个)** | ||
| 112 | + - `get_emptyKeyword_returnsAllUndeletedAsTree`:先 mapper.insert 几条(含 root + child + soft-deleted),GET 不带 keyword;断言 200 + data 长度等于未删 root 数;递归断言子树。 | ||
| 113 | + - `get_keyword_filtersByModuleNameZhWithAncestors`:插入 grandparent("系统配置") → parent("用户管理") → child("登录");GET `?keyword=登录`;断言返回三层链。 | ||
| 114 | + - `get_keywordNoMatch_returnsEmptyArray`:GET `?keyword=不存在`,断言 `data=[]`,code=200。 | ||
| 115 | + - `get_keywordTooLong_returns40010`:keyword 51 字符。 | ||
| 116 | + - `get_softDeletedNotInResult`:插入一条并立即 update bDeleted=1,GET 全量,断言不在结果。 | ||
| 117 | + - `get_responseExcludesInternalFields`:断言 `$.data[0].sProcedureName` doesNotExist;同时验证 `sModuleType` / `bShowPermission` / `tCreateDate` / `bDeleted` 不出现。 | ||
| 118 | + - `get_leafNodeChildrenIsEmptyArrayNotNull`:断言叶子 `$.data[*].children` is array 且 length=0(非 null)。 | ||
| 119 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` | ||
| 120 | + - 子会话: FAIL(端点不存在) | ||
| 121 | + | ||
| 122 | +- [ ] **Step 3.2 实现 GET 端点** | ||
| 123 | + - 子会话: PASS | ||
| 124 | + | ||
| 125 | +- [ ] **Step 3.3 跑全量 backend 测试** | ||
| 126 | + - 期望累计 47 + 6(service tree) + 7(controller GET) = 60 个,全绿(mvn 报数会因共享 context 而合并) | ||
| 127 | + | ||
| 128 | +- [ ] **Step 3.4 提交** | ||
| 129 | + - `git commit -m "feat(mod): GET /api/modules controller REQ-MOD-004"` | ||
| 130 | + | ||
| 131 | +--- | ||
| 132 | + | ||
| 133 | +## 提交计划 | ||
| 134 | + | ||
| 135 | +- `feat(mod): module tree VO + query DTO REQ-MOD-004`(Task 1) | ||
| 136 | +- `feat(mod): query module tree service REQ-MOD-004`(Task 2) | ||
| 137 | +- `feat(mod): GET /api/modules controller REQ-MOD-004`(Task 3) |
docs/superpowers/plans/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-001.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-001 用户新增 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(V1 已建 5 张表 + FK + UNIQUE 索引)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量 | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean | ||
| 27 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` | ||
| 28 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java` | ||
| 29 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java` | ||
| 30 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java` | ||
| 31 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` | ||
| 32 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` | ||
| 33 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` | ||
| 34 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 35 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 36 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 37 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言 | ||
| 38 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` | ||
| 39 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test | ||
| 40 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测 | ||
| 41 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试 | ||
| 42 | + | ||
| 43 | +--- | ||
| 44 | + | ||
| 45 | +## 任务步骤 | ||
| 46 | + | ||
| 47 | +### Task 1: 错误码 + PasswordConfig | ||
| 48 | + | ||
| 49 | +**Files:** | ||
| 50 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 51 | +- Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` | ||
| 52 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 53 | + | ||
| 54 | +**API shape:** | ||
| 55 | +- 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同): | ||
| 56 | + - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")` | ||
| 57 | + - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")` | ||
| 58 | + - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")` | ||
| 59 | + | ||
| 60 | + > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。 | ||
| 61 | +- `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }` | ||
| 62 | + | ||
| 63 | +- [ ] **Step 1.1 写失败断言** | ||
| 64 | + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行: | ||
| 65 | + ``` | ||
| 66 | + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); | ||
| 67 | + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); | ||
| 68 | + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); | ||
| 69 | + ``` | ||
| 70 | + - 子会话: FAIL | ||
| 71 | + | ||
| 72 | +- [ ] **Step 1.2 实现 ErrorCode + PasswordConfig** | ||
| 73 | + - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证) | ||
| 74 | + | ||
| 75 | +- [ ] **Step 1.3 提交** | ||
| 76 | + - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"` | ||
| 77 | + | ||
| 78 | +--- | ||
| 79 | + | ||
| 80 | +### Task 2: 4 张表 entity + mapper + Mapper smoke IT | ||
| 81 | + | ||
| 82 | +**Files:** | ||
| 83 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java` | ||
| 84 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` | ||
| 85 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` | ||
| 86 | + | ||
| 87 | +**API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式): | ||
| 88 | + | ||
| 89 | +| Entity | 表 | 关键字段(不含 5 个标准列) | | ||
| 90 | +|---|---|---| | ||
| 91 | +| `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 92 | +| `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 93 | +| `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 94 | +| `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) | | ||
| 95 | + | ||
| 96 | +每个 mapper `extends BaseMapper<XxxEntity>`,无自定义 SQL。 | ||
| 97 | + | ||
| 98 | +> **注意**:spec § Service 实现依赖 `tUser.iStaffId` 设置为 NULL 时不应被 IGNORED 策略影响(参考 REQ-MOD-002 经验)。本期 UserEntity 字段 **不**给 iStaffId 加 `FieldStrategy.IGNORED`——因为 user create 走 insert(默认 NOT_NULL 策略对 insert 没问题:null 字段被跳过,DB 列默认 NULL),不会触发 update path 上的副作用。如未来 REQ-USR-002 需要 update 中清空 iStaffId,再按 REQ-MOD-003 经验用 `LambdaUpdateWrapper.set(...)` 处理。 | ||
| 99 | + | ||
| 100 | +- [ ] **Step 2.1 写失败 IT** | ||
| 101 | + - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返) | ||
| 102 | + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` | ||
| 103 | + - 子会话: FAIL(entity / mapper 不存在) | ||
| 104 | + | ||
| 105 | +- [ ] **Step 2.2 实现 4 entity + 4 mapper** | ||
| 106 | + - 子会话: PASS | ||
| 107 | + | ||
| 108 | +- [ ] **Step 2.3 提交** | ||
| 109 | + - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"` | ||
| 110 | + | ||
| 111 | +--- | ||
| 112 | + | ||
| 113 | +### Task 3: UserCreateDTO + UserVO + Validation | ||
| 114 | + | ||
| 115 | +**Files:** | ||
| 116 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` | ||
| 117 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` | ||
| 118 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` | ||
| 119 | + | ||
| 120 | +**API shape:** | ||
| 121 | + | ||
| 122 | +`UserCreateDTO`: | ||
| 123 | +- `@NotBlank @Size(max=50) String sUserNo` | ||
| 124 | +- `@NotBlank @Size(max=50) String sUserName` | ||
| 125 | +- `Integer iStaffId`(可空) | ||
| 126 | +- `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` | ||
| 127 | +- `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` | ||
| 128 | +- `Boolean bCanModifyDocs`(可空,service 层 default false) | ||
| 129 | +- `List<Integer> permissionCategoryIds`(可空 / 空数组) | ||
| 130 | + | ||
| 131 | +`UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List<Integer> permissionCategoryIds)`。 | ||
| 132 | + | ||
| 133 | +- [ ] **Step 3.1 写失败测试(5 个)** | ||
| 134 | + - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations` | ||
| 135 | + - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank) | ||
| 136 | + - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` | ||
| 137 | + - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation` | ||
| 138 | + - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations` | ||
| 139 | + - 子会话: FAIL | ||
| 140 | + | ||
| 141 | +- [ ] **Step 3.2 实现 DTO + VO** | ||
| 142 | + - 子会话: PASS | ||
| 143 | + | ||
| 144 | +- [ ] **Step 3.3 提交** | ||
| 145 | + - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"` | ||
| 146 | + | ||
| 147 | +--- | ||
| 148 | + | ||
| 149 | +### Task 4: UserService.create + Mockito 单元测试 | ||
| 150 | + | ||
| 151 | +**Files:** | ||
| 152 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 153 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 154 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 155 | + | ||
| 156 | +**API shape:** | ||
| 157 | +- `interface UserService { UserVO create(UserCreateDTO dto); }` | ||
| 158 | +- `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。 | ||
| 159 | +- `create(dto)` 步骤(plan 锁定): | ||
| 160 | + 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。 | ||
| 161 | + 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。 | ||
| 162 | + 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。 | ||
| 163 | + 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。 | ||
| 164 | + 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。 | ||
| 165 | + 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。 | ||
| 166 | + 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。 | ||
| 167 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 168 | + | ||
| 169 | +**初始密码常量**(写在 plan 锁定): | ||
| 170 | +``` | ||
| 171 | +private static final String INITIAL_PASSWORD = "666666"; | ||
| 172 | +``` | ||
| 173 | +后续若策略变化,service 单点修改。 | ||
| 174 | + | ||
| 175 | +- [ ] **Step 4.1 写失败测试(9 个)** | ||
| 176 | + - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值 | ||
| 177 | + - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段 | ||
| 178 | + - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0 | ||
| 179 | + - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0 | ||
| 180 | + - `create_staffNotFound_throws40421`:staffMapper.selectById → null | ||
| 181 | + - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true | ||
| 182 | + - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短 | ||
| 183 | + - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调 | ||
| 184 | + - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException | ||
| 185 | + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<UserEntity>` / `ArgumentCaptor<UserPermissionEntity>` | ||
| 186 | + - 子会话: FAIL | ||
| 187 | + | ||
| 188 | +- [ ] **Step 4.2 实现 UserService + Impl** | ||
| 189 | + - 子会话: PASS | ||
| 190 | + | ||
| 191 | +- [ ] **Step 4.3 提交** | ||
| 192 | + - `git commit -m "feat(usr): create user service REQ-USR-001"` | ||
| 193 | + | ||
| 194 | +--- | ||
| 195 | + | ||
| 196 | +### Task 5: UserController + 端到端 IT | ||
| 197 | + | ||
| 198 | +**Files:** | ||
| 199 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 200 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | ||
| 201 | + | ||
| 202 | +**API shape:** | ||
| 203 | +- `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController` | ||
| 204 | +- `@PostMapping ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto)` | ||
| 205 | +- Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")` | ||
| 206 | + | ||
| 207 | +- [ ] **Step 5.1 写失败测试(7 个)** | ||
| 208 | + - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[] | ||
| 209 | + - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配 | ||
| 210 | + - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName | ||
| 211 | + - `post_staffNotFound_returns40421`:iStaffId=999999 | ||
| 212 | + - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999] | ||
| 213 | + - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666" | ||
| 214 | + - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist | ||
| 215 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper` | ||
| 216 | + - 子会话: FAIL(端点不存在) | ||
| 217 | + | ||
| 218 | +- [ ] **Step 5.2 实现 UserController** | ||
| 219 | + - 子会话: PASS | ||
| 220 | + | ||
| 221 | +- [ ] **Step 5.3 跑全量 backend 测试** | ||
| 222 | + - `cd backend && mvn -B test` | ||
| 223 | + - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。 | ||
| 224 | + | ||
| 225 | +- [ ] **Step 5.4 提交** | ||
| 226 | + - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"` | ||
| 227 | + | ||
| 228 | +--- | ||
| 229 | + | ||
| 230 | +## 提交计划 | ||
| 231 | + | ||
| 232 | +- `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1) | ||
| 233 | +- `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2) | ||
| 234 | +- `feat(usr): user create DTO and VO REQ-USR-001`(Task 3) | ||
| 235 | +- `feat(usr): create user service REQ-USR-001`(Task 4) | ||
| 236 | +- `feat(usr): POST /api/users controller REQ-USR-001`(Task 5) |
docs/superpowers/plans/2026-05-06-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-002.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-002 用户修改 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `PUT /api/users/{id}`:除 sUserNo / sUserName / sPasswordHash 外的字段全量替换 + 权限组重建(先删后插),返回 UserVO。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 REQ-USR-001 的 entity/mapper/service/exception/Jackson 体系。Service load-then-modify:`selectById` → 校验 + 字段合并 → `updateById`(user)→ `delete`(关联) + 循环 `insert`(关联)。`iStaffId` 字段加 `FieldStrategy.IGNORED` 让 NULL 写入生效。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用前序 REQ。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `USR_NOT_FOUND(40431, "用户不存在或已删除")` | ||
| 26 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` — `iStaffId` 加 `updateStrategy = FieldStrategy.IGNORED` | ||
| 27 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` | ||
| 28 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `update(Integer id, UserUpdateDTO dto): UserVO` | ||
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update | ||
| 30 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@PutMapping("/{id}")` | ||
| 31 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言 | ||
| 32 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` | ||
| 33 | +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 9 个 update 单测 | ||
| 34 | +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 8 个 PUT 集成测试 | ||
| 35 | + | ||
| 36 | +--- | ||
| 37 | + | ||
| 38 | +## 任务步骤 | ||
| 39 | + | ||
| 40 | +### Task 1: 错误码追加 + UserEntity.iStaffId IGNORED | ||
| 41 | + | ||
| 42 | +**Files:** | ||
| 43 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 44 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` | ||
| 45 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 46 | + | ||
| 47 | +**API shape:** | ||
| 48 | +- `USR_NOT_FOUND(40431, "用户不存在或已删除")` 追加到 ErrorCode 枚举 | ||
| 49 | +- `UserEntity#iStaffId` 字段注解改为 `@TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)`,并加注释说明(与 REQ-MOD-002 / module_mod 同样的副作用警告) | ||
| 50 | + | ||
| 51 | +- [ ] **Step 1.1 写失败断言** | ||
| 52 | + - `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 追加 `assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);` | ||
| 53 | + - 子会话: FAIL | ||
| 54 | + | ||
| 55 | +- [ ] **Step 1.2 实现错误码 + Entity 注解** | ||
| 56 | + - 子会话验证:`mvn -B test`(全量;让 SpringBootTest 预热 lambda cache)应仍 PASS(USR-001 现有用例 + 新断言) | ||
| 57 | + | ||
| 58 | +- [ ] **Step 1.3 提交** | ||
| 59 | + - `git commit -m "feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002"` | ||
| 60 | + | ||
| 61 | +--- | ||
| 62 | + | ||
| 63 | +### Task 2: UserUpdateDTO + 校验单测 | ||
| 64 | + | ||
| 65 | +**Files:** | ||
| 66 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` | ||
| 67 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` | ||
| 68 | + | ||
| 69 | +**API shape:** | ||
| 70 | +- 字段(与 UserCreateDTO 相比剥除 `sUserNo` / `sUserName`): | ||
| 71 | + - `Integer iStaffId`(可空) | ||
| 72 | + - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` | ||
| 73 | + - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` | ||
| 74 | + - `Boolean bCanModifyDocs`(可空,service 层语义:null 保留原值) | ||
| 75 | + - `List<Integer> permissionCategoryIds`(可空,空数组 / null 都视为清空) | ||
| 76 | + | ||
| 77 | +- [ ] **Step 2.1 写失败测试(4 个)** | ||
| 78 | + - `UserUpdateDTOValidationTest#allValidFields_yieldsNoViolations` | ||
| 79 | + - `UserUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(2 个 @NotBlank) | ||
| 80 | + - `UserUpdateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` | ||
| 81 | + - `UserUpdateDTOValidationTest#invalidLanguageEnum_yieldsViolation` | ||
| 82 | + - 子会话: FAIL | ||
| 83 | + | ||
| 84 | +- [ ] **Step 2.2 实现 DTO** | ||
| 85 | + - 子会话: PASS | ||
| 86 | + | ||
| 87 | +- [ ] **Step 2.3 提交** | ||
| 88 | + - `git commit -m "feat(usr): user update DTO REQ-USR-002"` | ||
| 89 | + | ||
| 90 | +--- | ||
| 91 | + | ||
| 92 | +### Task 3: UserService.update + Mockito 单测 | ||
| 93 | + | ||
| 94 | +**Files:** | ||
| 95 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`(追加方法签名) | ||
| 96 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 97 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`(追加 9 个测试) | ||
| 98 | + | ||
| 99 | +**API shape:** | ||
| 100 | +- `UserService.update(Integer id, UserUpdateDTO dto): UserVO` | ||
| 101 | +- 实现步骤(plan 锁定): | ||
| 102 | + 1. `target = userMapper.selectById(id)`;`null` 或 `bDeleted=true` → `BizException(USR_NOT_FOUND)` | ||
| 103 | + 2. iStaffId 校验(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` null / bDeleted → `BizException(STAFF_NOT_FOUND)` | ||
| 104 | + 3. 权限分类校验(仅当 dto.permissionCategoryIds 非空):`selectBatchIds` 长度 / bDeleted 检查 → `BizException(PERM_CATEGORY_NOT_FOUND)` | ||
| 105 | + 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy / tLastLoginDate / 多租户 / sId / bDeleted 三件套): | ||
| 106 | + - `target.setIStaffId(dto.getIStaffId())`(含 null 清空) | ||
| 107 | + - `target.setSUserType(dto.getSUserType())` | ||
| 108 | + - `target.setSLanguage(dto.getSLanguage())` | ||
| 109 | + - `if (dto.getBCanModifyDocs() != null) target.setBCanModifyDocs(...)`(部分更新) | ||
| 110 | + 5. `userMapper.updateById(target)` | ||
| 111 | + 6. 重建权限关联: | ||
| 112 | + - `userPermissionMapper.delete(LambdaQueryWrapper.eq(iUserId, id))` 清空所有 | ||
| 113 | + - 若 dto.permissionCategoryIds 非空 → 循环 insert(每条 iUserId / iCategoryId / tCreateDate=now) | ||
| 114 | + 7. 返回 `UserVO.from(target, dto.permissionCategoryIds 或 [])` | ||
| 115 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 116 | + | ||
| 117 | +- [ ] **Step 3.1 写失败测试(9 个)** | ||
| 118 | + - `update_targetNotFound_throws40431` | ||
| 119 | + - `update_targetSoftDeleted_throws40431` | ||
| 120 | + - `update_staffNotFound_throws40421` | ||
| 121 | + - `update_staffSoftDeleted_throws40421` | ||
| 122 | + - `update_permissionCategoryNotFound_throws40422` | ||
| 123 | + - `update_full_returnsVOWithUpdatedFields_andRebuildsPermissions`:mock target;ArgumentCaptor 验 | ||
| 124 | + - user 已修改字段:iStaffId / sUserType / sLanguage / bCanModifyDocs | ||
| 125 | + - user 保留字段:sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy | ||
| 126 | + - userPermissionMapper.delete 调一次(wrapper 含 eq iUserId);insert 调 N 次(按 dto 的 ids) | ||
| 127 | + - `update_partialNullBCanModifyDocs_keepsOriginal` | ||
| 128 | + - `update_clearStaffId_setsToNull`:dto.iStaffId=null,断言 captor entity.iStaffId == null | ||
| 129 | + - `update_emptyPermissionCategoryIds_clearsAllAssociations`:dto.permissionCategoryIds=[],verify userPermissionMapper.delete 被调一次 + insert never | ||
| 130 | + - 子会话: FAIL | ||
| 131 | + | ||
| 132 | +- [ ] **Step 3.2 实现 service.update** | ||
| 133 | + - 子会话: PASS(含原 9 个 USR-001 单测 + 9 个 USR-002 单测共 18 个) | ||
| 134 | + | ||
| 135 | +- [ ] **Step 3.3 提交** | ||
| 136 | + - `git commit -m "feat(usr): update user service REQ-USR-002"` | ||
| 137 | + | ||
| 138 | +--- | ||
| 139 | + | ||
| 140 | +### Task 4: UserController PUT + 端到端 IT | ||
| 141 | + | ||
| 142 | +**Files:** | ||
| 143 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 144 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`(追加 8 个 PUT IT) | ||
| 145 | + | ||
| 146 | +**API shape:** | ||
| 147 | +- `@PutMapping("/{id}") ApiResponse<UserVO> update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto)` | ||
| 148 | +- Javadoc:`REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")` | ||
| 149 | + | ||
| 150 | +- [ ] **Step 4.1 写失败测试(8 个)** | ||
| 151 | + - `put_validUpdate_returns200_andDbReflects`:先 mapper.insert 一个 user + staff + 3 个 cat + 3 个 userPermission;PUT 改 staff(另一个) + sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新 cat);断言 200 + reload 验证字段 + 关联表替换为 2 条 | ||
| 152 | + - `put_clearStaffId_setsNull`:原 user.iStaffId=staffId,PUT 时 iStaffId=null,断言 DB user.iStaffId IS NULL | ||
| 153 | + - `put_emptyPermissionCategoryIds_clearsAssociations`:原有 3 条关联,PUT 传 [],断言 DB tUserPermission count = 0 | ||
| 154 | + - `put_targetNotFound_returns40431` | ||
| 155 | + - `put_staffNotFound_returns40421` | ||
| 156 | + - `put_permissionCategoryNotFound_returns40422` | ||
| 157 | + - `put_missingRequired_returns40010`:缺 sUserType | ||
| 158 | + - `put_ignoresProtectedFields_doesNotChangeUserNoOrName`:手工拼 body 含 sUserNo / sUserName / sPasswordHash 字段,PUT 后 reload;断言 sUserNo / sUserName / sPasswordHash 与原值相同 | ||
| 159 | + - 子会话: FAIL | ||
| 160 | + | ||
| 161 | +- [ ] **Step 4.2 实现 PUT 端点** | ||
| 162 | + - 子会话: PASS | ||
| 163 | + | ||
| 164 | +- [ ] **Step 4.3 跑全量 backend 测试** | ||
| 165 | + - `cd backend && mvn -B test` | ||
| 166 | + - 期望 101 + 1(新错误码)+ 4(DTO valid)+ 9(service unit)+ 8(controller IT)= 123 测试,全绿 | ||
| 167 | + | ||
| 168 | +- [ ] **Step 4.4 提交** | ||
| 169 | + - `git commit -m "feat(usr): PUT /api/users/{id} controller REQ-USR-002"` | ||
| 170 | + | ||
| 171 | +--- | ||
| 172 | + | ||
| 173 | +## 提交计划 | ||
| 174 | + | ||
| 175 | +- `feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002`(Task 1) | ||
| 176 | +- `feat(usr): user update DTO REQ-USR-002`(Task 2) | ||
| 177 | +- `feat(usr): update user service REQ-USR-002`(Task 3) | ||
| 178 | +- `feat(usr): PUT /api/users/{id} controller REQ-USR-002`(Task 4) |
docs/superpowers/plans/2026-05-06-REQ-USR-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-003.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-003 用户查询 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `GET /api/users` 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult<UserListItemVO>。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 引入 MP `PaginationInnerInterceptor` + 通用 `PageResult<T>`。Mapper.xml 自定义 `searchUsers` SQL(LEFT JOIN tStaff + dynamic WHERE)。Service 层做 queryField 白名单校验防 SQL 注入,把白名单 column 字符串放入 query 对象传给 mapper。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用前序 REQ;首次启用 MP 分页插件 + XML mapper。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 创建: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` — 通用分页 VO | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — `PaginationInnerInterceptor` bean | ||
| 27 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java` | ||
| 28 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java` | ||
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)` 方法签名 | ||
| 30 | +- 创建: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL | ||
| 31 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `search(UserQueryDTO query): PageResult<UserListItemVO>` | ||
| 32 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 search + 白名单 | ||
| 33 | +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@GetMapping` | ||
| 34 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java` | ||
| 35 | +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 search 单测 | ||
| 36 | +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 GET IT | ||
| 37 | + | ||
| 38 | +--- | ||
| 39 | + | ||
| 40 | +## 任务步骤 | ||
| 41 | + | ||
| 42 | +### Task 1: PageResult<T> + MybatisPlusConfig(横切骨架) | ||
| 43 | + | ||
| 44 | +**Files:** | ||
| 45 | +- Create: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` | ||
| 46 | +- Create: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` | ||
| 47 | + | ||
| 48 | +**API shape:** | ||
| 49 | +- `PageResult<T>`:字段 `long total` + `List<T> list` + `long pageNum` + `long pageSize`;@Data + 静态工厂 `of(IPage<T> mpPage)`(从 MP IPage 构造) | ||
| 50 | +- `MybatisPlusConfig`:`@Bean MybatisPlusInterceptor mybatisPlusInterceptor()` 注册 `PaginationInnerInterceptor(DbType.MYSQL)` | ||
| 51 | + | ||
| 52 | +- [ ] **Step 1.1 实现两个文件(无独立单测,由 Task 4 的 Mapper IT 验证分页)** | ||
| 53 | + | ||
| 54 | +- [ ] **Step 1.2 子会话 mvn 全量测试**(验证 SpringBoot context 启动 + 122 现有测试不回归) | ||
| 55 | + | ||
| 56 | +- [ ] **Step 1.3 提交** | ||
| 57 | + - `git commit -m "feat(common): PageResult + MP pagination config REQ-USR-003"` | ||
| 58 | + | ||
| 59 | +--- | ||
| 60 | + | ||
| 61 | +### Task 2: UserQueryDTO + UserListItemVO + Validation | ||
| 62 | + | ||
| 63 | +**Files:** | ||
| 64 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java` | ||
| 65 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java` | ||
| 66 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java` | ||
| 67 | + | ||
| 68 | +**API shape:** | ||
| 69 | + | ||
| 70 | +`UserQueryDTO`: | ||
| 71 | +- `@Min(1) Integer pageNum = 1`(默认) | ||
| 72 | +- `@Min(1) @Max(100) Integer pageSize = 20` | ||
| 73 | +- `@Pattern(regexp="^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$") String queryField`(可空) | ||
| 74 | +- `@Pattern(regexp="^(contains|notContains|equals)?$") String matchType`(可空) | ||
| 75 | +- `@Size(max=100) String queryValue`(可空) | ||
| 76 | + | ||
| 77 | +`UserListItemVO`:11 字段(spec § 输出)。Lombok `@Data`,无静态工厂(mapper 直接通过 ResultMap / autoMap 映射)。 | ||
| 78 | + | ||
| 79 | +- [ ] **Step 2.1 写失败测试(5 个)** | ||
| 80 | + - `UserQueryDTOValidationTest#allValid_yieldsNoViolations`(含 default 值) | ||
| 81 | + - `UserQueryDTOValidationTest#pageSizeTooLarge_yieldsViolation`(>100) | ||
| 82 | + - `UserQueryDTOValidationTest#pageSizeTooSmall_yieldsViolation`(<1) | ||
| 83 | + - `UserQueryDTOValidationTest#queryFieldInvalidEnum_yieldsViolation` | ||
| 84 | + - `UserQueryDTOValidationTest#queryValueOverSized_yieldsViolation`(101 字符) | ||
| 85 | + - 子会话: FAIL | ||
| 86 | + | ||
| 87 | +- [ ] **Step 2.2 实现 DTO + VO** | ||
| 88 | + | ||
| 89 | +- [ ] **Step 2.3 提交** | ||
| 90 | + - `git commit -m "feat(usr): user query DTO + list item VO REQ-USR-003"` | ||
| 91 | + | ||
| 92 | +--- | ||
| 93 | + | ||
| 94 | +### Task 3: UserMapper.xml searchUsers + Mapper smoke IT | ||
| 95 | + | ||
| 96 | +**Files:** | ||
| 97 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`(追加 `IPage<UserListItemVO> searchUsers(...)` 方法签名) | ||
| 98 | +- Create: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL(spec § 实现路径选择 已锁定 SQL 模板) | ||
| 99 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java`(新文件,独立 IT) | ||
| 100 | + | ||
| 101 | +**XML SQL 锁定**(spec 已写): | ||
| 102 | + | ||
| 103 | +```xml | ||
| 104 | +<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO"> | ||
| 105 | + SELECT | ||
| 106 | + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo, | ||
| 107 | + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted, | ||
| 108 | + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate | ||
| 109 | + FROM tUser u | ||
| 110 | + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0 | ||
| 111 | + <where> | ||
| 112 | + <if test="query.queryField != 'deleted'"> | ||
| 113 | + u.bDeleted = 0 | ||
| 114 | + </if> | ||
| 115 | + <if test="query.column != null and query.column != '' and query.queryValue != null and query.queryValue != ''"> | ||
| 116 | + AND | ||
| 117 | + <choose> | ||
| 118 | + <when test="query.matchType == 'equals'">${query.column} = #{query.queryValue}</when> | ||
| 119 | + <when test="query.matchType == 'notContains'">${query.column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')</when> | ||
| 120 | + <otherwise>${query.column} LIKE CONCAT('%', #{query.queryValue}, '%')</otherwise> | ||
| 121 | + </choose> | ||
| 122 | + </if> | ||
| 123 | + </where> | ||
| 124 | + ORDER BY u.tCreateDate DESC, u.iIncrement DESC | ||
| 125 | +</select> | ||
| 126 | +``` | ||
| 127 | + | ||
| 128 | +> `query.column` 字段是 service 层白名单映射后的 SQL 列字符串(如 `"u.sUserName"`),由 `${...}` 渲染——**绝不**接受 DTO 原 queryField 直接拼。 | ||
| 129 | + | ||
| 130 | +为支持 `${query.column}`,需要在 `UserQueryDTO` 加一个 transient 字段 `String column`(service 写入;前端不接受)。 | ||
| 131 | + | ||
| 132 | +- [ ] **Step 3.1 写失败 IT** | ||
| 133 | + - `UserMapperSearchIT#searchUsers_emptyFilter_returnsAllUndeletedAsPage`:插入 2 个 user(含 1 个 staff 关联),`@Autowired UserMapper`,调用 `userMapper.searchUsers(new Page<>(1,10), query)`;断言 page.getTotal() ≥ 2、page.getRecords() 含 sUserName + sStaffName 字段 | ||
| 134 | + - `UserMapperSearchIT#searchUsers_filterByUserName_filtersCorrectly`:插入 alice / bob;query.queryField=username, column="u.sUserName", matchType=contains, queryValue="ali";断言只返回 alice | ||
| 135 | + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper` | ||
| 136 | + - 子会话: FAIL(searchUsers 方法未定义) | ||
| 137 | + | ||
| 138 | +- [ ] **Step 3.2 实现 mapper 方法签名 + XML + UserQueryDTO 加 column 字段** | ||
| 139 | + - 子会话: PASS | ||
| 140 | + | ||
| 141 | +- [ ] **Step 3.3 提交** | ||
| 142 | + - `git commit -m "feat(usr): UserMapper.xml searchUsers REQ-USR-003"` | ||
| 143 | + | ||
| 144 | +--- | ||
| 145 | + | ||
| 146 | +### Task 4: UserService.search + Mockito 单测 | ||
| 147 | + | ||
| 148 | +**Files:** | ||
| 149 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 150 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 151 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 152 | + | ||
| 153 | +**API shape:** | ||
| 154 | +- `UserService.search(UserQueryDTO query): PageResult<UserListItemVO>` | ||
| 155 | +- 实现步骤(plan 锁定): | ||
| 156 | + 1. 白名单校验 + 列映射: | ||
| 157 | + ``` | ||
| 158 | + Map<String,String> COLUMN_MAP = Map.of( | ||
| 159 | + "username", "u.sUserName", "staffname", "s.sStaffName", | ||
| 160 | + "userno", "u.sUserNo", "department", "s.sDepartment", | ||
| 161 | + "usertype", "u.sUserType", "language", "u.sLanguage", | ||
| 162 | + "deleted", "u.bDeleted", "lastLoginDate", "u.tLastLoginDate", | ||
| 163 | + "createdBy", "u.sCreatedBy"); | ||
| 164 | + ``` | ||
| 165 | + 若 `query.queryField` 非空但不在 map → `BizException(PARAM_INVALID, "queryField 非法")`; | ||
| 166 | + 若 `query.matchType` 非空但不在 {contains, notContains, equals} → 同样错误。 | ||
| 167 | + 2. 把映射后的列字符串写到 `query.setColumn(mappedCol)`;如果 queryField 为空,column 也为空。 | ||
| 168 | + 3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。 | ||
| 169 | + 4. 构造 `Page<UserListItemVO> page = new Page<>(query.getPageNum(), query.getPageSize())` | ||
| 170 | + 5. 调 `userMapper.searchUsers(page, query)` | ||
| 171 | + 6. 返回 `PageResult.of(result)` | ||
| 172 | +- 标 `@Transactional(readOnly = true)` | ||
| 173 | + | ||
| 174 | +- [ ] **Step 4.1 写失败测试(5 个)** | ||
| 175 | + - `search_emptyDb_returnsEmptyPage`:mock searchUsers 返回 empty Page;service 返回 PageResult total=0 | ||
| 176 | + - `search_invalidQueryField_throws40010`:query.queryField="invalid" | ||
| 177 | + - `search_invalidMatchType_throws40010`:query.matchType="like" | ||
| 178 | + - `search_passesMappedColumnToMapper`:query.queryField="username";ArgumentCaptor 捕 query 实参,断言 query.column == "u.sUserName" | ||
| 179 | + - `search_appliesDefaultPagination_whenNullPageNumOrSize`:query.pageNum=null, pageSize=null;断言 service 创建的 Page.size==20 && current==1 | ||
| 180 | + - 子会话: FAIL | ||
| 181 | + | ||
| 182 | +- [ ] **Step 4.2 实现 service.search** | ||
| 183 | + - 子会话: PASS | ||
| 184 | + | ||
| 185 | +- [ ] **Step 4.3 提交** | ||
| 186 | + - `git commit -m "feat(usr): user query service REQ-USR-003"` | ||
| 187 | + | ||
| 188 | +--- | ||
| 189 | + | ||
| 190 | +### Task 5: UserController GET + 端到端 IT | ||
| 191 | + | ||
| 192 | +**Files:** | ||
| 193 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 194 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | ||
| 195 | + | ||
| 196 | +**API shape:** | ||
| 197 | +- `@GetMapping ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query)` | ||
| 198 | +- Javadoc:`REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")` | ||
| 199 | + | ||
| 200 | +- [ ] **Step 5.1 写失败测试(9 个)** | ||
| 201 | + - `get_emptyKeyword_returnsAllUndeleted` | ||
| 202 | + - `get_filterByUsernameContains_returnsMatchedSubset` | ||
| 203 | + - `get_filterByStaffnameContains_returnsJoinedResults` | ||
| 204 | + - `get_filterByDeletedTrue_returnsOnlyDeleted` | ||
| 205 | + - `get_pagination_returnsCorrectSlice` | ||
| 206 | + - `get_responseExcludesInternalFields`:断言 jsonPath `$.data.list[0].sPasswordHash` doesNotExist + sId / iStaffId / sBrandsId 都不出现 | ||
| 207 | + - `get_pageSizeTooLarge_returns40010` | ||
| 208 | + - `get_invalidQueryField_returns40010` | ||
| 209 | + - `get_userWithoutStaff_listItemHasNullStaffFields` | ||
| 210 | + - `@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + insert helpers | ||
| 211 | + - 子会话: FAIL | ||
| 212 | + | ||
| 213 | +- [ ] **Step 5.2 实现 GET 端点** | ||
| 214 | + - 子会话: PASS | ||
| 215 | + | ||
| 216 | +- [ ] **Step 5.3 子会话跑全量 mvn test** | ||
| 217 | + - 期望:122 + 5(query DTO valid) + 5(service search unit) + 2(mapper IT) + 9(controller IT) = 143 测试,全绿 | ||
| 218 | + | ||
| 219 | +- [ ] **Step 5.4 提交** | ||
| 220 | + - `git commit -m "feat(usr): GET /api/users controller REQ-USR-003"` | ||
| 221 | + | ||
| 222 | +--- | ||
| 223 | + | ||
| 224 | +## 提交计划 | ||
| 225 | + | ||
| 226 | +- `feat(common): PageResult + MP pagination config REQ-USR-003`(Task 1) | ||
| 227 | +- `feat(usr): user query DTO + list item VO REQ-USR-003`(Task 2) | ||
| 228 | +- `feat(usr): UserMapper.xml searchUsers REQ-USR-003`(Task 3) | ||
| 229 | +- `feat(usr): user query service REQ-USR-003`(Task 4) | ||
| 230 | +- `feat(usr): GET /api/users controller REQ-USR-003`(Task 5) |
docs/superpowers/plans/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-004.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-004 用户登录 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `POST /api/auth/login`:BCrypt 校验密码 → 5 次失败锁定(内存)→ 签发 HS256 JWT → 回写 tLastLoginDate。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 引入 jjwt 0.12.x 签发 JWT;`LoginAttemptStore` 接口 + `InMemoryLoginAttemptStore` 实现(spec 注:Redis 替换为后续 REQ);`JwtTokenProvider` 封装 sign/parse;`LoginService` 协调 4 步逻辑;`SecurityConfig` 白名单 `/api/auth/login`。本 REQ 不切换其他端点为 authenticated(技术债登记)。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 沿用 + 新增 jjwt-api/impl/jackson 0.12.x。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/pom.xml` — 追加 jjwt 三件套 | ||
| 26 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 LOGIN_INVALID_CREDENTIALS(40101) / LOGIN_ACCOUNT_LOCKED(40301) | ||
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 白名单 `/api/auth/login` | ||
| 28 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` | ||
| 29 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`(含嵌套 LoginUserInfo) | ||
| 30 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` — sign/parse 封装 | ||
| 31 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 | ||
| 32 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 | ||
| 33 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | ||
| 34 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | ||
| 35 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` | ||
| 36 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 2 个错误码断言 | ||
| 37 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` | ||
| 38 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` | ||
| 39 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | ||
| 40 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` | ||
| 41 | + | ||
| 42 | +--- | ||
| 43 | + | ||
| 44 | +## 任务步骤 | ||
| 45 | + | ||
| 46 | +### Task 1: pom.xml + ErrorCode + DTO/VO + DTO Validation | ||
| 47 | + | ||
| 48 | +**Files:** | ||
| 49 | +- Modify: `backend/pom.xml`(追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6 三个 dependency) | ||
| 50 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 51 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 52 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` | ||
| 53 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java` | ||
| 54 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` | ||
| 55 | + | ||
| 56 | +**API shape:** | ||
| 57 | +- `LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误")` | ||
| 58 | +- `LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定")` | ||
| 59 | +- `LoginDTO`:`@NotBlank @Size(max=50) String sUserName` / `@NotBlank @Size(max=100) String sPassword` / `@NotBlank @Pattern(regexp="^standard$") String sVersion` | ||
| 60 | +- `LoginResultVO`:`String accessToken` / `long expiresIn` / `LoginUserInfo user`(内嵌静态类 5 字段:iIncrement / sUserNo / sUserName / sUserType / sLanguage) | ||
| 61 | + | ||
| 62 | +- [ ] **Step 1.1 写失败测试** | ||
| 63 | + - ApiResponseTest 追加:`assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);` + LOGIN_ACCOUNT_LOCKED 40301。 | ||
| 64 | + - LoginDTOValidationTest 4 个用例:allValid / blankRequired / invalidVersion / overSized。 | ||
| 65 | + - 子会话: FAIL(class 不存在) | ||
| 66 | + | ||
| 67 | +- [ ] **Step 1.2 实现** | ||
| 68 | + - 在 pom.xml `<dependencies>` 追加: | ||
| 69 | + ``` | ||
| 70 | + io.jsonwebtoken:jjwt-api:0.12.6 | ||
| 71 | + io.jsonwebtoken:jjwt-impl:0.12.6 (runtime) | ||
| 72 | + io.jsonwebtoken:jjwt-jackson:0.12.6 (runtime) | ||
| 73 | + ``` | ||
| 74 | + - ErrorCode + DTO + VO + Validation | ||
| 75 | + - 子会话: PASS | ||
| 76 | + | ||
| 77 | +- [ ] **Step 1.3 提交** | ||
| 78 | + - `git commit -m "feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004"` | ||
| 79 | + | ||
| 80 | +--- | ||
| 81 | + | ||
| 82 | +### Task 2: JwtTokenProvider + 单元测试 | ||
| 83 | + | ||
| 84 | +**Files:** | ||
| 85 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` | ||
| 86 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` | ||
| 87 | + | ||
| 88 | +**API shape:** | ||
| 89 | +- `JwtTokenProvider` Spring `@Component`,构造注入 `@Value("${erp.jwt.secret}") String secret` + `@Value("${erp.jwt.expires-in-seconds}") long expiresIn`。 | ||
| 90 | +- `String sign(int uid, String username, String userType)`:HS256,claims `sub=username` / `uid` / `type=userType` / `iat` / `exp = iat + expiresIn`。 | ||
| 91 | +- `Claims parse(String token)`:验证签名 + 过期,抛 `JwtException` / `ExpiredJwtException`。 | ||
| 92 | + | ||
| 93 | +> secret 不足 256 bit 的处理:jjwt 0.12 要求 SecretKey 至少 256 bit。`JWT_SECRET` (.env.local) 当前已是 256 bit hex。Provider 用 `Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))`。如不足直接抛 `WeakKeyException`(启动期暴露)。 | ||
| 94 | + | ||
| 95 | +- [ ] **Step 2.1 写失败测试** | ||
| 96 | + - `JwtTokenProviderTest#signAndParse_returnsClaims`:注入 fake secret + expiresIn=7200,sign(uid=1, username=alice, type=普通用户) → parse → 断言 sub=alice / uid=1 / type=普通用户 / exp-iat=7200。 | ||
| 97 | + - `JwtTokenProviderTest#parseExpiredToken_throwsExpiredJwtException`:mock Clock 或手工构造已过期 token(用 jjwt builder 设 exp=now-1)。 | ||
| 98 | + - 测试方式:直接 `new JwtTokenProvider(secret, expiresIn)` 实例化,避免 SpringBootTest 开销。 | ||
| 99 | + - 子会话: FAIL | ||
| 100 | + | ||
| 101 | +- [ ] **Step 2.2 实现 JwtTokenProvider** | ||
| 102 | + - 子会话: PASS | ||
| 103 | + | ||
| 104 | +- [ ] **Step 2.3 提交** | ||
| 105 | + - `git commit -m "feat(usr): JwtTokenProvider sign/parse REQ-USR-004"` | ||
| 106 | + | ||
| 107 | +--- | ||
| 108 | + | ||
| 109 | +### Task 3: LoginAttemptStore 接口 + InMemory 实现 | ||
| 110 | + | ||
| 111 | +**Files:** | ||
| 112 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 | ||
| 113 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 | ||
| 114 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java` | ||
| 115 | + | ||
| 116 | +**API shape:** | ||
| 117 | +``` | ||
| 118 | +interface LoginAttemptStore { | ||
| 119 | + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ | ||
| 120 | + long cooldownSeconds(String username); | ||
| 121 | + | ||
| 122 | + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ | ||
| 123 | + void recordFailure(String username); | ||
| 124 | + | ||
| 125 | + /** 登录成功清空记录 */ | ||
| 126 | + void clear(String username); | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +@Component | ||
| 130 | +class InMemoryLoginAttemptStore implements LoginAttemptStore { | ||
| 131 | + private static final int LOCK_THRESHOLD = 5; | ||
| 132 | + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); | ||
| 133 | + private final Map<String, FailRecord> store = new ConcurrentHashMap<>(); | ||
| 134 | + // FailRecord{int count; Instant lockUntil}; 同步 access via map.compute | ||
| 135 | +} | ||
| 136 | +``` | ||
| 137 | + | ||
| 138 | +- [ ] **Step 3.1 写失败测试(4 个)** | ||
| 139 | + - `cooldown_initial_returnsZero` | ||
| 140 | + - `recordFailure_under5_doesNotLock` | ||
| 141 | + - `recordFailure_at5_triggersLock_cooldownPositive` | ||
| 142 | + - `clear_resetsCount` | ||
| 143 | + - (不直接测"15min 后解锁"——time-based 测试用 Duration.ZERO mock 不实际可行;spec § 6 验收"锁定到期后可登录"由 LoginServiceImplTest 模拟 lockUntil 至过去验证) | ||
| 144 | + - 子会话: FAIL | ||
| 145 | + | ||
| 146 | +- [ ] **Step 3.2 实现接口 + InMemory** | ||
| 147 | + - 子会话: PASS | ||
| 148 | + | ||
| 149 | +- [ ] **Step 3.3 提交** | ||
| 150 | + - `git commit -m "feat(usr): in-memory login attempt store REQ-USR-004"` | ||
| 151 | + | ||
| 152 | +--- | ||
| 153 | + | ||
| 154 | +### Task 4: LoginService + Mockito 单元测试 | ||
| 155 | + | ||
| 156 | +**Files:** | ||
| 157 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | ||
| 158 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | ||
| 159 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | ||
| 160 | + | ||
| 161 | +**API shape:** | ||
| 162 | +- `LoginService.login(LoginDTO dto): LoginResultVO` | ||
| 163 | +- 实现步骤(plan 锁定): | ||
| 164 | + 1. 检查锁定:`long cd = attemptStore.cooldownSeconds(dto.sUserName)`。`cd > 0` → 抛 `BizException(LOGIN_ACCOUNT_LOCKED, message + cooldownSeconds)`;message 体内含 cooldownSeconds,由 controller 层把 BizException 转换时把 cd 放到 data.cooldownSeconds。 | ||
| 165 | + - **简化方案**:直接抛 `BizException(LOGIN_ACCOUNT_LOCKED)` 不带 cooldown;controller 层 `catch BizException` 包装成 `ApiResponse.fail` 时丢失 cooldown。**改进方案**:自定义 `AccountLockedException extends BizException`,含 `long cooldownSeconds`,GlobalExceptionHandler 加专门 handler 把 cooldownSeconds 放进 `ApiResponse.data`。spec § 验收 #7 要求 `data.cooldownSeconds`,故选改进方案。 | ||
| 166 | + 2. 查用户:`userMapper.selectOne(eq(sUserName).eq(bDeleted, false))`。null → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 | ||
| 167 | + 3. BCrypt 校验:`passwordEncoder.matches(dto.sPassword, user.sPasswordHash)`。false → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 | ||
| 168 | + 4. 成功:`attemptStore.clear(sUserName)`;签发 token;`userMapper.update(null, LambdaUpdateWrapper.eq(iIncrement,user.iIncrement).set(tLastLoginDate, now))`;构造 LoginResultVO 返回。 | ||
| 169 | +- 标 `@Transactional(rollbackFor = Exception.class)`(成功路径有 update;失败也安全)。 | ||
| 170 | + | ||
| 171 | +**新建** `AccountLockedException`(在 module_usr/security 或 common/exception,本 plan 选 common/exception): | ||
| 172 | +``` | ||
| 173 | +class AccountLockedException extends BizException { | ||
| 174 | + private final long cooldownSeconds; | ||
| 175 | + public AccountLockedException(long cd) { | ||
| 176 | + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); | ||
| 177 | + this.cooldownSeconds = cd; | ||
| 178 | + } | ||
| 179 | +} | ||
| 180 | +``` | ||
| 181 | + | ||
| 182 | +GlobalExceptionHandler 追加 `@ExceptionHandler(AccountLockedException.class)` 把 cooldownSeconds 包进 `ApiResponse.data`,为此需要 `Map<String, Object>` data 形式:`Map.of("cooldownSeconds", e.getCooldownSeconds())`。 | ||
| 183 | + | ||
| 184 | +- [ ] **Step 4.1 写失败测试(7 个)** | ||
| 185 | + - `login_validCredentials_returnsTokenAndClearsFailCount`:mock attemptStore.cooldownSeconds=0 / userMapper.selectOne 返回 user / passwordEncoder.matches=true / jwt.sign 返回固定 token / userMapper.update 返回 1。断言 VO.accessToken / verify attemptStore.clear / verify userMapper.update 被调(含 LambdaUpdateWrapper)。 | ||
| 186 | + - `login_userNotFound_returns40101_recordsFailure` | ||
| 187 | + - `login_userSoftDeleted_returns40101`:selectOne 已带 bDeleted=0 过滤,覆盖该路径等价于 userNotFound(返回 null);mock selectOne null 即可。 | ||
| 188 | + - `login_passwordMismatch_returns40101_recordsFailure` | ||
| 189 | + - `login_accountLocked_throwsAccountLockedException_withCooldown` | ||
| 190 | + - `login_5thFailureTriggersLock`(mock attemptStore 检验 recordFailure 调用次数) | ||
| 191 | + - `login_successUpdatesTLastLoginDate`:ArgumentCaptor 捕 LambdaUpdateWrapper(或 Wrapper),断言 sql set 含 tLastLoginDate。 | ||
| 192 | + - 子会话: FAIL | ||
| 193 | + | ||
| 194 | +- [ ] **Step 4.2 实现 LoginService + Impl + AccountLockedException + GlobalExceptionHandler 扩展** | ||
| 195 | + - 子会话: PASS | ||
| 196 | + | ||
| 197 | +- [ ] **Step 4.3 提交** | ||
| 198 | + - `git commit -m "feat(usr): login service + account locked handling REQ-USR-004"` | ||
| 199 | + | ||
| 200 | +--- | ||
| 201 | + | ||
| 202 | +### Task 5: LoginController + SecurityConfig 白名单 + 端到端 IT | ||
| 203 | + | ||
| 204 | +**Files:** | ||
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` | ||
| 206 | +- Modify: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`(白名单 /api/auth/login) | ||
| 207 | +- Modify: `backend/src/main/resources/application.yml`(确认 `erp.jwt.secret` / `erp.jwt.expires-in-seconds` 已存在;REQ-USR-001 引入时已设置) | ||
| 208 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` | ||
| 209 | + | ||
| 210 | +**API shape:** | ||
| 211 | +- `@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor LoginController` | ||
| 212 | +- `@PostMapping("/login") ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto)` | ||
| 213 | +- Javadoc: `REQ-USR-004 用户登录 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成` | ||
| 214 | +- SecurityConfig 修改:在 `authorizeHttpRequests` 配置上下文中显式 `requestMatchers("/api/auth/login").permitAll()`,其他保持 permitAll(REQ-USR-004 不强制收紧,留作技术债登记) | ||
| 215 | + | ||
| 216 | +- [ ] **Step 5.1 写失败测试(9 个)** | ||
| 217 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback`,`@Autowired UserMapper / PasswordEncoder / InMemoryLoginAttemptStore`,`@BeforeEach` 调用 `attemptStore.clear(sUserName)`(避免跨测试串扰,因为 store 是单例 ConcurrentHashMap)。 | ||
| 218 | + - `login_validCredentials_returns200WithToken`:先 mapper.insert 一个 user(密码 BCrypt 哈希 666666)→ POST 登录 → 断言 200 / token 非空 / expiresIn=7200 / user 嵌套。 | ||
| 219 | + - `login_jwtClaimsAreCorrect`:上一条 token 用 JwtTokenProvider 解析(@Autowired),断言 sub / uid / type 与 DB 一致。 | ||
| 220 | + - `login_invalidUsername_returns40101` | ||
| 221 | + - `login_wrongPassword_returns40101` | ||
| 222 | + - `login_softDeletedUser_returns40101` | ||
| 223 | + - `login_missingPassword_returns40010` | ||
| 224 | + - `login_invalidVersion_returns40010`:sVersion="experimental" | ||
| 225 | + - `login_5thFailureLocks_returns40301`:连续 5 次 wrong password,第 5 次断言 code=40301 + `data.cooldownSeconds > 0` | ||
| 226 | + - `login_responseExcludesSPasswordHash`:jsonPath `$.data.user.sPasswordHash` doesNotExist | ||
| 227 | + - 子会话: FAIL | ||
| 228 | + | ||
| 229 | +- [ ] **Step 5.2 实现 LoginController + SecurityConfig 白名单** | ||
| 230 | + - 子会话: PASS | ||
| 231 | + | ||
| 232 | +- [ ] **Step 5.3 跑全量 backend 测试** | ||
| 233 | + - `cd backend && mvn -B test` | ||
| 234 | + - 期望 144 + 4(DTO valid) + 2(JwtTokenProvider) + 4(InMemoryStore) + 7(service unit) + 9(controller IT) = 170 测试,全绿 | ||
| 235 | + | ||
| 236 | +- [ ] **Step 5.4 提交** | ||
| 237 | + - `git commit -m "feat(usr): POST /api/auth/login controller REQ-USR-004"` | ||
| 238 | + | ||
| 239 | +--- | ||
| 240 | + | ||
| 241 | +## 提交计划 | ||
| 242 | + | ||
| 243 | +- `feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004`(Task 1) | ||
| 244 | +- `feat(usr): JwtTokenProvider sign/parse REQ-USR-004`(Task 2) | ||
| 245 | +- `feat(usr): in-memory login attempt store REQ-USR-004`(Task 3) | ||
| 246 | +- `feat(usr): login service + account locked handling REQ-USR-004`(Task 4) | ||
| 247 | +- `feat(usr): POST /api/auth/login controller REQ-USR-004`(Task 5) |
docs/superpowers/reviews/2026-05-06-REQ-MOD-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-001 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:30 — 兜底 `@ExceptionHandler(Exception.class)` 会吃掉 `HttpMessageNotReadableException` / `HttpRequestMethodNotSupportedException` / `MissingServletRequestParameterException` 等参数类异常并映射到 50000;语义上更接近 40010。建议下一个 REQ 顺手为这几类异常补独立 handler。 | ||
| 19 | +- backend/src/main/java/com/xly/erp/config/SecurityConfig.java:18 — `csrf -> csrf.disable()` 等三处 lambda 可改用方法引用 `AbstractHttpConfigurer::disable`,更符合 Spring Security 6 官方示例风格。 | ||
| 20 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:39 — `.eq(ModuleEntity::getBDeleted, false)` 可读性建议统一用 `Boolean.FALSE` 或抽常量,依赖 JDBC 默认转换不影响正确性。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:26 / Controller / Mapper / Entity — spec § 代码与文档要求关键类贴 `// REQ-MOD-001 模块新增` 标签;目前 Controller 仅有 REQ-USR-004 备忘注释,缺 REQ-MOD-001 标签,Service / Mapper / Entity / DTO / VO 也均未显式标。建议补一行类级注释。 | ||
| 22 | +- backend/src/main/java/com/xly/erp/config/JacksonConfig.java:18 — JacksonConfig 是计划外补丁(修复匈牙利前缀字段 JSON 序列化);plan 文件清单未列出。建议补一条 ObjectMapper 序列化测试断言 `iIncrement` 而非 `IIncrement`,把这个隐式契约钉死。 | ||
| 23 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:55 — `post_validRootModule` 应额外断言 VO 字段值(sDisplayType / sModuleNameZh / iSortOrder / tCreateDate / bShowPermission),spec 验收 § 1 要求"DB 中查询新记录字段与入参一致"。 | ||
| 24 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:77 — `post_validChildModule` 没有断言 iSortOrder / bShowPermission / sCreatedBy 留 NULL 等持久化字段,建议补 select-after-insert 验证。 | ||
| 25 | +- backend/src/main/java/com/xly/erp/common/response/ApiResponse.java:28 — `fail(ErrorCode ec, String detail)` 把 message 替换为 detail,前端会吃到带英文字段名的中文混合串。建议将 detail 单列字段或保留 ec.getMessage() 作为 message。 | ||
| 26 | +- backend/src/main/resources/application.yml:21 — 全局 `map-underscore-to-camel-case: false` 对未来非匈牙利前缀字段有传染影响;当前 entity 已显式 `@TableField`,可改回 MyBatis-Plus 默认 true。 | ||
| 27 | + | ||
| 28 | +## 反例 / 测试覆盖缺口 | ||
| 29 | + | ||
| 30 | +1. spec § 验收 § 功能正确性 1 要求"DB 中查询新记录字段与入参一致"——目前 `ControllerIT` 只断言 3 个字段、未回查 DB;`ModuleMapperIT` 单独覆盖 mapper 层往返,端到端层面字段一致性不直观。 | ||
| 31 | +2. spec § 测试覆盖列出"并发同 sProcedureName"用例标可选,未实现——可接受。 | ||
| 32 | +3. spec § 业务规则 5 要求 `bDeleted=0` / `tDeletedDate=NULL` / `sDeletedBy=NULL` / `sBrandsId/sSubsidiaryId/sCreatedBy=NULL` 在 INSERT 后真正落库——`ModuleServiceImplTest` 用 mock 不能验证 entity 在传给 `mapper.insert` 前的赋值;建议加 `ArgumentCaptor` 断言。 | ||
| 33 | +4. Controller 层缺 `HttpMessageNotReadableException` 用例(POST 非法 JSON 当前会走 50000 兜底);spec 未显式要求,但 docs/04 § 1.4 友好错误码原则下属盲区。 | ||
| 34 | +5. 计划清单列出的 `MybatisPlusConfig.java` 未创建——plan 自身注明"REQ-MOD-001 不用,先建空骨架",YAGNI 角度可接受;建议在 plan 上同步勾掉或删除该行。 | ||
| 35 | +6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-001` 标签、响应不回显堆栈——全部合规。spec 写明的 REQ-USR-004 技术债(permitAll + 多租户字段 NULL + sCreatedBy NULL)落地受控,无失控扩散。 |
docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-002 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java:56 — `iParentId` 改为 `FieldStrategy.IGNORED` 是 entity 全局行为变更。本期 update 走 load-then-modify 全量回填路径所以安全;但未来若有 partial updateById 路径会把 iParentId 写成 NULL。建议在字段注释加 "调用方必须 selectById 后再 updateById",或将 NULL 写入语义收敛到 update 方法本地。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:207 — spec § 验收 #2「正向 — 设置父模块到合法 sibling」只覆盖了 setParentToNull,没有覆盖"把 iParentId 改到另一个未删除模块"的正向写入路径。建议追加一个 IT 用例。 | ||
| 20 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:630 — `update_full_returnsVOWithUpdatedFields` 的 dto.iParentId 与 target.iParentId 都是 null,断言只覆盖了 null→null。建议把 dto.setIParentId(非 null) 并 mock 合法父返回,覆盖 walk-up depth=1 直接退出循环的非环场景。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java:24 — 本 REQ 顺手把 REQ-MOD-001 那行 `// REQ-USR-004 完成后追加 @PreAuthorize(...)` 类外注释改成了 Javadoc,触及既有代码(非严格 surgical)。可读性改善但 commit message 未披露;下次类似情况建议拆 refactor commit 或在 body 注脚说明。 | ||
| 22 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:597 — `when(moduleMapper.selectById(999999)).thenReturn(null)` 冗余(Mockito 默认就是 null),可删除。 | ||
| 23 | + | ||
| 24 | +## 反例 / 测试覆盖缺口 | ||
| 25 | + | ||
| 26 | +1. spec § 验收 #2「正向设置父模块到 sibling」在 IT 与单元两层都缺成功路径用例;只覆盖了"清空父"和各种 parent 校验失败用例,未直接验证 iParentId 从 null/某值改到另一个有效模块后 DB 实际写入了新父 id 的主线路径。 | ||
| 27 | +2. spec § 验收 #6「目标已软删除」仅在单元层覆盖(`update_targetSoftDeleted_throws40421`),IT 缺一个"先 update bDeleted=1 再 PUT 返回 40421"用例。 | ||
| 28 | +3. spec § 验收 #8(枚举非法 `sDisplayType="X"`)/ #9(`sModuleType=51 字符`)只在 `ModuleUpdateDTOValidationTest` 单元层验证,未在 IT 走一遍 PUT 端到端断言 40010。 | ||
| 29 | +4. 环路检查的"4-5 层深度边界"和"父链中存在已软删除节点导致提前 break 的非环场景"未单独覆盖;本期数据量低可接受。 | ||
| 30 | +5. `FieldStrategy.IGNORED` 是 entity 全局变更,对未来其他 service 走 partial `updateById` 路径会埋雷;建议要么文档化要么把 NULL 写入收敛到本地策略。 | ||
| 31 | +6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-002` 标签、响应不回显堆栈——全部合规。错误码(40010/40411/40421/40921)与 docs/05 / spec 一致。环路检查实现 walk-up 沿父链最多 5 层 + 已删节点 break,逻辑正确。 |
docs/superpowers/reviews/2026-05-06-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 2 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-003 — round 2 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无;round 1 两条 must_fix 已在 commit 2419659 中修复) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:16 — `org.mockito.ArgumentMatchers` 仍未使用(round 1 提过,pre-existing;可顺手清掉)。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:358/375/387 — `(Wrapper<ModuleEntity>) any()` 强转触发 unchecked 警告,可在类上 `@SuppressWarnings("unchecked")` 或就近用 `ArgumentMatchers.<Wrapper<ModuleEntity>>any()`。 | ||
| 20 | +- 可选:增加 `delete_writesSDeletedByNull_onSoftDelete` 单元测试,用 ArgumentCaptor 捕 `LambdaUpdateWrapper` 解析 `wrapper.getSqlSet()` 断言含 `sDeletedBy=NULL / bDeleted=1 / tDeletedDate=...`,把 SET 子句的列覆盖也在单元层钉一遍(目前 SET 列内容仅由 IT 兜底)。 | ||
| 21 | +- docs/05-API接口契约.md § REQ-MOD-003 写「40912 响应附 data.references」,spec 未实现;属于已知契约漂移,留给 module-report 时统一对齐。 | ||
| 22 | + | ||
| 23 | +## 反例 / 测试覆盖缺口 | ||
| 24 | + | ||
| 25 | +Round 1 两条 must_fix 均已落实: | ||
| 26 | + | ||
| 27 | +1. `ModuleServiceImpl.delete()` 改为 `moduleMapper.update(null, uw)` + `LambdaUpdateWrapper.set(BDeleted,true).set(TDeletedDate,now()).set(SDeletedBy,null)`;`eq(BDeleted,false)` 并发兜底保留;`affected==0 → 40421` 保留。三件套全部由 wrapper 显式声明,**绕开** `iParentId.FieldStrategy.IGNORED` 副作用。 | ||
| 28 | +2. `ModuleControllerIT#delete_preservesOtherFields_onChildModule` 已新增:用自定义字段值(sDisplayType='接口' / sModuleType='AUDIT' / sManageDeptEn='OPS' / bShowPermission=true / sModuleNameZh='待保留中文名' / iSortOrder=7)建 child(parentId),DELETE 后 reload 断言 8 个字段全部保持原值 + bDeleted=true + tDeletedDate 非 null。 | ||
| 29 | + | ||
| 30 | +**单元测试降级合理性**:架构改动后 entity 参数为 null,原 ArgumentCaptor 对 entity 字段的断言失去对象;MP 真正写入的 SET 列在 wrapper 内部 SqlSegment,单元层断言列覆盖复杂度高且偏离职责。新 IT 在真实 MySQL 端到端验证「除三件套外其他列保持原值 + iParentId 不被清空」,比 mock 层 ArgumentCaptor 严格得多。`verify(moduleMapper).update((ModuleEntity) isNull(), ...)` 把"entity 参数必须是 null"这一架构不变量钉死,防止未来误回滚到 entity-driven update。整体是 mock 层小幅放宽 + IT 层显著加强的净增强。 | ||
| 31 | + | ||
| 32 | +非阻塞遗留:(a) docs/05 § REQ-MOD-003 `data.references` 描述与实现不一致;(b) 单元测试 `ArgumentMatchers` 未使用 import;(c) 重复 DELETE 集成层显式用例缺失(间接覆盖足够)。 |
docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-MOD-004 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #7『同级排序』:插入 root(iSortOrder=2) + root(iSortOrder=1),断言 GET 返回数组首个 iIncrement 是 sort=1 那条。当前同级排序仅在单测覆盖,缺端到端断言。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #6『keyword 含中英混合』:插入 sModuleNameZh='user 用户',GET ?keyword=user 用户 命中。 | ||
| 20 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:497 — `tree_softDeletedExcluded` 仅 verify(...).selectList(any()),未捕 wrapper 形状(plan 要求 ArgumentCaptor)。建议改名或真用 ArgumentCaptor 抽 `wrapper.getTargetSql()` 断言含 `bDeleted = 0`。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:227 — Comparator 对 iSortOrder 做了 null→0 防御,但 docs/03 已注明 NOT NULL;可简化为 `Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(::getIIncrement)`。 | ||
| 22 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:198 — `survivorIds = byId.keySet()` 是 live view,与 keyword 分支返回的独立 HashSet 类型不一致;建议改成 `new HashSet<>(byId.keySet())` 一致化。 | ||
| 23 | +- backend/pom.xml:121 — surefire includes 修改是项目级配置;建议在 docs/04 测试规范节追加一行说明『*IT.java 走 surefire (mvn test) 而非 failsafe (mvn verify)』,避免后续贡献者破坏 includes。 | ||
| 24 | +- docs/03-数据库设计文档.md § tModule 业务注记 — 建议追加一行『模块树最大深度 5 层(iParentId 自引用),由 service 层校验,REQ-MOD-002 / REQ-MOD-004 引用本约束』,把深度上限提升为 SSoT。 | ||
| 25 | +- backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java:11 — 在 @Size 上方 javadoc 写明『empty 视为不过滤』。 | ||
| 26 | + | ||
| 27 | +## 反例 / 测试覆盖缺口 | ||
| 28 | + | ||
| 29 | +1. spec § 验收 #7 同级排序的端到端 IT 缺失(仅单测)。 | ||
| 30 | +2. spec § 验收 #6 中英混合 keyword 没有专属 IT。 | ||
| 31 | +3. `tree_softDeletedExcluded` 单测断言强度低于 plan 期望。 | ||
| 32 | +4. 反例缺:keyword 含 SQL 通配符 `%` / `_`(spec 声明本期不转义,作为已知边界)。 | ||
| 33 | +5. 反例缺:祖先链深度 ≥ 5 的截断边界用例;docs/03 已限深度 5,正常打不到边界,可后续补 6 层强构数据单测固化截断语义。 | ||
| 34 | +6. Controller javadoc 标了『REQ-USR-004 完成后追加 @PreAuthorize』但缺集中跟踪——建议在 docs/08 § 二 USR-004 子项挂 follow-up 备注。 | ||
| 35 | + | ||
| 36 | +**核心结论**:实现忠实于 spec — keyword 过滤 + 祖先链 walk-up + 内存树构造 + 同级 (iSortOrder, iIncrement) 排序 + 叶子 children=[] 全部正确。API 契约字段精简到位(VO 7 个公开字段 + children)。surefire includes 修复是揭示性发现——本仓库之前 *IT.java 没在 mvn test 跑过,REQ-MOD-001/002/003 的 IT 都是"死代码";本 REQ commit body 已披露,REQ-MOD-004 的 IT 是首次在 mvn test 跑通。 |
docs/superpowers/reviews/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 2 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-001 — round 2 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +Round 1 两条 must_fix 处理结果: | ||
| 17 | +1. **HIGH(reviewer 误判)**:`UserServiceImpl.java:102` 实际已含 `up.setTCreateDate(LocalDateTime.now());`,round 1 reviewer 看错。fix commit 520c01f 正确不动代码。 | ||
| 18 | +2. **MEDIUM(已修)**:spec/plan 中 `bSelected` 提及全部转为「无该列」注解,与 docs/03 SSoT + UserPermissionEntity 实现一致(fix commit 520c01f)。 | ||
| 19 | + | ||
| 20 | +## Nice-to-have(待 sweep) | ||
| 21 | + | ||
| 22 | +- backend/src/main/java/com/xly/erp/common/response/ErrorCode.java — 3 个新常量(STAFF_NOT_FOUND / PERM_CATEGORY_NOT_FOUND / USR_USER_NAME_OR_NO_DUP)追 Javadoc,特别 `STAFF_NOT_FOUND(40421)` 与 `MOD_NOT_FOUND(40421)` 段位共享但语义不同——Javadoc 注明使用模块差异,避免 IDE 跳错枚举。 | ||
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:30 — `INITIAL_PASSWORD = "666666"` 外置到 `application.yml` + `@Value("${xly.user.initial-password:666666}")`,便于环境差异化 + REQ-USR-004 登录校验复用。 | ||
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — 补 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条 IT 镜像 service 单测分支。 | ||
| 25 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:70 — `permissionCategoryIds` 含重复 id 时 found.size() < ids.size() 误报 40422;可在校验前 distinct。 | ||
| 26 | +- docs/05 § REQ-USR-001 错误码段位 40020 vs spec 落地 40010 sweep 对齐。 | ||
| 27 | +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — Javadoc 注释笔误 `REQ-MOD-001 用户新增`,Controller.java 内已正确为 `REQ-USR-001`;plan 同步修正即可。 | ||
| 28 | + | ||
| 29 | +## 反例 / 测试覆盖缺口 | ||
| 30 | + | ||
| 31 | +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测覆盖)。 | ||
| 32 | +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。 | ||
| 33 | +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。 | ||
| 34 | +4. 跨 round 1 / round 2:fix commit 520c01f 仅触及 3 份 docs,0 行代码变更;UserServiceImpl / UserPermissionEntity / 测试均保持原样,无新引入风险。`scripts/test.sh` 全量 101/0/0/0 BUILD SUCCESS。 | ||
| 35 | + | ||
| 36 | +**核心结论**:round 1 must_fix #1 是 reviewer 误判(无需修),#2 已修。所有 IT + 单元测试 101 个全绿。verdict: approve。 |
docs/superpowers/reviews/2026-05-06-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-002 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:285 — `put_permissionCategoryNotFound_returns40422` 只断言响应 code,未断言 spec § 验收 #11 要求的 "DB user 与 tUserPermission 都不变(事务回滚)"。受 IT 类整体 `@Transactional+@Rollback` 制约,需另起 `Propagation.NOT_SUPPORTED` 测试或在 service 抛异常前后分别 reload 校验字段未变。 | ||
| 19 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:235 — `put_validUpdate_returns200_andDbReflects` 仅断言 `upCount==2L`,没显式断言新 2 条关联指向 `catNew1/catNew2` 且原 3 条已不存在。建议追加 `containsExactlyInAnyOrder(catNew1, catNew2)` + `doesNotContain(cat1, cat2, cat3)`。 | ||
| 20 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 缺 `put_targetSoftDeleted_returns40431` 和 `put_staffSoftDeleted_returns40421` 两条端到端用例(spec § 验收 #7 / #10 仅在 service 单测覆盖)。 | ||
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:327 — `put_ignoresProtectedFields_doesNotChangeUserNoOrName` 未断言 tCreateDate 保留(spec § 业务规则 #7 / 验收 #4 列入"不被修改"范围)。 | ||
| 22 | +- backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java:40 — `iStaffId` 全局 `FieldStrategy.IGNORED` 是已知副作用(注释已说明)。建议在 module_usr 完成报告 § ⑦ 跨模块改动 / 风险登记追加一条:"UserEntity.iStaffId 已加 IGNORED;后续 partial-update path 必须 selectById 后再 updateById",与 module_mod ModuleEntity.iParentId 同类风险并案管理。 | ||
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java — Service 中 iStaffId/sUserType/sLanguage 直接覆盖、bCanModifyDocs null 保留——建议加注释或抽 mergeUpdate 私有方法集中 null 语义。 | ||
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java — 未覆盖 `dto.permissionCategoryIds == null` 分支(spec § 业务规则 #5 "不传则只删不插")。当前所有相关测试都用 `List.of()` 而非 null。 | ||
| 25 | + | ||
| 26 | +## 反例 / 测试覆盖缺口 | ||
| 27 | + | ||
| 28 | +1. **AC11 回滚证据**:`put_permissionCategoryNotFound_returns40422` 受 `@Transactional+@Rollback` 制约,无法直接观测 service 层的回滚。 | ||
| 29 | +2. **AC1 重建关联粒度**:`upCount==2` 可被任何 insert 顺序异常掩盖,需细化断言。 | ||
| 30 | +3. **AC7 / AC10 端到端缺失**:目标软删除 / staff 软删除均仅在 service 单测覆盖。 | ||
| 31 | +4. **AC4 tCreateDate 保留**:实现走 load-then-modify 默认 NOT_NULL 策略行为正确,但缺测试佐证。 | ||
| 32 | +5. **业务规则 #5 null permissionCategoryIds 分支**:单测未直接覆盖。 | ||
| 33 | +6. **iStaffId IGNORED 全局副作用**:本期安全,未来 partial-update path 风险,需在 module_usr 完成报告中登记。 | ||
| 34 | + | ||
| 35 | +**核心结论**:Spec/Plan 业务规则 1-9 全部实现到位,错误码 40010 / 40421 / 40422 / 40431 全部回归,UserVO 不暴露 sPasswordHash,保留字段 sUserNo / sUserName / sPasswordHash 不被改通过 IT 验证。docs/05 § REQ-USR-002 列的 40331 / 40931 是 RBAC 范畴的本期不实施项,spec 已说明,不阻塞。verdict: approve;改进项放下一 REQ 或 module-report sweep。 |
docs/superpowers/reviews/2026-05-06-REQ-USR-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 2 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-003 — round 2 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +Round 1 三条 must_fix 处理结果(commit f53689c): | ||
| 17 | +1. **HIGH SQL 注入** — RESOLVED。UserQueryDTO 删除 `column` 字段;UserMapper.searchUsers 三参签名含 `@Param("column") String column`;UserMapper.xml 用 `${column}`;UserServiceImpl.search 用局部变量映射后通过 mapper 单独传入。 | ||
| 18 | +2. **HIGH spec § 6 deleted=true** — RESOLVED。Service 实现 'true'/'false' → '1'/'0' 标准化(其它抛 PARAM_INVALID);XML deleted 分支用 `CAST(#{queryValue} AS UNSIGNED)` 兼容 bit(1);恢复 `get_filterByDeletedTrue_returnsOnlyDeleted` IT;新增 `search_deletedQueryValueTrue_normalizedToOne` 单测。 | ||
| 19 | +3. **MEDIUM XML deleted 边界** — RESOLVED。queryField=deleted 但 queryValue 空时仍保留默认过滤 `u.bDeleted = 0`,避免返回全量含已删除。 | ||
| 20 | + | ||
| 21 | +## Nice-to-have | ||
| 22 | + | ||
| 23 | +- UserServiceImpl.search:212/214 — 直接 `query.setQueryValue("1")` 改写入参 DTO,单测靠副作用断言。语义上 service 不应突变 controller 入参;可改用局部 `normalizedDeletedValue` + `@Param("deletedValue")` 传给 mapper,XML 改用 `#{deletedValue}`,更纯。 | ||
| 24 | +- round 1 遗留的 6 条 IT 覆盖缺口(department equals / deleted=false / notContains / 排序 / matchType 非枚举 IT / 空结果 IT)+ UserMapperSearchIT 断言强化 + PageResult javadoc + QUERY_COLUMN_MAP 位置 + Base_Column_List 抽取——本轮也不在 must_fix 范畴,留待后续 sweep。 | ||
| 25 | + | ||
| 26 | +## 反例 / 测试覆盖缺口 | ||
| 27 | + | ||
| 28 | +Round 1 must_fix 1 + 2 + 3 三项均已落地: | ||
| 29 | + | ||
| 30 | +1. 注入:UserMapper.xml 唯一 `${...}` 插值仅来自 service 白名单映射,外部输入完全经过 `#{...}` 参数化绑定。 | ||
| 31 | +2. deleted 标准化:单测 `search_deletedQueryValueTrue_normalizedToOne` 钉死 `query.getQueryValue() == "1"`;IT `get_filterByDeletedTrue_returnsOnlyDeleted` 端到端验证返回已删除用户。 | ||
| 32 | +3. XML 边界:deleted 空值分支保留默认 `u.bDeleted = 0`,与 spec § 业务规则 1 一致。 | ||
| 33 | + | ||
| 34 | +`mvn -B test` 经 .env.local 注入后 144/144 全绿,无新高危。 | ||
| 35 | + | ||
| 36 | +**核心结论**:round 1 high 注入风险 + spec § 6 契约缺失全部修复;其余 nice-to-have 不阻塞,留下一 sweep。verdict: approve。 |
docs/superpowers/reviews/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 2 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-004 — round 2 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +Round 1 三条 must_fix 处理结果(commit d439c0d): | ||
| 17 | +1. **CRITICAL JwtTokenProviderTest 硬编码生产 JWT_SECRET** — RESOLVED。test SECRET 改为 fake 32-byte hex(line 21);`.env.local` JWT_SECRET 已旋转为新随机值。**遗留运维项**:旧值已入 commit b7ed804 git history,所有已部署环境必须同步轮换 JWT_SECRET。 | ||
| 18 | +2. **HIGH InMemoryLoginAttemptStore 锁定到期不重置** — RESOLVED。cooldownSeconds 过期路径 `store.remove`,recordFailure compute 入口检测 prev.lockUntil 过期重建 FailRecord;spec § 业务规则 4 第 4 条达成。 | ||
| 19 | +3. **MEDIUM 验收 #9 无测试** — RESOLVED。补 `cooldown_afterExpiry_resetsCount` 单测(含 reset 后再 4 次未锁、第 5 次再锁的完整往返)+ `login_afterLockExpiry_returns200` IT;`expireLockForTest` 改 public 解决跨包可见性。 | ||
| 20 | + | ||
| 21 | +## Nice-to-have | ||
| 22 | + | ||
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java + service — spec § 业务规则 7 客户端 IP 审计未实施;service 未拿 `HttpServletRequest.getRemoteAddr()`。**需在模块完成报告 § ⑩ 登记技术债**。 | ||
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java — `login_successUpdatesTLastLoginDate_viaSetClause` 仍只 `verify(...).update(isNull(), any(Wrapper.class))`,没断言 LambdaUpdateWrapper 的 set 子句包含 tLastLoginDate(plan Step 4.1 要求)。 | ||
| 25 | +- backend/src/main/java/com/xly/erp/config/SecurityConfig.java — plan Step 5.2 要求 `requestMatchers("/api/auth/login").permitAll()` 显式白名单;当前 anyRequest().permitAll() 等价覆盖但未做 plan 偏离登记。 | ||
| 26 | +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:17 — `java.util.Map` fully-qualified;下次 sweep 加 import。 | ||
| 27 | +- backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java:51 — `expireLockForTest` 已 public 但住在生产包;下一 sweep 可挪到 src/test 的 testutil 包。 | ||
| 28 | + | ||
| 29 | +## 反例 / 测试覆盖缺口 | ||
| 30 | + | ||
| 31 | +Round 1 三项 must_fix 全部解决: | ||
| 32 | +1. JwtTokenProviderTest SECRET 改 fake + .env.local 已旋转;运维侧需轮换部署环境。 | ||
| 33 | +2. cooldownSeconds + recordFailure 双路径处理过期重置;business rule #4 完整。 | ||
| 34 | +3. 验收 #9 单测 + IT 双层覆盖;reset 后能重新触发新一轮锁定。 | ||
| 35 | + | ||
| 36 | +`mvn -B test` 经 .env.local 注入后 172/172 全绿;`scripts/test.sh` GREEN;无新高危。 | ||
| 37 | + | ||
| 38 | +**核心结论**:critical + high + medium 三项关键修复已落地。剩余 5 条 nice-to-have(IP 审计 / SET 子句捕获 / SecurityConfig 显式白名单 / Map import / testutil 移植)在模块完成报告 § ⑩ 登记后留给后续 sweep。verdict: approve。 |
docs/superpowers/specs/2026-05-06-REQ-MOD-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-001 — 模块新增 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `POST /api/modules` 接口:将一条新的业务模块定义写入 `tModule` 表,作为 ERP 系统功能与权限分组的基础单位,返回新模块主键 `iIncrement` 与完整 VO。 | ||
| 12 | + | ||
| 13 | +本 REQ 是 module_mod 模块的第一个 REQ,也是整个 B 阶段的首个 REQ;需顺带建立 Spring Boot 项目骨架(最小可运行单元),仅落地能让本接口工作的横切组件。其余横切组件(JWT 鉴权、登录用户上下文)按 docs/02 § 三 约定由 REQ-USR-004 首次落地。 | ||
| 14 | + | ||
| 15 | +## 输入 / 触发 | ||
| 16 | + | ||
| 17 | +**接口**:`POST /api/modules`,Content-Type `application/json`。 | ||
| 18 | + | ||
| 19 | +**Request body**(`ModuleCreateDTO`)字段: | ||
| 20 | + | ||
| 21 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | | ||
| 22 | +|---|---|---|---|---| | ||
| 23 | +| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` | | ||
| 24 | +| `sProcedureName` | String | 是 | 长度 1-100;系统内唯一(`bDeleted=0` 范围内) | `tModule.sProcedureName` | | ||
| 25 | +| `sModuleType` | String | 是 | 长度 1-50(自由文本) | `tModule.sModuleType` | | ||
| 26 | +| `sManageDeptEn` | String | 是 | 长度 1-50(自由文本) | `tModule.sManageDeptEn` | | ||
| 27 | +| `bShowPermission` | Boolean | 否 | 默认 `false` | `tModule.bShowPermission` | | ||
| 28 | +| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` | | ||
| 29 | +| `iParentId` | Integer | 否 | 必须指向已存在且未软删除的 `tModule.iIncrement` | `tModule.iParentId` | | ||
| 30 | +| `iSortOrder` | Integer | 否 | 默认 `0`;非负整数 | `tModule.iSortOrder` | | ||
| 31 | + | ||
| 32 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:CREATE`。本 REQ 暂不实施实际鉴权(见 § 边界与约束),但 Controller 仍按需要鉴权的形态编写(无 `@AnonymousAccess` 标注),以便 REQ-USR-004 加入 SecurityFilterChain 后零改动。 | ||
| 33 | + | ||
| 34 | +## 输出 / 结果 | ||
| 35 | + | ||
| 36 | +**HTTP 200,响应体**(统一响应格式): | ||
| 37 | + | ||
| 38 | +```json | ||
| 39 | +{ | ||
| 40 | + "code": 200, | ||
| 41 | + "message": "操作成功", | ||
| 42 | + "data": { | ||
| 43 | + "iIncrement": 12, | ||
| 44 | + "sDisplayType": "前端业务", | ||
| 45 | + "sProcedureName": "sp_audit_user_module", | ||
| 46 | + "sModuleType": "USR", | ||
| 47 | + "sManageDeptEn": "IT", | ||
| 48 | + "bShowPermission": false, | ||
| 49 | + "sModuleNameZh": "用户管理", | ||
| 50 | + "iParentId": null, | ||
| 51 | + "iSortOrder": 0, | ||
| 52 | + "tCreateDate": "2026-05-06T10:30:00", | ||
| 53 | + "bDeleted": false | ||
| 54 | + }, | ||
| 55 | + "timestamp": 1746528600000 | ||
| 56 | +} | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +返回 VO(`ModuleVO`)字段:`iIncrement` / `sDisplayType` / `sProcedureName` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` / `tCreateDate` / `bDeleted`。其他标准列(`sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tDeletedDate` / `sDeletedBy`)不对外暴露。 | ||
| 60 | + | ||
| 61 | +## 业务规则 | ||
| 62 | + | ||
| 63 | +1. **唯一性**:`sProcedureName` 在未软删除范围内(`bDeleted=0`)系统内唯一。冲突返回错误码 `40911`。 | ||
| 64 | +2. **父模块校验**:若 `iParentId` 非空,必须指向 `tModule` 中存在且 `bDeleted=0` 的记录;不存在或已删除返回错误码 `40411`。 | ||
| 65 | +3. **bShowPermission 默认值**:未传入或传 `null` → 落库 `0`。 | ||
| 66 | +4. **iSortOrder 默认值**:未传入 → 落库 `0`。 | ||
| 67 | +5. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=now()`(应用层取系统时间,不依赖 DB DEFAULT)。 | ||
| 68 | +6. **多租户字段 (`sBrandsId` / `sSubsidiaryId`)**:本 REQ **不写入**(落库 `NULL`,列已 nullable)。多租户上下文将由 REQ-USR-004 引入登录会话后注入;后续 REQ 通过迁移补齐已有数据。 | ||
| 69 | +7. **`sCreatedBy`**:本 REQ 落库 `NULL`。等待 REQ-USR-004 引入登录用户上下文后从 `SecurityContextHolder` 取当前用户号回写。 | ||
| 70 | +8. **`sId`**(业务 ID 标准列):本 REQ 落库 `NULL`,不在本接口分配。后续若有外部对接需求再以独立 migration 补齐策略。 | ||
| 71 | + | ||
| 72 | +## 边界与约束 | ||
| 73 | + | ||
| 74 | +### 鉴权策略(本 REQ 限定) | ||
| 75 | + | ||
| 76 | +- 项目首个 REQ,尚无 SecurityFilterChain。本 REQ 在 `SecurityConfig` 里配置 `permitAll()` 临时放行所有 `/api/**`。 | ||
| 77 | +- Controller 不写 `@PreAuthorize("hasAuthority('MOD:CREATE')")`(无 SecurityContext 时该注解会因 `Authentication=null` 抛 `AccessDeniedException`);改为在 Controller 上方写一行说明性注释:`// REQ-USR-004 完成后改为 @PreAuthorize("hasAuthority('MOD:CREATE')")`。 | ||
| 78 | +- REQ-USR-004 完成后会回头给本接口加 `@PreAuthorize`,并把 `permitAll` 改为 `authenticated()`。这是已知技术债,**仅在 REQ-USR-004 范围内偿还**,本 REQ 不擅自前置。 | ||
| 79 | + | ||
| 80 | +### 字段长度与字符集 | ||
| 81 | + | ||
| 82 | +- 字符集统一 `utf8mb4`(DDL 已约束),允许中文落库。 | ||
| 83 | +- 字符串字段超出 DDL 长度限制视为参数错误(`40010`),不截断。 | ||
| 84 | + | ||
| 85 | +### 事务 | ||
| 86 | + | ||
| 87 | +- Service 方法标注 `@Transactional(rollbackFor = Exception.class)`,单表写入即可,事务范围最小化。 | ||
| 88 | + | ||
| 89 | +### 性能与并发 | ||
| 90 | + | ||
| 91 | +- 本 REQ 是单条 INSERT,预期无高并发。`uk_procedure_name` 唯一约束兜底并发竞争;唯一冲突映射为 `40911`。 | ||
| 92 | + | ||
| 93 | +### 项目骨架引导(首 REQ 一次性附带) | ||
| 94 | + | ||
| 95 | +本 REQ 顺带建立**最小可运行**的 Spring Boot 项目骨架(仅引入服务于本 REQ 的部分;后续 REQ 按需扩充)。预期新增目录与文件: | ||
| 96 | + | ||
| 97 | +``` | ||
| 98 | +backend/ | ||
| 99 | +├── pom.xml | ||
| 100 | +└── src/main/ | ||
| 101 | + ├── java/com/xly/erp/ | ||
| 102 | + │ ├── ErpApplication.java | ||
| 103 | + │ ├── config/ | ||
| 104 | + │ │ ├── MybatisPlusConfig.java | ||
| 105 | + │ │ └── SecurityConfig.java // 临时 permitAll | ||
| 106 | + │ ├── common/ | ||
| 107 | + │ │ ├── response/ | ||
| 108 | + │ │ │ ├── ApiResponse.java | ||
| 109 | + │ │ │ └── ErrorCode.java | ||
| 110 | + │ │ └── exception/ | ||
| 111 | + │ │ ├── BizException.java | ||
| 112 | + │ │ └── GlobalExceptionHandler.java | ||
| 113 | + │ └── module/mod/ | ||
| 114 | + │ ├── controller/ModuleController.java | ||
| 115 | + │ ├── service/ModuleService.java | ||
| 116 | + │ ├── service/impl/ModuleServiceImpl.java | ||
| 117 | + │ ├── mapper/ModuleMapper.java | ||
| 118 | + │ ├── entity/Module.java | ||
| 119 | + │ ├── dto/ModuleCreateDTO.java | ||
| 120 | + │ └── vo/ModuleVO.java | ||
| 121 | + └── resources/ | ||
| 122 | + ├── application.yml // ${DB_HOST}/${DB_PORT}/... 占位,从 .env.local 注入 | ||
| 123 | + └── mapper/mod/ModuleMapper.xml | ||
| 124 | +``` | ||
| 125 | + | ||
| 126 | +`pom.xml` 依赖:`spring-boot-starter-web` / `-validation` / `-security`、`mybatis-plus-spring-boot3-starter`、`flyway-core` + `flyway-mysql`、`mysql-connector-j`、`mapstruct`、`hutool-all`、`spring-boot-starter-test`、`spring-security-test`。 | ||
| 127 | + | ||
| 128 | +`application.yml` 通过 Spring Profile + `dotenv-java`(或本地启动脚本)加载 `.env.local`;不在仓内硬编码任何凭据(与 `docs/04 § 3.5` 一致)。 | ||
| 129 | + | ||
| 130 | +## 依赖的 schema 表 / 字段 | ||
| 131 | + | ||
| 132 | +**写表**:`tModule`(详见 `docs/03-数据库设计文档.md` § tModule) | ||
| 133 | + | ||
| 134 | +| 字段 | 落库逻辑 | | ||
| 135 | +|---|---| | ||
| 136 | +| `iIncrement` | DB 自增分配 | | ||
| 137 | +| `sId` | 落 `NULL`(本 REQ 不分配业务 ID) | | ||
| 138 | +| `sBrandsId` | 落 `NULL`(多租户 REQ-USR-004 后引入) | | ||
| 139 | +| `sSubsidiaryId` | 落 `NULL`(同上) | | ||
| 140 | +| `tCreateDate` | 应用层 `LocalDateTime.now()` | | ||
| 141 | +| `sDisplayType` | 入参(必填) | | ||
| 142 | +| `sProcedureName` | 入参(必填,唯一) | | ||
| 143 | +| `sModuleType` | 入参(必填) | | ||
| 144 | +| `sManageDeptEn` | 入参(必填) | | ||
| 145 | +| `bShowPermission` | 入参(默认 `false`) | | ||
| 146 | +| `sModuleNameZh` | 入参(必填) | | ||
| 147 | +| `iParentId` | 入参(可选;FK 校验通过的 `tModule.iIncrement`) | | ||
| 148 | +| `iSortOrder` | 入参(默认 `0`) | | ||
| 149 | +| `sCreatedBy` | 落 `NULL`(REQ-USR-004 后引入登录上下文) | | ||
| 150 | +| `bDeleted` | 落 `0` | | ||
| 151 | +| `tDeletedDate` | 落 `NULL` | | ||
| 152 | +| `sDeletedBy` | 落 `NULL` | | ||
| 153 | + | ||
| 154 | +**索引利用**: | ||
| 155 | +- `uk_procedure_name` UNIQUE:唯一性约束触发并发兜底 | ||
| 156 | +- `idx_parent`:父模块查询场景(FK 校验时按 `iParentId` 查 `tModule`) | ||
| 157 | + | ||
| 158 | +**外键**:本 REQ 利用 `fk_module_parent` 作为 DB 层兜底;应用层在写入前先查父模块存在性,提前返回 `40411`,避免直接抛 SQL 完整性异常。 | ||
| 159 | + | ||
| 160 | +## 依赖的接口 | ||
| 161 | + | ||
| 162 | +无(本接口是 module_mod 的入口接口,不依赖其他业务接口)。 | ||
| 163 | + | ||
| 164 | +后续在 REQ-USR-004 完成后,需要回归补齐本接口的 `@PreAuthorize("hasAuthority('MOD:CREATE')")`,并要求请求携带有效 JWT。 | ||
| 165 | + | ||
| 166 | +## 验收标准 | ||
| 167 | + | ||
| 168 | +### 功能正确性 | ||
| 169 | + | ||
| 170 | +1. **正向 — 根模块**:提交完整字段、`iParentId=null`,返回 200 + `data.iIncrement` 非空;DB 中查询新记录字段与入参一致;`bDeleted=0` / `tCreateDate` 已写入。 | ||
| 171 | +2. **正向 — 子模块**:先创建 root 再以其 `iIncrement` 作为 `iParentId` 创建子模块,返回 200。 | ||
| 172 | +3. **唯一性冲突**:用相同 `sProcedureName` 二次提交,返回 `code=40911` + 中文 message;DB 不产生重复记录。 | ||
| 173 | +4. **父模块不存在**:传入 `iParentId=999999`,返回 `code=40411`。 | ||
| 174 | +5. **必填缺失 / 枚举非法 / 长度超限**:返回 `code=40010` + 错误字段名定位。 | ||
| 175 | +6. **可选默认值**:不传 `bShowPermission` / `iSortOrder` → DB 落 `false / 0`。 | ||
| 176 | + | ||
| 177 | +### 接口契约一致性 | ||
| 178 | + | ||
| 179 | +- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。 | ||
| 180 | +- 错误码段位与 docs/05 一致:`200` / `40010` / `40911` / `40411` / `500xx`。 | ||
| 181 | +- 异常堆栈不出现在响应里(GlobalExceptionHandler 拦截并映射为友好错误,docs/04 § 1.4)。 | ||
| 182 | + | ||
| 183 | +### 测试覆盖(feature-tdd 阶段) | ||
| 184 | + | ||
| 185 | +- **单元测试**:`ModuleServiceImpl#create` 覆盖 6 类正/反路径(含唯一冲突的 `DuplicateKeyException` 映射)。 | ||
| 186 | +- **集成测试(MockMvc + 真实 MySQL test schema)**: | ||
| 187 | + - 正向:根模块、子模块创建后查 DB 验证字段 | ||
| 188 | + - 反向:唯一冲突、父模块不存在、必填缺失、枚举非法、长度超限 ≥ 5 个 case | ||
| 189 | + - 并发:两线程同时插入同 `sProcedureName`,断言一条成功 + 一条 40911(可选,若 CI 跑得动) | ||
| 190 | +- **测试数据隔离**:每个测试方法 `@Transactional` 自动回滚;不污染其他测试。 | ||
| 191 | + | ||
| 192 | +### 代码与文档 | ||
| 193 | + | ||
| 194 | +- `// REQ-MOD-001` 注释贴在 Controller / Service / Mapper / DTO / VO 关键类。 | ||
| 195 | +- 提交按 `feat(mod): add module create endpoint REQ-MOD-001` 规范。 | ||
| 196 | +- 不引入 docs/04 § 零 技术栈外的依赖(如需,按软规则 S1 经 AskUserQuestion)。 |
docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-002 — 模块修改 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `PUT /api/modules/{id}` 接口:在不破坏唯一性 / 树结构完整性的前提下,更新已有模块的可编辑字段,返回最新模块 VO。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`PUT /api/modules/{id}`,Content-Type `application/json`。`{id}` = `tModule.iIncrement`。 | ||
| 16 | + | ||
| 17 | +**Request body**(`ModuleUpdateDTO`)字段——与 REQ-MOD-001 输入相比**剥除 `sProcedureName`**(不可改,contract 约束);其余 7 个业务字段含义和校验规则保持一致: | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | | ||
| 20 | +|---|---|---|---|---| | ||
| 21 | +| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` | | ||
| 22 | +| `sModuleType` | String | 是 | 长度 1-50 | `tModule.sModuleType` | | ||
| 23 | +| `sManageDeptEn` | String | 是 | 长度 1-50 | `tModule.sManageDeptEn` | | ||
| 24 | +| `bShowPermission` | Boolean | 否 | 默认保持原值;显式传 `null` 视为不变 | `tModule.bShowPermission` | | ||
| 25 | +| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` | | ||
| 26 | +| `iParentId` | Integer | 否 | 可空(设为根模块);非空必须存在且未软删除;不能等于 `{id}` 自身或其后代 | `tModule.iParentId` | | ||
| 27 | +| `iSortOrder` | Integer | 否 | 默认保持原值;非负整数 | `tModule.iSortOrder` | | ||
| 28 | + | ||
| 29 | +> **`sProcedureName` 不在 DTO 中**:Jackson 反序列化时若客户端误传将被忽略(`@JsonIgnoreProperties(ignoreUnknown = true)` 由 Jackson 默认行为兜底;不抛错)。前端 UI 应把该字段渲染为只读。 | ||
| 30 | +> | ||
| 31 | +> **PUT 语义**:本接口采用全量替换语义。请求体中显式存在的字段均落库;若未提供(JSON 中 key 缺失或值为 `null`),按字段下方"必填"列:必填字段缺失 → `40010`;可选字段缺失 → 保持数据库原值。 | ||
| 32 | + | ||
| 33 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:UPDATE`。本 REQ 沿用 REQ-MOD-001 的 SecurityConfig permitAll 占位(REQ-USR-004 后回头收紧);Controller 写注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 | ||
| 34 | + | ||
| 35 | +## 输出 / 结果 | ||
| 36 | + | ||
| 37 | +**HTTP 200,响应体**(统一响应格式): | ||
| 38 | + | ||
| 39 | +```json | ||
| 40 | +{ | ||
| 41 | + "code": 200, | ||
| 42 | + "message": "操作成功", | ||
| 43 | + "data": { | ||
| 44 | + "iIncrement": 12, | ||
| 45 | + "sDisplayType": "前端业务", | ||
| 46 | + "sProcedureName": "sp_audit_user_module", | ||
| 47 | + "sModuleType": "USR", | ||
| 48 | + "sManageDeptEn": "IT", | ||
| 49 | + "bShowPermission": true, | ||
| 50 | + "sModuleNameZh": "用户管理(修订)", | ||
| 51 | + "iParentId": 3, | ||
| 52 | + "iSortOrder": 5, | ||
| 53 | + "tCreateDate": "2026-05-06T10:30:00", | ||
| 54 | + "bDeleted": false | ||
| 55 | + }, | ||
| 56 | + "timestamp": 1746528600000 | ||
| 57 | +} | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +VO 复用 REQ-MOD-001 的 `ModuleVO`(11 个字段)。 | ||
| 61 | + | ||
| 62 | +## 业务规则 | ||
| 63 | + | ||
| 64 | +1. **目标模块必须存在且未软删除**:`SELECT ... WHERE iIncrement = {id} AND bDeleted = 0`。不存在或已删 → `40421`。 | ||
| 65 | +2. **`sProcedureName` 不可改**:DTO 不接受该字段;后端读取目标记录后保留原 `sProcedureName` 不变。 | ||
| 66 | +3. **`iParentId` 自引用校验**: | ||
| 67 | + - 若 `iParentId` 等于路径参数 `{id}`(自引用)→ `40921`。 | ||
| 68 | + - 若 `iParentId` 在 `tModule` 中不存在或已软删除 → `40411`。 | ||
| 69 | + - 若 `iParentId` 是 `{id}` 的后代(沿 `iParentId` 链向下走,深度上限 5 层与 docs/03 § tModule 业务注记一致)→ `40921`。 | ||
| 70 | +4. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 | ||
| 71 | +5. **`bShowPermission` / `iSortOrder` 部分更新**:DTO 中为 `null` → 保持原值;显式传值 → 覆盖。 | ||
| 72 | +6. **审计**:本 REQ 暂不维护"最近修改时间"和"修改人"列(schema 未规划相关字段,docs/03 也未要求)。后续若需,按 V_n migration 加列同步更新 docs/03。 | ||
| 73 | +7. **多租户字段不写入**:与 REQ-MOD-001 一致,本接口不动 `sBrandsId / sSubsidiaryId`。 | ||
| 74 | + | ||
| 75 | +## 边界与约束 | ||
| 76 | + | ||
| 77 | +### 鉴权策略(本 REQ 限定) | ||
| 78 | + | ||
| 79 | +沿用 REQ-MOD-001:SecurityConfig permitAll;Controller 上写说明性注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 | ||
| 80 | + | ||
| 81 | +### 事务 | ||
| 82 | + | ||
| 83 | +- Service 方法标 `@Transactional(rollbackFor = Exception.class)`。读取目标模块 → 校验 → 更新 全在同一事务。 | ||
| 84 | +- 父模块校验 + 后代环路检查需多次 `selectById`,事务内可能产生几次小查询;本期数据量低,不做缓存优化。 | ||
| 85 | + | ||
| 86 | +### 并发 | ||
| 87 | + | ||
| 88 | +- 用 `moduleMapper.updateById(entity)` 走 PK 更新;不引入乐观锁版本号(schema 没规划 `version` 列)。 | ||
| 89 | +- 并发同时更新同一模块时遵循"后写覆盖"语义,可接受。需要更强一致性时另开 REQ。 | ||
| 90 | + | ||
| 91 | +### 性能 | ||
| 92 | + | ||
| 93 | +- 后代环路检查用迭代 BFS(队列),每次查 `selectList(eq("iParentId", ...))` 拿子节点;深度上限 5 层 + 单层节点数受限于业务,不做递归 SQL。 | ||
| 94 | + | ||
| 95 | +### 错误码映射(与 docs/05 对齐) | ||
| 96 | + | ||
| 97 | +| 场景 | 错误码 | | ||
| 98 | +|---|---| | ||
| 99 | +| 必填字段缺失 / 类型错误 / 长度超限 / 枚举非法 | `40010` | | ||
| 100 | +| `{id}` 模块不存在或已软删除 | `40421` | | ||
| 101 | +| `iParentId` 指向不存在 / 已删模块 | `40411` | | ||
| 102 | +| `iParentId == {id}` 或为 `{id}` 的后代 | `40921` | | ||
| 103 | +| 服务端兜底 | `50000` | | ||
| 104 | + | ||
| 105 | +> docs/05 列出的 `40911`(sProcedureName 冲突)在本实现里不会触发(DTO 不接受 sProcedureName);保留契约文档不变即可。 | ||
| 106 | +> 新增错误码 `40921` 需补到 `ErrorCode` 枚举(命名 `MOD_PARENT_LOOP`);`40421` 命名 `MOD_NOT_FOUND`。 | ||
| 107 | + | ||
| 108 | +## 依赖的 schema 表 / 字段 | ||
| 109 | + | ||
| 110 | +**写表**:`tModule`(详见 docs/03 § tModule) | ||
| 111 | + | ||
| 112 | +| 字段 | 行为 | | ||
| 113 | +|---|---| | ||
| 114 | +| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** | | ||
| 115 | +| `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` | **不修改** | | ||
| 116 | +| `sDisplayType` | 入参覆盖 | | ||
| 117 | +| `sProcedureName` | **不修改**(保留原值) | | ||
| 118 | +| `sModuleType` | 入参覆盖 | | ||
| 119 | +| `sManageDeptEn` | 入参覆盖 | | ||
| 120 | +| `bShowPermission` | 入参非 null 覆盖;null 保留 | | ||
| 121 | +| `sModuleNameZh` | 入参覆盖 | | ||
| 122 | +| `iParentId` | 入参覆盖(含 null 设根) | | ||
| 123 | +| `iSortOrder` | 入参非 null 覆盖;null 保留 | | ||
| 124 | +| `bDeleted` / `tDeletedDate` / `sDeletedBy` | **不修改** | | ||
| 125 | + | ||
| 126 | +**索引利用**: | ||
| 127 | +- 主键定位 `{id}` | ||
| 128 | +- `idx_parent` / `fk_module_parent`:iParentId 校验时按父链 / 子链查询 | ||
| 129 | + | ||
| 130 | +**外键**:`fk_module_parent` 仍兜底;应用层环路检查在写入前显式拦截。 | ||
| 131 | + | ||
| 132 | +## 依赖的接口 | ||
| 133 | + | ||
| 134 | +无(本接口独立工作;与 REQ-MOD-001 并列同模块同 schema)。 | ||
| 135 | + | ||
| 136 | +## 验收标准 | ||
| 137 | + | ||
| 138 | +### 功能正确性 | ||
| 139 | + | ||
| 140 | +1. **正向 — 全量更新非父字段**:传入合法的 7 个字段(不含 `iParentId` 自引用),返回 200 + 最新 VO;DB 中查询新值与入参一致;`sProcedureName` / `tCreateDate` 与原值相同。 | ||
| 141 | +2. **正向 — 设置父模块**:先建 root + child,再 `PUT /api/modules/{child_id}` 把 `iParentId` 改到另一个 sibling;返回 200,DB 中 `iParentId` 更新成功。 | ||
| 142 | +3. **正向 — 清空父模块(设为根)**:`PUT` 时显式传 `"iParentId": null`,DB 中 `iParentId` 变 NULL。 | ||
| 143 | +4. **正向 — 部分字段保留原值**:DTO 中 `bShowPermission` / `iSortOrder` 传 null,DB 中保留原值。 | ||
| 144 | +5. **目标不存在**:`PUT /api/modules/999999`,返回 200 + `code=40421`。 | ||
| 145 | +6. **目标已软删除**:先把模块 `bDeleted` 置 1(直接 DB UPDATE 模拟),再 `PUT`,返回 `40421`。 | ||
| 146 | +7. **必填缺失**:DTO 缺 `sModuleNameZh`,返回 `40010`。 | ||
| 147 | +8. **枚举非法**:`sDisplayType="X"`,返回 `40010`。 | ||
| 148 | +9. **长度超限**:`sModuleType` = 51 字符,返回 `40010`。 | ||
| 149 | +10. **iParentId 自引用**:`PUT /api/modules/{id}` 把 `iParentId` 设为 `{id}` 本身,返回 `40921`。 | ||
| 150 | +11. **iParentId 不存在**:`PUT` 时 `iParentId=999999`,返回 `40411`。 | ||
| 151 | +12. **iParentId 是后代**:祖父→父→子三层结构,`PUT` 祖父把 `iParentId` 设为子的 id,返回 `40921`。 | ||
| 152 | +13. **sProcedureName 字段被忽略**:客户端误传 `sProcedureName="other"`,DB 中该字段保持原值。 | ||
| 153 | + | ||
| 154 | +### 接口契约一致性 | ||
| 155 | + | ||
| 156 | +- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。 | ||
| 157 | +- 错误码段位与 docs/05 一致:`40010` / `40411` / `40421` / `40921` / `50000`。 | ||
| 158 | +- 异常堆栈不出现在响应里。 | ||
| 159 | + | ||
| 160 | +### 测试覆盖 | ||
| 161 | + | ||
| 162 | +- **单元测试** `ModuleServiceImplTest`(继续 mock ModuleMapper): | ||
| 163 | + - update_targetNotFound_throws40421 | ||
| 164 | + - update_targetSoftDeleted_throws40421 | ||
| 165 | + - update_parentSelfReference_throws40921 | ||
| 166 | + - update_parentNotFound_throws40411 | ||
| 167 | + - update_parentIsDescendant_throws40921 | ||
| 168 | + - update_full_returnsVOWithUpdatedFields(断言传给 mapper.updateById 的 entity 字段值,包括 sProcedureName 保留) | ||
| 169 | + - update_partialNullFields_keepsOriginalValues | ||
| 170 | + - update_clearParent_setsParentToNull | ||
| 171 | + | ||
| 172 | +- **集成测试** `ModuleControllerIT` 追加(`@Transactional` 自动回滚;用 ModuleMapper 直接预置数据): | ||
| 173 | + - put_validUpdate_returns200 | ||
| 174 | + - put_setParentToNull_clearsParent | ||
| 175 | + - put_targetNotFound_returns40421 | ||
| 176 | + - put_parentNotFound_returns40411 | ||
| 177 | + - put_parentSelfRef_returns40921 | ||
| 178 | + - put_parentIsDescendant_returns40921 | ||
| 179 | + - put_missingRequired_returns40010 | ||
| 180 | + - put_ignoresProcedureNameField_doesNotChange | ||
| 181 | + | ||
| 182 | +### 代码与文档 | ||
| 183 | + | ||
| 184 | +- `// REQ-MOD-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。 | ||
| 185 | +- 提交按 `feat(mod): <subject> REQ-MOD-002` 规范,每 Task 一个 commit。 | ||
| 186 | +- 不引入 docs/04 § 零 技术栈外的依赖。 |
docs/superpowers/specs/2026-05-06-REQ-MOD-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-003 — 模块删除 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `DELETE /api/modules/{id}` 接口:对指定模块做**软删除**(写入 `bDeleted=1` / `tDeletedDate=now` / `sDeletedBy=NULL`),并在删除前校验子模块引用,避免破坏树结构完整性。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`DELETE /api/modules/{id}`,无请求体。`{id}` = `tModule.iIncrement`。 | ||
| 16 | + | ||
| 17 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:DELETE`。本 REQ 沿用 SecurityConfig permitAll;Controller 上写 Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')")`。 | ||
| 18 | + | ||
| 19 | +## 输出 / 结果 | ||
| 20 | + | ||
| 21 | +**HTTP 200,响应体**: | ||
| 22 | + | ||
| 23 | +```json | ||
| 24 | +{ | ||
| 25 | + "code": 200, | ||
| 26 | + "message": "操作成功", | ||
| 27 | + "data": { | ||
| 28 | + "iIncrement": 12, | ||
| 29 | + "bDeleted": true | ||
| 30 | + }, | ||
| 31 | + "timestamp": 1746528600000 | ||
| 32 | +} | ||
| 33 | +``` | ||
| 34 | + | ||
| 35 | +返回精简 VO:仅 `iIncrement` + `bDeleted`,足以让前端在表格里直接更新该行的删除标记。新增 `ModuleDeleteResultVO` 单独承载该结构(避免复用 `ModuleVO` 暴露不必要字段)。 | ||
| 36 | + | ||
| 37 | +## 业务规则 | ||
| 38 | + | ||
| 39 | +1. **目标存在且未被软删除**:`SELECT iIncrement, bDeleted FROM tModule WHERE iIncrement = {id}`。`null` 或 `bDeleted = 1` → `BizException(MOD_NOT_FOUND)` (40421)。 | ||
| 40 | +2. **子模块引用检查**:`SELECT COUNT(*) FROM tModule WHERE iParentId = {id} AND bDeleted = 0`。`> 0` → `BizException(MOD_HAS_REFERENCES)` (40912)。 | ||
| 41 | +3. **外部业务引用**:本期 schema 无其他业务表通过 FK 引用 `tModule`(V1 SQL 中无外部 FK 指向 tModule)。本 REQ 仅检查子模块;后续若新增模块引用 tModule(如菜单 / 权限分配),需在该模块的 service 层追加引用检查并复用 `MOD_HAS_REFERENCES`。 | ||
| 42 | +4. **软删除字段写入**: | ||
| 43 | + - `bDeleted = 1` | ||
| 44 | + - `tDeletedDate = LocalDateTime.now()` | ||
| 45 | + - `sDeletedBy = NULL`(REQ-USR-004 后由登录上下文回填) | ||
| 46 | + - 其他字段保持原值 | ||
| 47 | +5. **已删除模块不可再删**:bDeleted=1 直接走 40421(与 #1 等效)。 | ||
| 48 | +6. **重复请求语义**:连续两次 DELETE 同一 id,第一次成功(200 + bDeleted=true),第二次 40421。**非幂等**——但响应可预测,不会破坏数据。 | ||
| 49 | +7. **事务边界**:service 方法 `@Transactional`,校验 + 软删除单事务内完成。 | ||
| 50 | + | ||
| 51 | +## 边界与约束 | ||
| 52 | + | ||
| 53 | +### 鉴权策略 | ||
| 54 | + | ||
| 55 | +沿用 REQ-MOD-001/002 SecurityConfig permitAll。 | ||
| 56 | + | ||
| 57 | +### 错误码映射 | ||
| 58 | + | ||
| 59 | +| 场景 | 错误码 | ErrorCode 枚举常量 | | ||
| 60 | +|---|---|---| | ||
| 61 | +| `{id}` 不存在或已软删除 | 40421 | `MOD_NOT_FOUND`(已存在) | | ||
| 62 | +| 存在未软删除子模块 | 40912 | `MOD_HAS_REFERENCES`(**新增**) | | ||
| 63 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 64 | + | ||
| 65 | +### 并发 | ||
| 66 | + | ||
| 67 | +- 用 `moduleMapper.update(entity, wrapper)` 加 `bDeleted = 0` 条件,让"两个并发删除"中只有一个能写成功(影响行数 1),另一个影响 0 行。Service 检查影响行数:`= 0` → 返回 40421(视为已被并发删除)。 | ||
| 68 | +- 不引入乐观锁版本号。 | ||
| 69 | + | ||
| 70 | +### 性能 | ||
| 71 | + | ||
| 72 | +- 子模块计数走 `idx_parent` 索引,O(1)。 | ||
| 73 | + | ||
| 74 | +## 依赖的 schema 表 / 字段 | ||
| 75 | + | ||
| 76 | +**写表**:`tModule` | ||
| 77 | + | ||
| 78 | +| 字段 | 行为 | | ||
| 79 | +|---|---| | ||
| 80 | +| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** | | ||
| 81 | +| `bDeleted` | 0 → 1 | | ||
| 82 | +| `tDeletedDate` | 写入 `LocalDateTime.now()` | | ||
| 83 | +| `sDeletedBy` | 写入 `NULL`(REQ-USR-004 后回填) | | ||
| 84 | +| 其他全部字段 | **不修改** | | ||
| 85 | + | ||
| 86 | +**索引利用**: | ||
| 87 | +- `pk_module`:定位 `{id}` | ||
| 88 | +- `idx_parent`:子模块计数 | ||
| 89 | + | ||
| 90 | +**外键**:本期无其他表 FK 指向 tModule,无需额外检查。 | ||
| 91 | + | ||
| 92 | +## 依赖的接口 | ||
| 93 | + | ||
| 94 | +无(独立接口)。 | ||
| 95 | + | ||
| 96 | +## 验收标准 | ||
| 97 | + | ||
| 98 | +### 功能正确性 | ||
| 99 | + | ||
| 100 | +1. **正向 — 叶子模块删除**:先建一个无子模块的 root,DELETE,返回 200 + `data.iIncrement` + `data.bDeleted=true`。DB 中 `bDeleted=1` / `tDeletedDate` 非空 / `sDeletedBy=NULL` / 其他字段保持原值。 | ||
| 101 | +2. **正向 — 删除后再 GET 列表(REQ-MOD-004)跳过该模块**:本 REQ 不实现 GET,但通过 `selectById` 后断言查询接口的过滤效果。 | ||
| 102 | +3. **目标不存在**:DELETE `/api/modules/999999`,返回 `40421`。 | ||
| 103 | +4. **目标已软删除**:手工 update bDeleted=1 后 DELETE,返回 `40421`。 | ||
| 104 | +5. **存在未删除子模块**:先建 parent + child,DELETE parent,返回 `40912`;DB 中 parent.bDeleted 仍为 0。 | ||
| 105 | +6. **存在已删除子模块(不阻塞)**:先建 parent + child,DELETE child(成功),再 DELETE parent,应成功(已删子模块不计入引用)。 | ||
| 106 | +7. **重复 DELETE**:第二次返回 40421。 | ||
| 107 | +8. **响应 VO 字段精简**:仅含 iIncrement + bDeleted(断言 sDisplayType / sProcedureName 等不在响应里)。 | ||
| 108 | + | ||
| 109 | +### 接口契约一致性 | ||
| 110 | + | ||
| 111 | +- 响应格式 `{code, message, data, timestamp}`。 | ||
| 112 | +- 错误码:200 / 40421 / 40912 / 50000。 | ||
| 113 | +- 不回显堆栈。 | ||
| 114 | + | ||
| 115 | +### 测试覆盖 | ||
| 116 | + | ||
| 117 | +- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper): | ||
| 118 | + - delete_targetNotFound_throws40421 | ||
| 119 | + - delete_targetAlreadyDeleted_throws40421 | ||
| 120 | + - delete_hasUndeletedChildren_throws40912 | ||
| 121 | + - delete_leafModule_writesSoftDeleteFields_returnsResult | ||
| 122 | + - delete_softDeletedChildren_doesNotBlock | ||
| 123 | + - delete_concurrentRace_throws40421(mock update 影响 0 行) | ||
| 124 | + | ||
| 125 | +- **集成测试** `ModuleControllerIT` 追加: | ||
| 126 | + - delete_validLeaf_returns200WithBDeletedTrue(先 mapper.insert,再 DELETE,再 selectById 验证 bDeleted=true / tDeletedDate 非空) | ||
| 127 | + - delete_targetNotFound_returns40421 | ||
| 128 | + - delete_targetAlreadyDeleted_returns40421 | ||
| 129 | + - delete_hasUndeletedChildren_returns40912 | ||
| 130 | + - delete_softDeletedChildren_doesNotBlock_returns200 | ||
| 131 | + - delete_responseVOContainsOnlyIIncrementAndBDeleted | ||
| 132 | + | ||
| 133 | +### 代码与文档 | ||
| 134 | + | ||
| 135 | +- `// REQ-MOD-003 模块删除` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。 | ||
| 136 | +- 提交按 `feat(mod): <subject> REQ-MOD-003` 规范。 | ||
| 137 | +- 不引入 docs/04 § 零 技术栈外的依赖。 |
docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-MOD-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_mod | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-MOD-004 — 模块查询 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `GET /api/modules` 接口:以**树形结构**返回所有未软删除的模块,可选按 `sModuleNameZh` 模糊匹配过滤;过滤命中时同时保留命中节点的所有**祖先路径**以便定位上下文。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`GET /api/modules`,无请求体。 | ||
| 16 | + | ||
| 17 | +**Query parameters**: | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | | ||
| 20 | +|---|---|---|---| | ||
| 21 | +| `keyword` | String | 否 | 长度 ≤ 50;非空时对 `sModuleNameZh` 做 `LIKE '%keyword%'`(不区分大小写——MySQL `utf8mb4_unicode_ci` 默认行为) | | ||
| 22 | + | ||
| 23 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`。 | ||
| 24 | + | ||
| 25 | +## 输出 / 结果 | ||
| 26 | + | ||
| 27 | +**HTTP 200,响应体**: | ||
| 28 | + | ||
| 29 | +```json | ||
| 30 | +{ | ||
| 31 | + "code": 200, | ||
| 32 | + "message": "操作成功", | ||
| 33 | + "data": [ | ||
| 34 | + { | ||
| 35 | + "iIncrement": 1, | ||
| 36 | + "sModuleNameZh": "系统配置", | ||
| 37 | + "sDisplayType": "系统配置", | ||
| 38 | + "sManageDeptEn": "IT", | ||
| 39 | + "iParentId": null, | ||
| 40 | + "iSortOrder": 0, | ||
| 41 | + "children": [ | ||
| 42 | + { | ||
| 43 | + "iIncrement": 2, | ||
| 44 | + "sModuleNameZh": "用户管理", | ||
| 45 | + "sDisplayType": "前端业务", | ||
| 46 | + "sManageDeptEn": "IT", | ||
| 47 | + "iParentId": 1, | ||
| 48 | + "iSortOrder": 1, | ||
| 49 | + "children": [] | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + ], | ||
| 54 | + "timestamp": 1746528600000 | ||
| 55 | +} | ||
| 56 | +``` | ||
| 57 | + | ||
| 58 | +`data` 是根节点数组(`iParentId == null` 的节点);每个节点带 `children` 数组(同结构递归)。 | ||
| 59 | + | ||
| 60 | +新增 VO `ModuleTreeNodeVO`:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `children: List<ModuleTreeNodeVO>`。 | ||
| 61 | + | ||
| 62 | +> **不返回的字段**(避免泄露内部):`sProcedureName` / `sModuleType` / `bShowPermission` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`。如未来 REQ 需要其中字段,再扩 VO。 | ||
| 63 | + | ||
| 64 | +## 业务规则 | ||
| 65 | + | ||
| 66 | +1. **范围过滤**:`bDeleted = 0`(默认仅返回未软删除)。 | ||
| 67 | +2. **空 keyword**:返回所有未软删除模块构成的完整树。 | ||
| 68 | +3. **非空 keyword**: | ||
| 69 | + - 步骤 a:在所有未软删除模块中找出 `sModuleNameZh LIKE '%keyword%'` 的命中集合 `hits`。 | ||
| 70 | + - 步骤 b:对每个 `hit`,沿 `iParentId` 链向上收集所有未软删除祖先(深度上限 5 与 docs/03 § tModule 一致),并入结果集合。 | ||
| 71 | + - 步骤 c:用结果集合在内存中按 `iParentId` 组装树。 | ||
| 72 | + - 命中节点本身的子孙不会被强制纳入(除非也命中);这样避免一次过滤拉出整棵子树。 | ||
| 73 | +4. **排序**:同级节点按 `iSortOrder ASC` 升序,`iSortOrder` 相同则按 `iIncrement ASC`(确定性排序)。 | ||
| 74 | +5. **`children` 字段**:叶子节点为 `[]`,不为 `null`(确保前端可直接 `.map`)。 | ||
| 75 | +6. **空结果**:keyword 无匹配 → `data = []`,**HTTP 200 + code=200**,不返回 404 类错误。 | ||
| 76 | +7. **只读**:本接口不写库,无事务要求;标 `@Transactional(readOnly = true)`。 | ||
| 77 | + | ||
| 78 | +## 边界与约束 | ||
| 79 | + | ||
| 80 | +### 鉴权策略 | ||
| 81 | + | ||
| 82 | +沿用 REQ-MOD-001/002/003 SecurityConfig permitAll。 | ||
| 83 | + | ||
| 84 | +### 错误码 | ||
| 85 | + | ||
| 86 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 87 | +|---|---|---| | ||
| 88 | +| `keyword` 长度 > 50 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 89 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 90 | + | ||
| 91 | +### 性能 | ||
| 92 | + | ||
| 93 | +- 单次 `selectList(LambdaQueryWrapper.eq(bDeleted, false))` 拉取所有未删除模块;spec § 性能上限 docs/03 注明"单次返回不超过 500 项 / 树深度上限 5 层",本期数据量低不分页。 | ||
| 94 | +- 在内存里 O(N) 建索引(id → entity)+ O(N) 建子节点列表 + O(N) 排序 + O(K * D) 沿祖先链向上(K=hits 数,D=深度上限 5)。 | ||
| 95 | +- 不引入 SQL 递归 CTE / 自连接;保持 mapper 层轻量。 | ||
| 96 | + | ||
| 97 | +### 大小写敏感 | ||
| 98 | + | ||
| 99 | +`utf8mb4_unicode_ci` collation 默认不区分大小写。MyBatis-Plus `LambdaQueryWrapper.like(...)` 走 SQL `LIKE`,行为与 collation 一致。无需额外处理。 | ||
| 100 | + | ||
| 101 | +### keyword 中的特殊字符 | ||
| 102 | + | ||
| 103 | +`%` / `_` 在 SQL `LIKE` 模式里是通配符。简化处理:本期不做转义(业务模块名通常不含这些字符);如未来需要,service 层先把 keyword 中的 `%` / `_` / `\` 替换为转义形式(`MyBatis-Plus 5.x` 的 `like` 已自带 escape,本期 3.5.7 需手动)。**本 REQ 暂不处理转义**,作为已知边界记录。 | ||
| 104 | + | ||
| 105 | +## 依赖的 schema 表 / 字段 | ||
| 106 | + | ||
| 107 | +**读表**:`tModule` | ||
| 108 | + | ||
| 109 | +| 字段 | 用途 | | ||
| 110 | +|---|---| | ||
| 111 | +| `iIncrement` | 节点 id;输出 | | ||
| 112 | +| `sModuleNameZh` | 模糊匹配列;输出 | | ||
| 113 | +| `sDisplayType` | 输出 | | ||
| 114 | +| `sManageDeptEn` | 输出 | | ||
| 115 | +| `iParentId` | 父子关系;输出 | | ||
| 116 | +| `iSortOrder` | 排序;输出 | | ||
| 117 | +| `bDeleted` | 过滤未删除(=0) | | ||
| 118 | + | ||
| 119 | +**索引利用**: | ||
| 120 | +- `idx_module_name_zh`:`LIKE '%keyword%'` 实际不走索引(左模糊),但本期数据量低可接受。 | ||
| 121 | +- `idx_parent`:祖先链查询时按 iIncrement PK 走 selectById,不用 idx_parent。 | ||
| 122 | +- `idx_module_deleted`(未单独建,但 bDeleted 在多个 idx 中已有):bDeleted 过滤时 MySQL 优化器自行选择。 | ||
| 123 | + | ||
| 124 | +**外键**:本接口只读,不触发 FK 检查。 | ||
| 125 | + | ||
| 126 | +## 依赖的接口 | ||
| 127 | + | ||
| 128 | +无(独立查询接口)。 | ||
| 129 | + | ||
| 130 | +## 验收标准 | ||
| 131 | + | ||
| 132 | +### 功能正确性 | ||
| 133 | + | ||
| 134 | +1. **正向 — 空 keyword 返回完整树**:DB 中 5 棵根 + 多层子模块;GET 返回 5 个根节点,子孙完整嵌套;同级按 iSortOrder 升序。 | ||
| 135 | +2. **正向 — keyword 模糊匹配 + 祖先**:DB 有 root("系统配置") → 子("用户管理") → 孙("登录");GET `?keyword=登录` 返回 root → 子 → 孙 三层(即命中节点 + 全部祖先)。 | ||
| 136 | +3. **正向 — keyword 部分匹配**:keyword="管理",匹配多个节点;返回它们各自完整祖先链合并的树。 | ||
| 137 | +4. **正向 — 软删除模块过滤**:DELETE 一个模块后再查,结果不含该模块。 | ||
| 138 | +5. **正向 — 空结果**:keyword="不存在的关键词",返回 `data=[]` + code=200。 | ||
| 139 | +6. **正向 — keyword 含中英混合**:keyword="user 用户" 也走 LIKE '%user 用户%'(与 spec § 业务规则 3 一致;不拆词)。 | ||
| 140 | +7. **同级排序**:两根 iSortOrder=2/1,返回顺序 1 在前。 | ||
| 141 | +8. **children 字段**:叶子节点 children 是 `[]` 而非 null(jsonPath 断言)。 | ||
| 142 | +9. **keyword 长度超限**:keyword=51 字符,返回 `code=40010`。 | ||
| 143 | +10. **无登录也可访问**(permitAll 阶段):直接 GET 不带 Authorization 返回 200。 | ||
| 144 | +11. **响应字段精简**:响应不含 sProcedureName / sModuleType / bShowPermission / 标准列等内部字段(jsonPath 断言)。 | ||
| 145 | + | ||
| 146 | +### 接口契约一致性 | ||
| 147 | + | ||
| 148 | +- 响应格式 `{code, message, data, timestamp}`。 | ||
| 149 | +- 错误码 200 / 40010 / 50000。 | ||
| 150 | +- 不回显堆栈。 | ||
| 151 | + | ||
| 152 | +### 测试覆盖 | ||
| 153 | + | ||
| 154 | +- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper): | ||
| 155 | + - tree_emptyDb_returnsEmptyList | ||
| 156 | + - tree_singleRoot_returnsOneNodeWithEmptyChildren | ||
| 157 | + - tree_multiLevel_buildsNestedStructureSortedByISortOrder | ||
| 158 | + - tree_keywordHit_includesAncestorChain | ||
| 159 | + - tree_keywordNoMatch_returnsEmptyList | ||
| 160 | + - tree_softDeletedExcluded(mapper 已过滤;service 不重复过滤) | ||
| 161 | + | ||
| 162 | +- **集成测试** `ModuleControllerIT` 追加: | ||
| 163 | + - get_emptyKeyword_returnsAllUndeletedAsTree | ||
| 164 | + - get_keyword_filtersByModuleNameZhWithAncestors | ||
| 165 | + - get_keywordNoMatch_returnsEmptyArray | ||
| 166 | + - get_keywordTooLong_returns40010 | ||
| 167 | + - get_softDeletedNotInResult | ||
| 168 | + - get_responseExcludesInternalFields | ||
| 169 | + - get_leafNodeChildrenIsEmptyArrayNotNull | ||
| 170 | + | ||
| 171 | +### 代码与文档 | ||
| 172 | + | ||
| 173 | +- `// REQ-MOD-004` 注释贴在 Controller / Service / VO。 | ||
| 174 | +- 提交按 `feat(mod): <subject> REQ-MOD-004` 规范。 |
docs/superpowers/specs/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-001 — 用户新增 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`POST /api/users`,Content-Type `application/json`。 | ||
| 16 | + | ||
| 17 | +**Request body**(`UserCreateDTO`)字段: | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | | ||
| 20 | +|---|---|---|---|---| | ||
| 21 | +| `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` | | ||
| 22 | +| `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` | | ||
| 23 | +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` | | ||
| 24 | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` | | ||
| 25 | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` | | ||
| 26 | +| `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` | | ||
| 27 | +| `permissionCategoryIds` | List<Integer> | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 | | ||
| 28 | + | ||
| 29 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 | ||
| 30 | + | ||
| 31 | +> **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。 | ||
| 32 | + | ||
| 33 | +## 输出 / 结果 | ||
| 34 | + | ||
| 35 | +**HTTP 200,响应体**: | ||
| 36 | + | ||
| 37 | +```json | ||
| 38 | +{ | ||
| 39 | + "code": 200, | ||
| 40 | + "message": "操作成功", | ||
| 41 | + "data": { | ||
| 42 | + "iIncrement": 12, | ||
| 43 | + "sUserNo": "u001", | ||
| 44 | + "sUserName": "alice", | ||
| 45 | + "iStaffId": 7, | ||
| 46 | + "sUserType": "普通用户", | ||
| 47 | + "sLanguage": "zh", | ||
| 48 | + "bCanModifyDocs": false, | ||
| 49 | + "tCreateDate": "2026-05-06T10:30:00", | ||
| 50 | + "bDeleted": false, | ||
| 51 | + "permissionCategoryIds": [1, 2, 3] | ||
| 52 | + }, | ||
| 53 | + "timestamp": 1746528600000 | ||
| 54 | +} | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。 | ||
| 58 | + | ||
| 59 | +**不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。 | ||
| 60 | + | ||
| 61 | +## 业务规则 | ||
| 62 | + | ||
| 63 | +1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。 | ||
| 64 | +2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。 | ||
| 65 | +3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 | ||
| 66 | +4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。 | ||
| 67 | +5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。 | ||
| 68 | +6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。 | ||
| 69 | +7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。 | ||
| 70 | +8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。 | ||
| 71 | + | ||
| 72 | +## 边界与约束 | ||
| 73 | + | ||
| 74 | +### 鉴权策略 | ||
| 75 | + | ||
| 76 | +沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 | ||
| 77 | + | ||
| 78 | +### 错误码映射 | ||
| 79 | + | ||
| 80 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 81 | +|---|---|---| | ||
| 82 | +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 83 | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) | | ||
| 84 | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) | | ||
| 85 | +| `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) | | ||
| 86 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 87 | + | ||
| 88 | +> docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。 | ||
| 89 | + | ||
| 90 | +### 性能 / 并发 | ||
| 91 | + | ||
| 92 | +- 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。 | ||
| 93 | +- `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。 | ||
| 94 | + | ||
| 95 | +### 字符集 / 长度 | ||
| 96 | + | ||
| 97 | +- utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。 | ||
| 98 | +- 长度超 schema 上限 → 视为参数错 40010。 | ||
| 99 | + | ||
| 100 | +### 与 docs/04 § 1.4 / 3.5 一致性 | ||
| 101 | + | ||
| 102 | +- 异常走 GlobalExceptionHandler。 | ||
| 103 | +- BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。 | ||
| 104 | + | ||
| 105 | +### 已知技术债 | ||
| 106 | + | ||
| 107 | +- **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。 | ||
| 108 | +- **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。 | ||
| 109 | + | ||
| 110 | +## 依赖的 schema 表 / 字段 | ||
| 111 | + | ||
| 112 | +**写表**:`tUser`、`tUserPermission` | ||
| 113 | + | ||
| 114 | +**读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典) | ||
| 115 | + | ||
| 116 | +| `tUser` 字段 | 落库逻辑 | | ||
| 117 | +|---|---| | ||
| 118 | +| `iIncrement` | DB AUTO_INCREMENT | | ||
| 119 | +| `sUserNo` / `sUserName` | 入参(必填,唯一) | | ||
| 120 | +| `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) | | ||
| 121 | +| `sUserType` / `sLanguage` | 入参(必填,枚举) | | ||
| 122 | +| `bCanModifyDocs` | 入参(可选,默认 false) | | ||
| 123 | +| `sPasswordHash` | BCrypt("666666") | | ||
| 124 | +| `tCreateDate` | LocalDateTime.now() | | ||
| 125 | +| `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 | | ||
| 126 | + | ||
| 127 | +| `tUserPermission` 字段 | 落库逻辑 | | ||
| 128 | +|---|---| | ||
| 129 | +| `iIncrement` | DB AUTO_INCREMENT | | ||
| 130 | +| `iUserId` | 新用户 iIncrement | | ||
| 131 | +| `iCategoryId` | dto.permissionCategoryIds[i] | | ||
| 132 | +| `tCreateDate` | LocalDateTime.now() | | ||
| 133 | +| `sCreatedBy` | NULL(REQ-USR-004 后回填) | | ||
| 134 | +| 多租户 / `sId` | NULL | | ||
| 135 | + | ||
| 136 | +**索引利用**: | ||
| 137 | +- `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底 | ||
| 138 | +- `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底) | ||
| 139 | + | ||
| 140 | +**外键**: | ||
| 141 | +- `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常 | ||
| 142 | +- `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心 | ||
| 143 | +- `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert | ||
| 144 | + | ||
| 145 | +## 依赖的接口 | ||
| 146 | + | ||
| 147 | +无(本接口独立工作)。 | ||
| 148 | + | ||
| 149 | +REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。 | ||
| 150 | + | ||
| 151 | +## 验收标准 | ||
| 152 | + | ||
| 153 | +### 功能正确性 | ||
| 154 | + | ||
| 155 | +1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。 | ||
| 156 | +2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。 | ||
| 157 | +3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。 | ||
| 158 | +4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。 | ||
| 159 | +5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。 | ||
| 160 | +6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。 | ||
| 161 | +7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。 | ||
| 162 | +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 | ||
| 163 | +9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。 | ||
| 164 | +10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。 | ||
| 165 | +11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。 | ||
| 166 | + | ||
| 167 | +### 接口契约一致性 | ||
| 168 | + | ||
| 169 | +- 响应格式 `{code, message, data, timestamp}`。 | ||
| 170 | +- 不回显堆栈。 | ||
| 171 | + | ||
| 172 | +### 测试覆盖 | ||
| 173 | + | ||
| 174 | +- **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder | ||
| 175 | + - create_minimalFields_returnsVOWithBCryptHash | ||
| 176 | + - create_withStaffAndPermissions_writesAssociation | ||
| 177 | + - create_duplicateUserName_throws40921 | ||
| 178 | + - create_duplicateUserNo_throws40921 | ||
| 179 | + - create_staffNotFound_throws40421 | ||
| 180 | + - create_staffSoftDeleted_throws40421 | ||
| 181 | + - create_permissionCategoryNotFound_throws40422 | ||
| 182 | + - create_emptyPermissionCategoryIds_doesNotInsertAssociation | ||
| 183 | + - create_concurrentDuplicate_dupKeyException_mappedTo40921 | ||
| 184 | +- **集成测试** `UserControllerIT`: | ||
| 185 | + - post_minimalFields_returns200 | ||
| 186 | + - post_withStaffAndPermissions_returns200_andDbAssociated | ||
| 187 | + - post_duplicateUserName_returns40921 | ||
| 188 | + - post_staffNotFound_returns40421 | ||
| 189 | + - post_permissionCategoryNotFound_returns40422 | ||
| 190 | + - post_passwordHashedInDb_notPlaintext | ||
| 191 | + - post_responseExcludesSPasswordHash | ||
| 192 | + | ||
| 193 | +### 代码与文档 | ||
| 194 | + | ||
| 195 | +- `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。 | ||
| 196 | +- 提交按 `feat(usr): <subject> REQ-USR-001` 规范。 | ||
| 197 | +- 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。 |
docs/superpowers/specs/2026-05-06-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-002 — 用户修改 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `PUT /api/users/{id}` 接口:在不破坏唯一性的前提下,更新已有用户的可编辑字段(含权限组关联),返回最新 UserVO(不含 sPasswordHash)。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`PUT /api/users/{id}`,Content-Type `application/json`。`{id}` = `tUser.iIncrement`。 | ||
| 16 | + | ||
| 17 | +**Request body**(`UserUpdateDTO`)字段——与 REQ-USR-001 输入相比**剥除 `sUserNo` / `sUserName`**(不可改,登录身份固定);其余字段均可修改: | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 行为 | | ||
| 20 | +|---|---|---|---|---| | ||
| 21 | +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement`;显式 `null` 表示清空员工关联 | 覆盖 | | ||
| 22 | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | 覆盖 | | ||
| 23 | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | 覆盖 | | ||
| 24 | +| `bCanModifyDocs` | Boolean | 否 | `null` 保持原值;显式覆盖 | 部分更新 | | ||
| 25 | +| `permissionCategoryIds` | List<Integer> | 否 | 每元素必须存在且未软删除;可空数组(清空所有授权) | 重建关联(先删后插,幂等) | | ||
| 26 | + | ||
| 27 | +> **不在 DTO 中**:`sUserNo`(用户号唯一不可改)、`sUserName`(登录账号唯一不可改)、`sPasswordHash`(密码不通过本接口修改)。Jackson 默认忽略未知字段。 | ||
| 28 | +> 前端 UI 应把这些字段渲染为只读。 | ||
| 29 | + | ||
| 30 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:UPDATE`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`。 | ||
| 31 | + | ||
| 32 | +## 输出 / 结果 | ||
| 33 | + | ||
| 34 | +**HTTP 200,响应体**复用 REQ-USR-001 的 `UserVO`(10 个字段含 `permissionCategoryIds`),不暴露 sPasswordHash。 | ||
| 35 | + | ||
| 36 | +```json | ||
| 37 | +{ | ||
| 38 | + "code": 200, | ||
| 39 | + "message": "操作成功", | ||
| 40 | + "data": { | ||
| 41 | + "iIncrement": 12, | ||
| 42 | + "sUserNo": "u001", | ||
| 43 | + "sUserName": "alice", | ||
| 44 | + "iStaffId": 7, | ||
| 45 | + "sUserType": "超级管理员", | ||
| 46 | + "sLanguage": "en", | ||
| 47 | + "bCanModifyDocs": true, | ||
| 48 | + "tCreateDate": "2026-05-06T10:30:00", | ||
| 49 | + "bDeleted": false, | ||
| 50 | + "permissionCategoryIds": [1, 2] | ||
| 51 | + }, | ||
| 52 | + "timestamp": 1746528600000 | ||
| 53 | +} | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +## 业务规则 | ||
| 57 | + | ||
| 58 | +1. **目标用户必须存在且未软删除**:`selectById(id)` 返回 null 或 `bDeleted=1` → `BizException(USR_NOT_FOUND)` (40431)。 | ||
| 59 | +2. **`sUserNo` / `sUserName` / `sPasswordHash` 不可改**:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。 | ||
| 60 | +3. **iStaffId 校验**(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` 不存在或 `bDeleted=true` → `BizException(STAFF_NOT_FOUND)` (40421);显式 `null` 表示清空员工关联,不校验。 | ||
| 61 | +4. **权限分类校验**(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(`selectBatchIds` 一次性查);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 | ||
| 62 | +5. **权限组重建语义**:service 内**先删后插**——先 `userPermissionMapper.delete(eq(iUserId, {id}))` 清空目标用户所有现有关联,再按 `permissionCategoryIds` 顺序插入。空数组 / 不传则只删不插(清空授权)。 | ||
| 63 | +6. **`bCanModifyDocs` / `iStaffId` 部分更新**:DTO 中 `null` 时—— | ||
| 64 | + - `bCanModifyDocs == null` → 保持原值; | ||
| 65 | + - `iStaffId == null` → **显式清空**为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。 | ||
| 66 | +7. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 | ||
| 67 | +8. **审计**:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。 | ||
| 68 | +9. **事务**:`@Transactional(rollbackFor = Exception.class)`,覆盖目标校验 → 父校验 → 权限校验 → user update → tUserPermission delete → tUserPermission insert,整体回滚。 | ||
| 69 | + | ||
| 70 | +## 边界与约束 | ||
| 71 | + | ||
| 72 | +### 鉴权策略 | ||
| 73 | + | ||
| 74 | +沿用 module_mod / REQ-USR-001 SecurityConfig permitAll。 | ||
| 75 | + | ||
| 76 | +### 错误码映射 | ||
| 77 | + | ||
| 78 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 79 | +|---|---|---| | ||
| 80 | +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 81 | +| `{id}` 用户不存在或已软删除 | 40431 | `USR_NOT_FOUND`(**新增**) | | ||
| 82 | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(已存在) | | ||
| 83 | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(已存在) | | ||
| 84 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 85 | + | ||
| 86 | +> docs/05 § REQ-USR-002 中列的 40331(角色变更权限)涉及操作员角色权限模型,需要 SecurityContext + RBAC 才能落地,**REQ-USR-004 后再补**;本 REQ 不实施。 | ||
| 87 | + | ||
| 88 | +### iStaffId 的 NULL 写入 | ||
| 89 | + | ||
| 90 | +借鉴 REQ-MOD-002 经验:MyBatis-Plus 默认 update 跳过 null 字段,需让 `iStaffId` 字段加 `FieldStrategy.IGNORED` 才能把 NULL 写入 SQL。本期在 `UserEntity#iStaffId` 上加 `@TableField(value="iStaffId", updateStrategy=FieldStrategy.IGNORED)`。 | ||
| 91 | + | ||
| 92 | +> 风险与 REQ-MOD-002 相同:未来 partial update 路径会埋雷。本 REQ 走 load-then-modify 全量回填,安全。 | ||
| 93 | + | ||
| 94 | +### 权限组重建的并发 | ||
| 95 | + | ||
| 96 | +- "先删后插"在事务内是原子的;`uk_user_perm` 唯一约束兜底。 | ||
| 97 | +- 同一用户并发修改权限:MySQL 默认行级锁 + 事务隔离 → 后写覆盖(与 REQ-MOD-002 一致)。 | ||
| 98 | + | ||
| 99 | +### 性能 | ||
| 100 | + | ||
| 101 | +- `selectBatchIds` 单次 round-trip 校验 N 个权限分类。 | ||
| 102 | +- `delete + insert` 为 N+1 次 SQL,本期不优化。 | ||
| 103 | + | ||
| 104 | +## 依赖的 schema 表 / 字段 | ||
| 105 | + | ||
| 106 | +**写表**:`tUser`(主体字段更新)、`tUserPermission`(先删后插) | ||
| 107 | + | ||
| 108 | +**读表**:`tStaff`(iStaffId 校验)、`tPermissionCategory`(权限分类校验) | ||
| 109 | + | ||
| 110 | +| `tUser` 字段 | 行为 | | ||
| 111 | +|---|---| | ||
| 112 | +| `iIncrement` / `sUserNo` / `sUserName` / `sPasswordHash` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `sId` / 多租户 / `bDeleted` 三件套 | **不修改** | | ||
| 113 | +| `iStaffId` | 入参覆盖(含 null 设根;需 `FieldStrategy.IGNORED`) | | ||
| 114 | +| `sUserType` / `sLanguage` | 入参覆盖(必填) | | ||
| 115 | +| `bCanModifyDocs` | 入参非 null 覆盖;null 保留 | | ||
| 116 | + | ||
| 117 | +`tUserPermission` 操作:先 `delete(eq(iUserId, {id}))`,再按 `permissionCategoryIds` 顺序 `insert`(每条 `iUserId={id}` / `iCategoryId=...` / `tCreateDate=now`,无 bSelected)。 | ||
| 118 | + | ||
| 119 | +**索引利用**:`uk_user_no` / `uk_user_name`(不会触发,因为本接口不改这两列);`uk_user_perm`(兜底重复授权)。 | ||
| 120 | + | ||
| 121 | +## 依赖的接口 | ||
| 122 | + | ||
| 123 | +无(独立接口;REQ-USR-001 建立的体系完全复用)。 | ||
| 124 | + | ||
| 125 | +## 验收标准 | ||
| 126 | + | ||
| 127 | +### 功能正确性 | ||
| 128 | + | ||
| 129 | +1. **正向 — 全字段更新**:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。 | ||
| 130 | +2. **正向 — 清空 iStaffId**:PUT 时显式 `iStaffId=null`,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。 | ||
| 131 | +3. **正向 — 清空权限组**:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。 | ||
| 132 | +4. **正向 — 保留字段**:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。 | ||
| 133 | +5. **正向 — 部分字段保留原值**:DTO 中 `bCanModifyDocs=null`,DB 保留原值(验证 NOT_NULL 策略生效)。 | ||
| 134 | +6. **目标不存在**:`PUT /api/users/999999`,返回 40431。 | ||
| 135 | +7. **目标已软删除**:先建 user 后置 bDeleted=1,PUT 返回 40431。 | ||
| 136 | +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 | ||
| 137 | +9. **iStaffId 不存在**:iStaffId=999999,返回 40421。 | ||
| 138 | +10. **iStaffId 已软删除**:返回 40421。 | ||
| 139 | +11. **permissionCategoryIds 任一不存在**:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。 | ||
| 140 | +12. **sUserNo / sUserName / sPasswordHash 字段被忽略**:客户端误传 `sUserNo="hijack"` / `sUserName="hijack"` / `sPasswordHash="$2a$10$xxx"`,DB 中这 3 个字段保持原值。 | ||
| 141 | + | ||
| 142 | +### 接口契约一致性 | ||
| 143 | + | ||
| 144 | +- 响应格式 `{code, message, data, timestamp}`。 | ||
| 145 | +- 错误码 200 / 40010 / 40421 / 40422 / 40431 / 50000。 | ||
| 146 | +- 不暴露 sPasswordHash;不回显堆栈。 | ||
| 147 | + | ||
| 148 | +### 测试覆盖 | ||
| 149 | + | ||
| 150 | +- **单元测试** `UserServiceImplTest` 追加(mock 5 个 mapper + PasswordEncoder): | ||
| 151 | + - update_targetNotFound_throws40431 | ||
| 152 | + - update_targetSoftDeleted_throws40431 | ||
| 153 | + - update_staffNotFound_throws40421 | ||
| 154 | + - update_staffSoftDeleted_throws40421 | ||
| 155 | + - update_permissionCategoryNotFound_throws40422 | ||
| 156 | + - update_full_returnsVOWithUpdatedFields_andRebuildsPermissions(捕 ArgumentCaptor,断言 user 字段保留 sUserNo/sUserName/sPasswordHash + 新值字段;断言 userPermissionMapper.delete 调一次 + insert 调 N 次) | ||
| 157 | + - update_partialNullBCanModifyDocs_keepsOriginal | ||
| 158 | + - update_clearStaffId_setsToNull | ||
| 159 | + - update_emptyPermissionCategoryIds_clearsAllAssociations(delete 调一次 + insert 不调) | ||
| 160 | + | ||
| 161 | +- **集成测试** `UserControllerIT` 追加: | ||
| 162 | + - put_validUpdate_returns200_andDbReflects | ||
| 163 | + - put_clearStaffId_setsNull | ||
| 164 | + - put_emptyPermissionCategoryIds_clearsAssociations | ||
| 165 | + - put_targetNotFound_returns40431 | ||
| 166 | + - put_staffNotFound_returns40421 | ||
| 167 | + - put_permissionCategoryNotFound_returns40422 | ||
| 168 | + - put_missingRequired_returns40010 | ||
| 169 | + - put_ignoresProtectedFields_doesNotChangeUserNoOrName(body 含 sUserNo / sUserName / sPasswordHash 但 DB 中这三列保持原值) | ||
| 170 | + | ||
| 171 | +### 代码与文档 | ||
| 172 | + | ||
| 173 | +- `// REQ-USR-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode `USR_NOT_FOUND`。 | ||
| 174 | +- 提交按 `feat(usr): <subject> REQ-USR-002`。 |
docs/superpowers/specs/2026-05-06-REQ-USR-003.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-003 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-003 — 用户查询 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `GET /api/users` 接口:按 `queryField` + `matchType` + `queryValue` 三件套对 `tUser` 进行过滤(含跨表 JOIN `tStaff` 取员工名 / 部门),分页返回精简 VO 列表。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`GET /api/users`,无请求体。 | ||
| 16 | + | ||
| 17 | +**Query parameters**(`UserQueryDTO`): | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | | ||
| 20 | +|---|---|---|---| | ||
| 21 | +| `pageNum` | Integer | 否 | ≥1,默认 1 | | ||
| 22 | +| `pageSize` | Integer | 否 | 1-100,默认 20 | | ||
| 23 | +| `queryField` | String | 否 | 枚举:`username` / `staffname` / `userno` / `department` / `usertype` / `language` / `deleted` / `lastLoginDate` / `createdBy`;缺省视为不过滤 | | ||
| 24 | +| `matchType` | String | 否 | 枚举:`contains` / `notContains` / `equals`;默认 `contains` | | ||
| 25 | +| `queryValue` | String | 否 | 长度 ≤ 100;缺省视为不过滤;`deleted` 字段下视为 `'true' / 'false'` 字符串 | | ||
| 26 | + | ||
| 27 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`。 | ||
| 28 | + | ||
| 29 | +## 输出 / 结果 | ||
| 30 | + | ||
| 31 | +**HTTP 200,响应体**: | ||
| 32 | + | ||
| 33 | +```json | ||
| 34 | +{ | ||
| 35 | + "code": 200, | ||
| 36 | + "message": "操作成功", | ||
| 37 | + "data": { | ||
| 38 | + "total": 42, | ||
| 39 | + "list": [ | ||
| 40 | + { | ||
| 41 | + "iIncrement": 12, | ||
| 42 | + "sUserName": "alice", | ||
| 43 | + "sStaffName": "张三", | ||
| 44 | + "sUserNo": "u001", | ||
| 45 | + "sDepartment": "研发部", | ||
| 46 | + "sUserType": "普通用户", | ||
| 47 | + "sLanguage": "zh", | ||
| 48 | + "bDeleted": false, | ||
| 49 | + "tLastLoginDate": "2026-05-06T09:00:00", | ||
| 50 | + "sCreatedBy": "admin", | ||
| 51 | + "tCreateDate": "2026-05-06T08:00:00" | ||
| 52 | + } | ||
| 53 | + ], | ||
| 54 | + "pageNum": 1, | ||
| 55 | + "pageSize": 20 | ||
| 56 | + }, | ||
| 57 | + "timestamp": 1746528600000 | ||
| 58 | +} | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +新建 `UserListItemVO`(11 字段)+ 复用项目通用 `PageResult<T>`(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。 | ||
| 62 | + | ||
| 63 | +> **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `iStaffId` / `bCanModifyDocs` / `tDeletedDate` / `sDeletedBy`。 | ||
| 64 | + | ||
| 65 | +## 业务规则 | ||
| 66 | + | ||
| 67 | +1. **范围过滤**:默认仅返回 `bDeleted=0` 用户**除非** `queryField=deleted` 且 `queryValue` 显式过滤——见规则 6。 | ||
| 68 | +2. **跨表 JOIN**:`tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0`;空关联字段(无 staff)展示为空字符串 `""`(VO 字段为 null)。 | ||
| 69 | +3. **queryField 列映射**: | ||
| 70 | + | ||
| 71 | + | queryField 值 | SQL 实际列 | | ||
| 72 | + |---|---| | ||
| 73 | + | `username` | `tUser.sUserName` | | ||
| 74 | + | `staffname` | `tStaff.sStaffName` | | ||
| 75 | + | `userno` | `tUser.sUserNo` | | ||
| 76 | + | `department` | `tStaff.sDepartment` | | ||
| 77 | + | `usertype` | `tUser.sUserType` | | ||
| 78 | + | `language` | `tUser.sLanguage` | | ||
| 79 | + | `deleted` | `tUser.bDeleted` | | ||
| 80 | + | `lastLoginDate` | `tUser.tLastLoginDate` | | ||
| 81 | + | `createdBy` | `tUser.sCreatedBy` | | ||
| 82 | + | ||
| 83 | +4. **matchType 映射**: | ||
| 84 | + | ||
| 85 | + | matchType 值 | SQL 片段 | | ||
| 86 | + |---|---| | ||
| 87 | + | `contains`(默认) | `<col> LIKE '%' || queryValue || '%'` | | ||
| 88 | + | `notContains` | `<col> NOT LIKE '%' || queryValue || '%'` | | ||
| 89 | + | `equals` | `<col> = queryValue` | | ||
| 90 | + | ||
| 91 | +5. **空过滤值语义**:`queryField` 或 `queryValue` 缺省 / 空串 → 不附加该 WHERE 条件。 | ||
| 92 | +6. **deleted 字段特殊处理**:当 `queryField=deleted` 时,`bDeleted=0` 的默认过滤不应用(让 `queryValue` 直接控制——`'false'` / `'0'` → bDeleted=0;`'true'` / `'1'` → bDeleted=1)。 | ||
| 93 | +7. **排序**:按 `tUser.tCreateDate DESC, tUser.iIncrement DESC` 稳定排序。 | ||
| 94 | +8. **分页**:MyBatis-Plus `Page<T>` + `selectPage` 标准分页;total 由 MP 自动计数。 | ||
| 95 | +9. **只读**:`@Transactional(readOnly = true)`;不写库。 | ||
| 96 | +10. **空结果**:`data.list = []` + `data.total = 0` + `code=200`,不返回 404。 | ||
| 97 | + | ||
| 98 | +## 边界与约束 | ||
| 99 | + | ||
| 100 | +### 鉴权策略 | ||
| 101 | + | ||
| 102 | +沿用 SecurityConfig permitAll。 | ||
| 103 | + | ||
| 104 | +### 错误码映射 | ||
| 105 | + | ||
| 106 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 107 | +|---|---|---| | ||
| 108 | +| `pageSize` 超 1-100 / `queryField` / `matchType` 非枚举 / `queryValue` 长度超限 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 109 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 110 | + | ||
| 111 | +### 实现路径选择 | ||
| 112 | + | ||
| 113 | +引入**自定义 XML SQL**(`UserMapper.xml`):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 `mapper/usr/UserMapper.xml` 添加 `searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)` 方法,对应 ResultMap 映射到 `UserListItemVO`。 | ||
| 114 | + | ||
| 115 | +XML SQL 草稿(写入 plan 锁定): | ||
| 116 | + | ||
| 117 | +```xml | ||
| 118 | +<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO"> | ||
| 119 | + SELECT | ||
| 120 | + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo, | ||
| 121 | + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted, | ||
| 122 | + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate | ||
| 123 | + FROM tUser u | ||
| 124 | + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0 | ||
| 125 | + <where> | ||
| 126 | + <if test="query.queryField != 'deleted'"> | ||
| 127 | + u.bDeleted = 0 | ||
| 128 | + </if> | ||
| 129 | + <if test="query.queryField != null and query.queryField != '' and query.queryValue != null and query.queryValue != ''"> | ||
| 130 | + AND | ||
| 131 | + <choose> | ||
| 132 | + <when test="query.matchType == 'equals'"> | ||
| 133 | + ${col} = #{query.queryValue} | ||
| 134 | + </when> | ||
| 135 | + <when test="query.matchType == 'notContains'"> | ||
| 136 | + ${col} NOT LIKE CONCAT('%', #{query.queryValue}, '%') | ||
| 137 | + </when> | ||
| 138 | + <otherwise> | ||
| 139 | + ${col} LIKE CONCAT('%', #{query.queryValue}, '%') | ||
| 140 | + </otherwise> | ||
| 141 | + </choose> | ||
| 142 | + </if> | ||
| 143 | + </where> | ||
| 144 | + ORDER BY u.tCreateDate DESC, u.iIncrement DESC | ||
| 145 | +</select> | ||
| 146 | +``` | ||
| 147 | + | ||
| 148 | +> `${col}` 由 service 层通过 enum 映射成实际列名后传入参数(**不是直接拼用户输入**——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。 | ||
| 149 | + | ||
| 150 | +实际实现可能要把 `${col}` 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 `query.setColumn(...)`),XML 用 `${query.column}` 渲染——XMl `${...}` 直接拼接字符串,服务层先白名单校验。 | ||
| 151 | + | ||
| 152 | +### 性能 | ||
| 153 | + | ||
| 154 | +- LEFT JOIN tStaff 走 `tUser.iStaffId` 上的索引(`fk_user_staff`)。 | ||
| 155 | +- `LIKE '%X%'` 左模糊不走索引;本期数据量低可接受。 | ||
| 156 | +- MP `Page<T>` 走 PaginationInnerInterceptor,需要在 `MybatisPlusConfig` 注册(如已存在跳过;如未引入,本 REQ 引入)。 | ||
| 157 | + | ||
| 158 | +### Bean Validation | ||
| 159 | + | ||
| 160 | +`UserQueryDTO` 用 `@Min(1) Integer pageNum`、`@Min(1) @Max(100) Integer pageSize`、`@Pattern` 校验枚举字段。Controller 用 `@Valid`(query 参数 bean 绑定)。 | ||
| 161 | + | ||
| 162 | +## 依赖的 schema 表 / 字段 | ||
| 163 | + | ||
| 164 | +**读表**:`tUser`(主体)+ `tStaff`(LEFT JOIN) | ||
| 165 | + | ||
| 166 | +| `tUser` 字段 | 用途 | | ||
| 167 | +|---|---| | ||
| 168 | +| 全部 11 个输出字段 + `bDeleted` 过滤 | SELECT + WHERE | | ||
| 169 | +| `iStaffId` | LEFT JOIN 键 | | ||
| 170 | + | ||
| 171 | +| `tStaff` 字段 | 用途 | | ||
| 172 | +|---|---| | ||
| 173 | +| `iIncrement` | LEFT JOIN 键 | | ||
| 174 | +| `sStaffName` / `sDepartment` | SELECT + 可选 WHERE | | ||
| 175 | +| `bDeleted` | LEFT JOIN 时附加条件 (s.bDeleted=0) | | ||
| 176 | + | ||
| 177 | +**索引利用**: | ||
| 178 | +- `pk_user`:分页 ORDER BY iIncrement | ||
| 179 | +- `fk_user_staff`(隐式 BTREE on `iStaffId`):JOIN | ||
| 180 | +- `uk_user_no` / `uk_user_name`:equals 匹配能走索引 | ||
| 181 | + | ||
| 182 | +## 依赖的接口 | ||
| 183 | + | ||
| 184 | +无(独立查询接口)。 | ||
| 185 | + | ||
| 186 | +## 验收标准 | ||
| 187 | + | ||
| 188 | +### 功能正确性 | ||
| 189 | + | ||
| 190 | +1. **空查询返回所有未删除用户**:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。 | ||
| 191 | +2. **按用户名 contains 过滤**:DB 有 alice / alex / bob,`queryField=username&matchType=contains&queryValue=al` 返回 alice + alex。 | ||
| 192 | +3. **按员工名 contains 过滤**:通过 LEFT JOIN,匹配 tStaff.sStaffName。 | ||
| 193 | +4. **按部门 equals 过滤**:matchType=equals,命中精确部门。 | ||
| 194 | +5. **deleted=false 过滤**:返回未删除集合。 | ||
| 195 | +6. **deleted=true 过滤**:仅返回已软删除(验证规则 6)。 | ||
| 196 | +7. **notContains 排除**:matchType=notContains,排除指定关键字。 | ||
| 197 | +8. **分页**:pageSize=2,pageNum=1 / 2,断言 list.size + total。 | ||
| 198 | +9. **空结果**:queryValue 完全不匹配,返回 list=[] + total=0。 | ||
| 199 | +10. **响应字段精简**:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。 | ||
| 200 | +11. **未关联 staff 用户**:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。 | ||
| 201 | +12. **pageSize 超限**:pageSize=101,返回 40010。 | ||
| 202 | +13. **queryField 非枚举**:queryField=invalid,返回 40010。 | ||
| 203 | +14. **matchType 非枚举**:返回 40010。 | ||
| 204 | +15. **排序**:tCreateDate DESC,新建在前。 | ||
| 205 | + | ||
| 206 | +### 接口契约一致性 | ||
| 207 | + | ||
| 208 | +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{total, list, pageNum, pageSize}`。 | ||
| 209 | +- 错误码 200 / 40010 / 50000。 | ||
| 210 | +- 不暴露 sPasswordHash 等内部字段。 | ||
| 211 | + | ||
| 212 | +### 测试覆盖 | ||
| 213 | + | ||
| 214 | +- **单元测试** `UserServiceImplTest` 追加(mock UserMapper.searchUsers): | ||
| 215 | + - search_emptyDb_returnsEmptyPage | ||
| 216 | + - search_invalidQueryField_throws40010(service 内白名单校验) | ||
| 217 | + - search_invalidMatchType_throws40010 | ||
| 218 | + - search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射) | ||
| 219 | + | ||
| 220 | +- **集成测试** `UserControllerIT` 追加: | ||
| 221 | + - get_emptyKeyword_returnsAllUndeleted | ||
| 222 | + - get_filterByUsernameContains_returnsMatchedSubset | ||
| 223 | + - get_filterByStaffnameContains_returnsJoinedResults | ||
| 224 | + - get_filterByDeletedTrue_returnsOnlyDeleted | ||
| 225 | + - get_pagination_returnsCorrectSlice | ||
| 226 | + - get_responseExcludesInternalFields | ||
| 227 | + - get_pageSizeTooLarge_returns40010 | ||
| 228 | + - get_invalidQueryField_returns40010 | ||
| 229 | + - get_userWithoutStaff_listItemHasNullStaffFields | ||
| 230 | + | ||
| 231 | +### 代码与文档 | ||
| 232 | + | ||
| 233 | +- `// REQ-USR-003` 注释贴在 Controller / Service 方法 / Mapper.xml 顶部。 | ||
| 234 | +- 提交按 `feat(usr): <subject> REQ-USR-003`。 | ||
| 235 | +- **新增** `MybatisPlusConfig`(如不存在):`PaginationInnerInterceptor` bean 启用 MP 分页。 | ||
| 236 | +- **新增** 通用 `PageResult<T>` VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。 |
docs/superpowers/specs/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-004 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-004 — 用户登录 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `POST /api/auth/login` 接口:用户凭用户名 + 密码(+ 版本)完成身份认证,签发限时 JWT access token + 返回用户基本信息;连续失败时临时锁定账号;登录成功回写 `tLastLoginDate`。 | ||
| 12 | + | ||
| 13 | +## 范围说明 | ||
| 14 | + | ||
| 15 | +本 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 处理。 | ||
| 16 | + | ||
| 17 | +> 锁定失败计数:spec 写明用**内存** `ConcurrentHashMap` 保管;docs/03 § tUser 业务注记声明走 Redis,但本仓库 .env.local 未配 Redis 凭据、pom 也未引 spring-boot-starter-data-redis。本期保留内存实现并在 javadoc / 业务注记中注明"REQ-USR-XXX 引入 Redis 后替换"。 | ||
| 18 | + | ||
| 19 | +## 输入 / 触发 | ||
| 20 | + | ||
| 21 | +**接口**:`POST /api/auth/login`,Content-Type `application/json`。**不需鉴权**(white-list in SecurityConfig)。 | ||
| 22 | + | ||
| 23 | +**Request body**(`LoginDTO`): | ||
| 24 | + | ||
| 25 | +| 字段 | 类型 | 必填 | 校验 | | ||
| 26 | +|---|---|---|---| | ||
| 27 | +| `sUserName` | String | 是 | 长度 1-50 | | ||
| 28 | +| `sPassword` | String | 是 | 长度 1-100(明文 HTTPS 传输;线下 prod 必须 HTTPS) | | ||
| 29 | +| `sVersion` | String | 是 | 枚举:`standard`(仅一个值;REQ 卡片表 1 写"标准版",spec 用 ASCII `standard` 作 SQL/code 友好) | | ||
| 30 | + | ||
| 31 | +## 输出 / 结果 | ||
| 32 | + | ||
| 33 | +**HTTP 200,响应体**(成功): | ||
| 34 | + | ||
| 35 | +```json | ||
| 36 | +{ | ||
| 37 | + "code": 200, | ||
| 38 | + "message": "操作成功", | ||
| 39 | + "data": { | ||
| 40 | + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTc0NjUyODYwMCwiZXhwIjoxNzQ2NTM1ODAwfQ...", | ||
| 41 | + "expiresIn": 7200, | ||
| 42 | + "user": { | ||
| 43 | + "iIncrement": 12, | ||
| 44 | + "sUserNo": "u001", | ||
| 45 | + "sUserName": "alice", | ||
| 46 | + "sUserType": "普通用户", | ||
| 47 | + "sLanguage": "zh" | ||
| 48 | + } | ||
| 49 | + }, | ||
| 50 | + "timestamp": 1746528600000 | ||
| 51 | +} | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +新建 VO `LoginResultVO`:字段 `accessToken` / `expiresIn`(秒)/ `user`(嵌套 `LoginUserInfo`:iIncrement / sUserNo / sUserName / sUserType / sLanguage 5 字段)。 | ||
| 55 | + | ||
| 56 | +**不返回**:`sPasswordHash` / `iStaffId` / `bCanModifyDocs` / `tLastLoginDate` / 多租户字段 / 软删除三件套。`refreshToken` 暂不签发(spec § 范围说明,留作后续)。 | ||
| 57 | + | ||
| 58 | +## 业务规则 | ||
| 59 | + | ||
| 60 | +1. **JWT 签发**:HS256,secret 来自 `.env.local` `JWT_SECRET`(已配置);`expiresIn = 7200`(2 小时);claims:`sub = sUserName`、`iat`、`exp`、`uid = iIncrement`、`type = sUserType`。 | ||
| 61 | +2. **密码校验**:`BCryptPasswordEncoder.matches(sPassword, user.sPasswordHash)`。复用 REQ-USR-001 引入的 PasswordConfig bean。 | ||
| 62 | +3. **目标用户存在 + 未软删**:`SELECT iIncrement, sUserName, sPasswordHash, sUserType, sLanguage, sUserNo, bDeleted FROM tUser WHERE sUserName = ? AND bDeleted = 0`。null → 失败计数 + `40101`(用户名或密码错误,不区分原因避免账号枚举);`bDeleted=1` 同样 40101。 | ||
| 63 | +4. **失败计数 + 锁定**:内存 `Map<String, FailRecord>`(key=sUserName)。`FailRecord{count, firstFailAt, lockUntil}`。 | ||
| 64 | + - 每次密码错误 → `count++`,记 `firstFailAt`(首次失败时间,每个统计窗口)。 | ||
| 65 | + - `count >= 5` 且未锁定 → 设 `lockUntil = now + 15min`。 | ||
| 66 | + - 已锁定(`now < lockUntil`)→ 返回 `40301`(账号锁定)+ `data.cooldownSeconds = (lockUntil - now)`。 | ||
| 67 | + - 锁定到期(`now >= lockUntil`)→ reset `count=0`,正常进入校验流程。 | ||
| 68 | + - 登录成功 → `cache.remove(sUserName)`(清失败计数)。 | ||
| 69 | +5. **`sVersion` 校验**:当前仅支持 `standard`(DTO @Pattern + service 层防御)。其他值返回 `40010`。 | ||
| 70 | +6. **`tLastLoginDate` 回写**:成功签发 token 后用 `LambdaUpdateWrapper.set(tLastLoginDate, now)` 显式更新,避免 entity-driven update 触发 iStaffId.IGNORED 副作用。 | ||
| 71 | +7. **审计 / 日志**:登录失败 / 锁定 / 成功均 `log.info` 一条(含 sUserName + 客户端 IP,IP 由 controller 通过 `HttpServletRequest.getRemoteAddr()` 获取并传给 service;REQ 卡片 § 验收提到"防暴力破解"以日志为最低抑制)。 | ||
| 72 | +8. **响应格式**:成功 / 失败统一 `{code, message, data, timestamp}`;data 在失败时为 null(除 40301 外,含 `cooldownSeconds`)。 | ||
| 73 | + | ||
| 74 | +## 边界与约束 | ||
| 75 | + | ||
| 76 | +### 鉴权策略 | ||
| 77 | + | ||
| 78 | +- **本接口** `/api/auth/login`:white-list,permitAll(任何人可调)。 | ||
| 79 | +- **其他既有接口**:本 REQ 不切换为 authenticated,仍保留 permitAll。技术债登记在模块完成报告 § ⑩。 | ||
| 80 | + | ||
| 81 | +### 错误码映射 | ||
| 82 | + | ||
| 83 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 84 | +|---|---|---| | ||
| 85 | +| 必填缺失 / 长度超限 / sVersion 非枚举 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 86 | +| 用户名不存在 / 密码错误 / 用户已软删 | 40101 | `LOGIN_INVALID_CREDENTIALS`(**新增**) | | ||
| 87 | +| 账号被临时锁定 | 40301 | `LOGIN_ACCOUNT_LOCKED`(**新增**) | | ||
| 88 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 89 | + | ||
| 90 | +### Redis 缺位的妥协 | ||
| 91 | + | ||
| 92 | +`InMemoryLoginAttemptStore`(service 层 bean)实现 `LoginAttemptStore` 接口,提供 `recordFailure / clear / isLocked / cooldownSeconds`。后续 REQ 引入 Redis 时新增 `RedisLoginAttemptStore` impl,替换 bean,service 不变。 | ||
| 93 | + | ||
| 94 | +### JWT 库选择 | ||
| 95 | + | ||
| 96 | +引入 `io.jsonwebtoken:jjwt-api / jjwt-impl / jjwt-jackson` 0.12.x(最新稳定)。这属 docs/04 § 零 已列「权限认证 Spring Security / JWT」的具体实现,不触发软规则 S1。 | ||
| 97 | + | ||
| 98 | +### SecurityConfig 改动 | ||
| 99 | + | ||
| 100 | +`SecurityConfig` 在白名单中显式加 `requestMatchers("/api/auth/login").permitAll()`;其他 `anyRequest().permitAll()` 维持原状(与 module_mod / module_usr 已有端点兼容)。 | ||
| 101 | + | ||
| 102 | +### 防暴力破解 | ||
| 103 | + | ||
| 104 | +仅做基础锁定(5 次错误 → 15 分钟锁定)。docs/04 § 1.6 提到 token 生命周期 / 刷新机制 / 密钥管理;本期完成 expires-in、未实施 refreshToken 流转,记入已知 gap。 | ||
| 105 | + | ||
| 106 | +## 依赖的 schema 表 / 字段 | ||
| 107 | + | ||
| 108 | +**读表**:`tUser`(按 sUserName 查 + 校验密码) | ||
| 109 | + | ||
| 110 | +**写表**:`tUser`(仅写 `tLastLoginDate`;其他字段不动) | ||
| 111 | + | ||
| 112 | +| 字段 | 用途 | | ||
| 113 | +|---|---| | ||
| 114 | +| `sUserName` | 主索引(uk_user_name) | | ||
| 115 | +| `sPasswordHash` | BCrypt 校验 | | ||
| 116 | +| `bDeleted` | 过滤 | | ||
| 117 | +| `iIncrement` / `sUserNo` / `sUserType` / `sLanguage` | LoginUserInfo / JWT claims | | ||
| 118 | +| `tLastLoginDate` | 成功登录回写 | | ||
| 119 | + | ||
| 120 | +**索引利用**:`uk_user_name`(UNIQUE,单字段查询 O(1))。 | ||
| 121 | + | ||
| 122 | +**外键 / 关联表**:本接口不用。 | ||
| 123 | + | ||
| 124 | +## 依赖的接口 | ||
| 125 | + | ||
| 126 | +无(独立鉴权入口)。 | ||
| 127 | + | ||
| 128 | +## 验收标准 | ||
| 129 | + | ||
| 130 | +### 功能正确性 | ||
| 131 | + | ||
| 132 | +1. **正向 — 凭据正确**:先用 REQ-USR-001 创建用户(默认密码 666666),POST /api/auth/login,返回 200 + accessToken(非空)+ expiresIn=7200 + user 含 5 字段;DB 中 tLastLoginDate 已写入。 | ||
| 133 | +2. **JWT 解析**:解析 accessToken(用 JWT_SECRET),断言 claims 含 `sub == sUserName`、`uid == iIncrement`、`type == sUserType`、`exp - iat == 7200`。 | ||
| 134 | +3. **凭据错误 — 用户名不存在**:返回 40101。 | ||
| 135 | +4. **凭据错误 — 密码错误**:返回 40101;DB tLastLoginDate 不更新;内存计数 +1。 | ||
| 136 | +5. **用户已软删**:先创建用户后置 bDeleted=1,登录返回 40101(不区分原因防账号枚举)。 | ||
| 137 | +6. **必填缺失 / sVersion 非枚举**:返回 40010。 | ||
| 138 | +7. **5 次密码错误后锁定**:连续 5 次错误密码登录同一用户名,第 5 次(含)开始返回 40301 + `data.cooldownSeconds > 0`;正确密码也返回 40301(锁定期内一律锁定)。 | ||
| 139 | +8. **锁定后正确密码仍 40301**:锁定状态下传正确密码也拒绝;登录失败和成功都不重置锁定到期时间。 | ||
| 140 | +9. **锁定到期后可登录**:直接修改内存 store 的 lockUntil 至过去,再用正确密码登录,返回 200。 | ||
| 141 | +10. **响应不含 sPasswordHash**:jsonPath 验证 `$.data.user.sPasswordHash` doesNotExist。 | ||
| 142 | + | ||
| 143 | +### 接口契约一致性 | ||
| 144 | + | ||
| 145 | +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{accessToken, expiresIn, user}`。 | ||
| 146 | +- 错误码 200 / 40010 / 40101 / 40301 / 50000。 | ||
| 147 | +- 不暴露 sPasswordHash;不回显堆栈。 | ||
| 148 | + | ||
| 149 | +### 测试覆盖 | ||
| 150 | + | ||
| 151 | +- **单元测试** `LoginServiceImplTest`(mock UserMapper / PasswordEncoder / LoginAttemptStore / JwtTokenProvider): | ||
| 152 | + - login_validCredentials_returnsTokenAndClearsFailCount | ||
| 153 | + - login_userNotFound_returns40101_recordsFailure | ||
| 154 | + - login_userSoftDeleted_returns40101 | ||
| 155 | + - login_passwordMismatch_returns40101_recordsFailure | ||
| 156 | + - login_accountLocked_returns40301_withCooldown | ||
| 157 | + - login_5thFailureTriggersLock | ||
| 158 | + - login_successUpdatesTLastLoginDate | ||
| 159 | + - jwtTokenProvider_signAndParse_returnsClaims(独立 JwtTokenProvider 单元测试) | ||
| 160 | + | ||
| 161 | +- **集成测试** `LoginControllerIT`(@SpringBootTest @Transactional + 真实 PasswordEncoder + 真实 InMemoryLoginAttemptStore,用 @BeforeEach 重置 store): | ||
| 162 | + - login_validCredentials_returns200WithToken | ||
| 163 | + - login_jwtClaimsAreCorrect | ||
| 164 | + - login_invalidUsername_returns40101 | ||
| 165 | + - login_wrongPassword_returns40101 | ||
| 166 | + - login_softDeletedUser_returns40101 | ||
| 167 | + - login_missingPassword_returns40010 | ||
| 168 | + - login_invalidVersion_returns40010 | ||
| 169 | + - login_5thFailureLocks_returns40301 | ||
| 170 | + - login_responseExcludesSPasswordHash | ||
| 171 | + | ||
| 172 | +### 代码与文档 | ||
| 173 | + | ||
| 174 | +- `// REQ-USR-004` 注释贴在 LoginController / LoginService / JwtTokenProvider / LoginAttemptStore / 新增 ErrorCode。 | ||
| 175 | +- 提交按 `feat(usr): <subject> REQ-USR-004` 规范。 | ||
| 176 | +- pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。 |
scripts/test.sh
| @@ -8,6 +8,19 @@ set -euo pipefail | @@ -8,6 +8,19 @@ set -euo pipefail | ||
| 8 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" | 8 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 9 | cd "$PROJECT_ROOT" | 9 | cd "$PROJECT_ROOT" |
| 10 | 10 | ||
| 11 | +# 让 .env.local 注入 JAVA_HOME / DB_* / JWT_SECRET 等运行期变量 | ||
| 12 | +# 由 .githooks/pre-push 触发本脚本时也能拿到 | ||
| 13 | +ENV_FILE="${PROJECT_ROOT}/.env.local" | ||
| 14 | +if [ -f "$ENV_FILE" ]; then | ||
| 15 | + set -a; . "$ENV_FILE"; set +a | ||
| 16 | +fi | ||
| 17 | +if [ -n "${JAVA_HOME:-}" ] && [ -d "$JAVA_HOME" ]; then | ||
| 18 | + export PATH="$JAVA_HOME/bin:$PATH" | ||
| 19 | +fi | ||
| 20 | +if [ -n "${EXTRA_PATH:-}" ]; then | ||
| 21 | + export PATH="$EXTRA_PATH:$PATH" | ||
| 22 | +fi | ||
| 23 | + | ||
| 11 | # Stack detection (runtime, mode-agnostic) | 24 | # Stack detection (runtime, mode-agnostic) |
| 12 | HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 | 25 | HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 |
| 13 | HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 | 26 | HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 |