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
Too many changes to show.
To preserve performance only 68 of 102 files are displayed.
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
| 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.mod.mapper.ModuleMapper"> | |
| 4 | + <!-- REQ-MOD-001 仅使用 BaseMapper 默认 SQL;后续 REQ 按需扩展自定义查询 --> | |
| 5 | +</mapper> | ... | ... |
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 | +} | ... | ... |