Compare View

switch
from
...
to
 
Commits (56)

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
  1 +package com.xly.erp.module.mod.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.mod.entity.ModuleEntity;
  5 +
  6 +public interface ModuleMapper extends BaseMapper<ModuleEntity> {
  7 +}
... ...
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
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  5 +
  6 +public interface PermissionCategoryMapper extends BaseMapper<PermissionCategoryEntity> {
  7 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.StaffEntity;
  5 +
  6 +public interface StaffMapper extends BaseMapper<StaffEntity> {
  7 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  6 +import com.xly.erp.module.usr.entity.UserEntity;
  7 +import com.xly.erp.module.usr.vo.UserListItemVO;
  8 +import org.apache.ibatis.annotations.Param;
  9 +
  10 +public interface UserMapper extends BaseMapper<UserEntity> {
  11 +
  12 + /**
  13 + * REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。
  14 + * @param column service 层白名单映射后的 SQL 列字符串(如 "u.sUserName");外部输入绝不直接走这里。
  15 + */
  16 + IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page,
  17 + @Param("query") UserQueryDTO query,
  18 + @Param("column") String column);
  19 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  5 +
  6 +public interface UserPermissionMapper extends BaseMapper<UserPermissionEntity> {
  7 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import org.springframework.stereotype.Component;
  4 +
  5 +import java.time.Duration;
  6 +import java.time.Instant;
  7 +import java.util.concurrent.ConcurrentHashMap;
  8 +import java.util.concurrent.ConcurrentMap;
  9 +
  10 +/**
  11 + * REQ-USR-004 内存版登录失败计数 / 锁定。
  12 + * 注意:单机有效;多实例部署需要替换为 Redis 实现(后续 REQ)。
  13 + */
  14 +@Component
  15 +public class InMemoryLoginAttemptStore implements LoginAttemptStore {
  16 +
  17 + static final int LOCK_THRESHOLD = 5;
  18 + static final Duration LOCK_DURATION = Duration.ofMinutes(15);
  19 +
  20 + private final ConcurrentMap<String, FailRecord> store = new ConcurrentHashMap<>();
  21 +
  22 + @Override
  23 + public long cooldownSeconds(String username) {
  24 + FailRecord r = store.get(username);
  25 + if (r == null || r.lockUntil == null) {
  26 + return 0L;
  27 + }
  28 + long remaining = r.lockUntil.getEpochSecond() - Instant.now().getEpochSecond();
  29 + if (remaining <= 0L) {
  30 + // 锁定到期 → 清空 record(spec § 业务规则 4 第 4 条:reset count=0)
  31 + store.remove(username);
  32 + return 0L;
  33 + }
  34 + return remaining;
  35 + }
  36 +
  37 + @Override
  38 + public void recordFailure(String username) {
  39 + Instant now = Instant.now();
  40 + store.compute(username, (k, prev) -> {
  41 + // 锁定到期 → reset 重新起算
  42 + FailRecord r = (prev != null && prev.lockUntil != null && now.isAfter(prev.lockUntil))
  43 + ? new FailRecord()
  44 + : (prev == null ? new FailRecord() : prev);
  45 + r.count++;
  46 + if (r.count >= LOCK_THRESHOLD && r.lockUntil == null) {
  47 + r.lockUntil = now.plus(LOCK_DURATION);
  48 + }
  49 + return r;
  50 + });
  51 + }
  52 +
  53 + @Override
  54 + public void clear(String username) {
  55 + store.remove(username);
  56 + }
  57 +
  58 + /** 测试辅助:把锁定到期时间手工拨到过去(验证「锁定到期后可登录」语义)。
  59 + * public 因 LoginControllerIT 跨包调用;命名后缀 ForTest 提示生产代码不应使用。 */
  60 + public void expireLockForTest(String username) {
  61 + store.computeIfPresent(username, (k, r) -> {
  62 + r.lockUntil = Instant.now().minusSeconds(1);
  63 + return r;
  64 + });
  65 + }
  66 +
  67 + static class FailRecord {
  68 + int count;
  69 + Instant lockUntil;
  70 + }
  71 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +import io.jsonwebtoken.Claims;
  4 +import io.jsonwebtoken.Jwts;
  5 +import io.jsonwebtoken.security.Keys;
  6 +import org.springframework.beans.factory.annotation.Value;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +import javax.crypto.SecretKey;
  10 +import java.nio.charset.StandardCharsets;
  11 +import java.time.Instant;
  12 +import java.util.Date;
  13 +
  14 +/** REQ-USR-004 JWT 签发 / 校验封装。HS256,secret 来自 .env.local JWT_SECRET。 */
  15 +@Component
  16 +public class JwtTokenProvider {
  17 +
  18 + private final SecretKey key;
  19 + private final long expiresInSeconds;
  20 +
  21 + public JwtTokenProvider(@Value("${erp.jwt.secret}") String secret,
  22 + @Value("${erp.jwt.expires-in-seconds}") long expiresInSeconds) {
  23 + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
  24 + this.expiresInSeconds = expiresInSeconds;
  25 + }
  26 +
  27 + public long getExpiresInSeconds() {
  28 + return expiresInSeconds;
  29 + }
  30 +
  31 + public String sign(int uid, String username, String userType) {
  32 + Instant now = Instant.now();
  33 + return Jwts.builder()
  34 + .subject(username)
  35 + .claim("uid", uid)
  36 + .claim("type", userType)
  37 + .issuedAt(Date.from(now))
  38 + .expiration(Date.from(now.plusSeconds(expiresInSeconds)))
  39 + .signWith(key)
  40 + .compact();
  41 + }
  42 +
  43 + public Claims parse(String token) {
  44 + return Jwts.parser()
  45 + .verifyWith(key)
  46 + .build()
  47 + .parseSignedClaims(token)
  48 + .getPayload();
  49 + }
  50 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java 0 → 100644
  1 +package com.xly.erp.module.usr.security;
  2 +
  3 +/** REQ-USR-004 登录失败计数 / 锁定接口;本期 InMemory 实现,后续 REQ 用 Redis 替换。 */
  4 +public interface LoginAttemptStore {
  5 +
  6 + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */
  7 + long cooldownSeconds(String username);
  8 +
  9 + /** 记录一次失败:count++;count==5 触发 15min 锁定 */
  10 + void recordFailure(String username);
  11 +
  12 + /** 登录成功清空记录 */
  13 + void clear(String username);
  14 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.dto.LoginDTO;
  4 +import com.xly.erp.module.usr.vo.LoginResultVO;
  5 +
  6 +public interface LoginService {
  7 + /** REQ-USR-004 用户登录 */
  8 + LoginResultVO login(LoginDTO dto);
  9 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.response.PageResult;
  4 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  5 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  6 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  7 +import com.xly.erp.module.usr.vo.UserListItemVO;
  8 +import com.xly.erp.module.usr.vo.UserVO;
  9 +
  10 +public interface UserService {
  11 + /** REQ-USR-001 用户新增 */
  12 + UserVO create(UserCreateDTO dto);
  13 +
  14 + /** REQ-USR-002 用户修改 */
  15 + UserVO update(Integer id, UserUpdateDTO dto);
  16 +
  17 + /** REQ-USR-003 用户列表查询 */
  18 + PageResult<UserListItemVO> search(UserQueryDTO query);
  19 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  5 +import com.xly.erp.common.exception.AccountLockedException;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.module.usr.dto.LoginDTO;
  9 +import com.xly.erp.module.usr.entity.UserEntity;
  10 +import com.xly.erp.module.usr.mapper.UserMapper;
  11 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  12 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  13 +import com.xly.erp.module.usr.service.LoginService;
  14 +import com.xly.erp.module.usr.vo.LoginResultVO;
  15 +import lombok.RequiredArgsConstructor;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.security.crypto.password.PasswordEncoder;
  18 +import org.springframework.stereotype.Service;
  19 +import org.springframework.transaction.annotation.Transactional;
  20 +
  21 +import java.time.LocalDateTime;
  22 +
  23 +/** REQ-USR-004 用户登录 service */
  24 +@Slf4j
  25 +@Service
  26 +@RequiredArgsConstructor
  27 +public class LoginServiceImpl implements LoginService {
  28 +
  29 + private final UserMapper userMapper;
  30 + private final PasswordEncoder passwordEncoder;
  31 + private final LoginAttemptStore attemptStore;
  32 + private final JwtTokenProvider jwtTokenProvider;
  33 +
  34 + @Override
  35 + @Transactional(rollbackFor = Exception.class)
  36 + public LoginResultVO login(LoginDTO dto) {
  37 + String username = dto.getSUserName();
  38 +
  39 + // 1. 锁定检查
  40 + long cooldown = attemptStore.cooldownSeconds(username);
  41 + if (cooldown > 0L) {
  42 + log.info("Login locked username={} cooldown={}s", username, cooldown);
  43 + throw new AccountLockedException(cooldown);
  44 + }
  45 +
  46 + // 2. 查用户
  47 + UserEntity user = userMapper.selectOne(
  48 + new LambdaQueryWrapper<UserEntity>()
  49 + .eq(UserEntity::getSUserName, username)
  50 + .eq(UserEntity::getBDeleted, false));
  51 + if (user == null) {
  52 + log.info("Login user-not-found username={}", username);
  53 + attemptStore.recordFailure(username);
  54 + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁)
  55 + long cd = attemptStore.cooldownSeconds(username);
  56 + if (cd > 0L) {
  57 + throw new AccountLockedException(cd);
  58 + }
  59 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  60 + }
  61 +
  62 + // 3. BCrypt 校验
  63 + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) {
  64 + log.info("Login bad-password username={}", username);
  65 + attemptStore.recordFailure(username);
  66 + long cd = attemptStore.cooldownSeconds(username);
  67 + if (cd > 0L) {
  68 + throw new AccountLockedException(cd);
  69 + }
  70 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  71 + }
  72 +
  73 + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate
  74 + attemptStore.clear(username);
  75 + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType());
  76 + userMapper.update(null,
  77 + new LambdaUpdateWrapper<UserEntity>()
  78 + .eq(UserEntity::getIIncrement, user.getIIncrement())
  79 + .set(UserEntity::getTLastLoginDate, LocalDateTime.now()));
  80 + log.info("Login success username={} uid={}", username, user.getIIncrement());
  81 +
  82 + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo(
  83 + user.getIIncrement(), user.getSUserNo(), user.getSUserName(),
  84 + user.getSUserType(), user.getSLanguage());
  85 + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info);
  86 + }
  87 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.common.response.PageResult;
  9 +import com.xly.erp.module.usr.dto.UserCreateDTO;
  10 +import com.xly.erp.module.usr.dto.UserQueryDTO;
  11 +import com.xly.erp.module.usr.dto.UserUpdateDTO;
  12 +import com.xly.erp.module.usr.entity.PermissionCategoryEntity;
  13 +import com.xly.erp.module.usr.entity.StaffEntity;
  14 +import com.xly.erp.module.usr.entity.UserEntity;
  15 +import com.xly.erp.module.usr.entity.UserPermissionEntity;
  16 +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
  17 +import com.xly.erp.module.usr.mapper.StaffMapper;
  18 +import com.xly.erp.module.usr.mapper.UserMapper;
  19 +import com.xly.erp.module.usr.mapper.UserPermissionMapper;
  20 +import com.xly.erp.module.usr.service.UserService;
  21 +import com.xly.erp.module.usr.vo.UserListItemVO;
  22 +import com.xly.erp.module.usr.vo.UserVO;
  23 +import lombok.RequiredArgsConstructor;
  24 +import org.springframework.dao.DuplicateKeyException;
  25 +import org.springframework.security.crypto.password.PasswordEncoder;
  26 +import org.springframework.stereotype.Service;
  27 +import org.springframework.transaction.annotation.Transactional;
  28 +
  29 +import java.time.LocalDateTime;
  30 +import java.util.ArrayList;
  31 +import java.util.List;
  32 +import java.util.Map;
  33 +import java.util.Set;
  34 +
  35 +/** REQ-USR-001 用户新增 */
  36 +@Service
  37 +@RequiredArgsConstructor
  38 +public class UserServiceImpl implements UserService {
  39 +
  40 + private static final String INITIAL_PASSWORD = "666666";
  41 +
  42 + private final UserMapper userMapper;
  43 + private final StaffMapper staffMapper;
  44 + private final PermissionCategoryMapper permissionCategoryMapper;
  45 + private final UserPermissionMapper userPermissionMapper;
  46 + private final PasswordEncoder passwordEncoder;
  47 +
  48 + @Override
  49 + @Transactional(rollbackFor = Exception.class)
  50 + public UserVO create(UserCreateDTO dto) {
  51 + // 1. 唯一性预检:sUserName / sUserNo(bDeleted=0 范围)
  52 + Long existsByName = userMapper.selectCount(
  53 + new LambdaQueryWrapper<UserEntity>()
  54 + .eq(UserEntity::getSUserName, dto.getSUserName())
  55 + .eq(UserEntity::getBDeleted, false));
  56 + if (existsByName != null && existsByName > 0L) {
  57 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  58 + }
  59 + Long existsByNo = userMapper.selectCount(
  60 + new LambdaQueryWrapper<UserEntity>()
  61 + .eq(UserEntity::getSUserNo, dto.getSUserNo())
  62 + .eq(UserEntity::getBDeleted, false));
  63 + if (existsByNo != null && existsByNo > 0L) {
  64 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  65 + }
  66 +
  67 + // 2. iStaffId 校验
  68 + if (dto.getIStaffId() != null) {
  69 + StaffEntity staff = staffMapper.selectById(dto.getIStaffId());
  70 + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) {
  71 + throw new BizException(ErrorCode.STAFF_NOT_FOUND);
  72 + }
  73 + }
  74 +
  75 + // 3. 权限分类校验:批量查;要求每个 id 都存在且未软删除
  76 + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null
  77 + ? new ArrayList<>() : dto.getPermissionCategoryIds();
  78 + if (!categoryIds.isEmpty()) {
  79 + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds);
  80 + if (found.size() != categoryIds.size()
  81 + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) {
  82 + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND);
  83 + }
  84 + }
  85 +
  86 + // 4. 构造 UserEntity 并 insert
  87 + UserEntity user = new UserEntity();
  88 + user.setSUserNo(dto.getSUserNo());
  89 + user.setSUserName(dto.getSUserName());
  90 + user.setIStaffId(dto.getIStaffId());
  91 + user.setSUserType(dto.getSUserType());
  92 + user.setSLanguage(dto.getSLanguage());
  93 + user.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : Boolean.FALSE);
  94 + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD));
  95 + user.setTCreateDate(LocalDateTime.now());
  96 + user.setBDeleted(Boolean.FALSE);
  97 + // tLastLoginDate / sCreatedBy / sBrandsId / sSubsidiaryId / sId / tDeletedDate / sDeletedBy 留 null
  98 +
  99 + try {
  100 + userMapper.insert(user);
  101 + } catch (DuplicateKeyException dup) {
  102 + throw new BizException(ErrorCode.USR_USER_NAME_OR_NO_DUP);
  103 + }
  104 +
  105 + // 5. 批量 insert UserPermission
  106 + for (Integer categoryId : categoryIds) {
  107 + UserPermissionEntity up = new UserPermissionEntity();
  108 + up.setIUserId(user.getIIncrement());
  109 + up.setICategoryId(categoryId);
  110 + up.setTCreateDate(LocalDateTime.now());
  111 + // sCreatedBy 留 null(REQ-USR-004 后回填)
  112 + userPermissionMapper.insert(up);
  113 + }
  114 +
  115 + return UserVO.from(user, categoryIds);
  116 + }
  117 +
  118 + /** REQ-USR-002 用户修改 */
  119 + @Override
  120 + @Transactional(rollbackFor = Exception.class)
  121 + public UserVO update(Integer id, UserUpdateDTO dto) {
  122 + // 1. 目标用户存在 + 未软删除
  123 + UserEntity target = userMapper.selectById(id);
  124 + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) {
  125 + throw new BizException(ErrorCode.USR_NOT_FOUND);
  126 + }
  127 +
  128 + // 2. iStaffId 校验(仅当非空)
  129 + if (dto.getIStaffId() != null) {
  130 + StaffEntity staff = staffMapper.selectById(dto.getIStaffId());
  131 + if (staff == null || Boolean.TRUE.equals(staff.getBDeleted())) {
  132 + throw new BizException(ErrorCode.STAFF_NOT_FOUND);
  133 + }
  134 + }
  135 +
  136 + // 3. 权限分类校验(仅当非空)
  137 + List<Integer> categoryIds = dto.getPermissionCategoryIds() == null
  138 + ? new ArrayList<>() : dto.getPermissionCategoryIds();
  139 + if (!categoryIds.isEmpty()) {
  140 + List<PermissionCategoryEntity> found = permissionCategoryMapper.selectBatchIds(categoryIds);
  141 + if (found.size() != categoryIds.size()
  142 + || found.stream().anyMatch(p -> Boolean.TRUE.equals(p.getBDeleted()))) {
  143 + throw new BizException(ErrorCode.PERM_CATEGORY_NOT_FOUND);
  144 + }
  145 + }
  146 +
  147 + // 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash 等保护字段)
  148 + target.setIStaffId(dto.getIStaffId()); // 含 null 清空(依赖 iStaffId.IGNORED 策略)
  149 + target.setSUserType(dto.getSUserType());
  150 + target.setSLanguage(dto.getSLanguage());
  151 + if (dto.getBCanModifyDocs() != null) {
  152 + target.setBCanModifyDocs(dto.getBCanModifyDocs());
  153 + }
  154 +
  155 + // 5. 落库 user
  156 + userMapper.updateById(target);
  157 +
  158 + // 6. 重建权限关联:先删后插(清空原有,再按 dto 插入)
  159 + userPermissionMapper.delete(
  160 + new LambdaQueryWrapper<UserPermissionEntity>()
  161 + .eq(UserPermissionEntity::getIUserId, id));
  162 + for (Integer categoryId : categoryIds) {
  163 + UserPermissionEntity up = new UserPermissionEntity();
  164 + up.setIUserId(id);
  165 + up.setICategoryId(categoryId);
  166 + up.setTCreateDate(LocalDateTime.now());
  167 + userPermissionMapper.insert(up);
  168 + }
  169 +
  170 + return UserVO.from(target, categoryIds);
  171 + }
  172 +
  173 + /** REQ-USR-003 用户列表查询 — queryField 白名单映射 + LEFT JOIN tStaff 分页 */
  174 + private static final Map<String, String> QUERY_COLUMN_MAP = Map.ofEntries(
  175 + Map.entry("username", "u.sUserName"),
  176 + Map.entry("staffname", "s.sStaffName"),
  177 + Map.entry("userno", "u.sUserNo"),
  178 + Map.entry("department", "s.sDepartment"),
  179 + Map.entry("usertype", "u.sUserType"),
  180 + Map.entry("language", "u.sLanguage"),
  181 + Map.entry("deleted", "u.bDeleted"),
  182 + Map.entry("lastLoginDate", "u.tLastLoginDate"),
  183 + Map.entry("createdBy", "u.sCreatedBy"));
  184 +
  185 + private static final Set<String> MATCH_TYPES = Set.of("contains", "notContains", "equals");
  186 +
  187 + @Override
  188 + @Transactional(readOnly = true)
  189 + public PageResult<UserListItemVO> search(UserQueryDTO query) {
  190 + // 1. queryField 白名单 + 列映射(防 SQL 注入)。
  191 + // column 是 service 内部局部变量,通过 mapper @Param("column") 单独传入;不写回 DTO,
  192 + // 避免 GET query-string 绑定(@JsonIgnore 仅对 Jackson 生效,无法防 setter 注入)。
  193 + String column = null;
  194 + if (query.getQueryField() != null && !query.getQueryField().isEmpty()) {
  195 + column = QUERY_COLUMN_MAP.get(query.getQueryField());
  196 + if (column == null) {
  197 + throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField());
  198 + }
  199 + }
  200 +
  201 + // 2. matchType 白名单
  202 + if (query.getMatchType() != null && !query.getMatchType().isEmpty()
  203 + && !MATCH_TYPES.contains(query.getMatchType())) {
  204 + throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType());
  205 + }
  206 +
  207 + // 3. spec § 业务规则 6:deleted 字段值标准化('true'/'1' → '1';'false'/'0' → '0';其它非法)
  208 + if ("deleted".equals(query.getQueryField())
  209 + && query.getQueryValue() != null && !query.getQueryValue().isEmpty()) {
  210 + String v = query.getQueryValue().trim().toLowerCase();
  211 + if ("true".equals(v) || "1".equals(v)) {
  212 + query.setQueryValue("1");
  213 + } else if ("false".equals(v) || "0".equals(v)) {
  214 + query.setQueryValue("0");
  215 + } else {
  216 + throw new BizException(ErrorCode.PARAM_INVALID, "deleted queryValue 仅支持 true/false/1/0");
  217 + }
  218 + }
  219 +
  220 + // 4. 默认值兜底
  221 + int pageNum = query.getPageNum() == null ? 1 : query.getPageNum();
  222 + int pageSize = query.getPageSize() == null ? 20 : query.getPageSize();
  223 +
  224 + // 5. MP 分页查询
  225 + IPage<UserListItemVO> page = new Page<>(pageNum, pageSize);
  226 + IPage<UserListItemVO> result = userMapper.searchUsers(page, query, column);
  227 +
  228 + return PageResult.of(result);
  229 + }
  230 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +/** REQ-USR-004 登录结果 VO(含 JWT + 用户基本信息) */
  8 +@Data
  9 +@NoArgsConstructor
  10 +@AllArgsConstructor
  11 +public class LoginResultVO {
  12 + private String accessToken;
  13 + private long expiresIn;
  14 + private LoginUserInfo user;
  15 +
  16 + @Data
  17 + @NoArgsConstructor
  18 + @AllArgsConstructor
  19 + public static class LoginUserInfo {
  20 + private Integer iIncrement;
  21 + private String sUserNo;
  22 + private String sUserName;
  23 + private String sUserType;
  24 + private String sLanguage;
  25 + }
  26 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.time.LocalDateTime;
  6 +
  7 +/** REQ-USR-003 用户列表行 VO(含 LEFT JOIN tStaff 出来的 sStaffName / sDepartment)。 */
  8 +@Data
  9 +public class UserListItemVO {
  10 + private Integer iIncrement;
  11 + private String sUserName;
  12 + private String sStaffName;
  13 + private String sUserNo;
  14 + private String sDepartment;
  15 + private String sUserType;
  16 + private String sLanguage;
  17 + private Boolean bDeleted;
  18 + private LocalDateTime tLastLoginDate;
  19 + private String sCreatedBy;
  20 + private LocalDateTime tCreateDate;
  21 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.xly.erp.module.usr.entity.UserEntity;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +import java.util.ArrayList;
  8 +import java.util.List;
  9 +
  10 +/** REQ-USR-001 用户 VO(不含 sPasswordHash 等内部字段)。 */
  11 +@Data
  12 +public class UserVO {
  13 + private Integer iIncrement;
  14 + private String sUserNo;
  15 + private String sUserName;
  16 + private Integer iStaffId;
  17 + private String sUserType;
  18 + private String sLanguage;
  19 + private Boolean bCanModifyDocs;
  20 + private LocalDateTime tCreateDate;
  21 + private Boolean bDeleted;
  22 + private List<Integer> permissionCategoryIds = new ArrayList<>();
  23 +
  24 + public static UserVO from(UserEntity e, List<Integer> permissionCategoryIds) {
  25 + UserVO v = new UserVO();
  26 + v.setIIncrement(e.getIIncrement());
  27 + v.setSUserNo(e.getSUserNo());
  28 + v.setSUserName(e.getSUserName());
  29 + v.setIStaffId(e.getIStaffId());
  30 + v.setSUserType(e.getSUserType());
  31 + v.setSLanguage(e.getSLanguage());
  32 + v.setBCanModifyDocs(e.getBCanModifyDocs());
  33 + v.setTCreateDate(e.getTCreateDate());
  34 + v.setBDeleted(e.getBDeleted());
  35 + v.setPermissionCategoryIds(permissionCategoryIds == null ? new ArrayList<>() : permissionCategoryIds);
  36 + return v;
  37 + }
  38 +}
... ...
backend/src/main/resources/application-test.yml 0 → 100644
  1 +spring:
  2 + flyway:
  3 + enabled: true
  4 + locations: filesystem:../sql/migrations
  5 + baseline-on-migrate: true
  6 + baseline-version: 1
  7 + baseline-description: "REQ-MOD-001 baseline (V1 already applied manually in A4)"
... ...
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 +}
... ...