Commit 3bd7a62ada115b4cb1d7d59e8e9fabf1da169bef
merge(usr): integrate module-usr
Showing
86 changed files
with
8449 additions
and
5 deletions
backend/checkstyle.xml
0 → 100644
| 1 | +<?xml version="1.0"?> | |
| 2 | +<!DOCTYPE module PUBLIC | |
| 3 | + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" | |
| 4 | + "https://checkstyle.org/dtds/configuration_1_3.dtd"> | |
| 5 | +<!-- | |
| 6 | + 小羚羊 ERP 后端 Checkstyle 规则(轻量级)。 | |
| 7 | + 仅做基础卫生检查:禁止 import *、禁止 tab、保留必要规约; | |
| 8 | + 不引入会阻塞合法业务代码的严格风格规则。 | |
| 9 | +--> | |
| 10 | +<module name="Checker"> | |
| 11 | + <property name="charset" value="UTF-8"/> | |
| 12 | + <property name="severity" value="error"/> | |
| 13 | + <property name="fileExtensions" value="java"/> | |
| 14 | + | |
| 15 | + <!-- 禁止行尾空白以外的硬性风格,这里只查基本项 --> | |
| 16 | + <module name="FileTabCharacter"> | |
| 17 | + <property name="eachLine" value="true"/> | |
| 18 | + </module> | |
| 19 | + | |
| 20 | + <module name="TreeWalker"> | |
| 21 | + <!-- 禁止通配符 import --> | |
| 22 | + <module name="AvoidStarImport"/> | |
| 23 | + <!-- 禁止未使用 import --> | |
| 24 | + <module name="UnusedImports"/> | |
| 25 | + <!-- 禁止冗余 import --> | |
| 26 | + <module name="RedundantImport"/> | |
| 27 | + <!-- 左大括号风格 --> | |
| 28 | + <module name="LeftCurly"/> | |
| 29 | + <!-- 每条语句必须有大括号 --> | |
| 30 | + <module name="NeedBraces"/> | |
| 31 | + </module> | |
| 32 | +</module> | ... | ... |
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</groupId> | |
| 15 | + <artifactId>erp-backend</artifactId> | |
| 16 | + <version>1.0.0</version> | |
| 17 | + <packaging>jar</packaging> | |
| 18 | + <name>xly-erp-backend</name> | |
| 19 | + <description>小羚羊 ERP 后端服务</description> | |
| 20 | + | |
| 21 | + <properties> | |
| 22 | + <java.version>17</java.version> | |
| 23 | + <maven.compiler.release>17</maven.compiler.release> | |
| 24 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
| 25 | + <mybatis-plus.version>3.5.7</mybatis-plus.version> | |
| 26 | + <flyway.version>10.10.0</flyway.version> | |
| 27 | + <jjwt.version>0.12.5</jjwt.version> | |
| 28 | + <hutool.version>5.8.27</hutool.version> | |
| 29 | + <checkstyle.plugin.version>3.3.1</checkstyle.plugin.version> | |
| 30 | + </properties> | |
| 31 | + | |
| 32 | + <dependencies> | |
| 33 | + <dependency> | |
| 34 | + <groupId>org.springframework.boot</groupId> | |
| 35 | + <artifactId>spring-boot-starter-web</artifactId> | |
| 36 | + </dependency> | |
| 37 | + <dependency> | |
| 38 | + <groupId>org.springframework.boot</groupId> | |
| 39 | + <artifactId>spring-boot-starter-validation</artifactId> | |
| 40 | + </dependency> | |
| 41 | + <dependency> | |
| 42 | + <groupId>org.springframework.boot</groupId> | |
| 43 | + <artifactId>spring-boot-starter-security</artifactId> | |
| 44 | + </dependency> | |
| 45 | + | |
| 46 | + <!-- MyBatis-Plus (Spring Boot 3 starter) --> | |
| 47 | + <dependency> | |
| 48 | + <groupId>com.baomidou</groupId> | |
| 49 | + <artifactId>mybatis-plus-spring-boot3-starter</artifactId> | |
| 50 | + <version>${mybatis-plus.version}</version> | |
| 51 | + </dependency> | |
| 52 | + | |
| 53 | + <!-- MySQL driver --> | |
| 54 | + <dependency> | |
| 55 | + <groupId>com.mysql</groupId> | |
| 56 | + <artifactId>mysql-connector-j</artifactId> | |
| 57 | + <scope>runtime</scope> | |
| 58 | + </dependency> | |
| 59 | + | |
| 60 | + <!-- Flyway --> | |
| 61 | + <dependency> | |
| 62 | + <groupId>org.flywaydb</groupId> | |
| 63 | + <artifactId>flyway-core</artifactId> | |
| 64 | + <version>${flyway.version}</version> | |
| 65 | + </dependency> | |
| 66 | + <dependency> | |
| 67 | + <groupId>org.flywaydb</groupId> | |
| 68 | + <artifactId>flyway-mysql</artifactId> | |
| 69 | + <version>${flyway.version}</version> | |
| 70 | + </dependency> | |
| 71 | + | |
| 72 | + <!-- JWT (jjwt) --> | |
| 73 | + <dependency> | |
| 74 | + <groupId>io.jsonwebtoken</groupId> | |
| 75 | + <artifactId>jjwt-api</artifactId> | |
| 76 | + <version>${jjwt.version}</version> | |
| 77 | + </dependency> | |
| 78 | + <dependency> | |
| 79 | + <groupId>io.jsonwebtoken</groupId> | |
| 80 | + <artifactId>jjwt-impl</artifactId> | |
| 81 | + <version>${jjwt.version}</version> | |
| 82 | + <scope>runtime</scope> | |
| 83 | + </dependency> | |
| 84 | + <dependency> | |
| 85 | + <groupId>io.jsonwebtoken</groupId> | |
| 86 | + <artifactId>jjwt-jackson</artifactId> | |
| 87 | + <version>${jjwt.version}</version> | |
| 88 | + <scope>runtime</scope> | |
| 89 | + </dependency> | |
| 90 | + | |
| 91 | + <!-- Hutool --> | |
| 92 | + <dependency> | |
| 93 | + <groupId>cn.hutool</groupId> | |
| 94 | + <artifactId>hutool-core</artifactId> | |
| 95 | + <version>${hutool.version}</version> | |
| 96 | + </dependency> | |
| 97 | + | |
| 98 | + <!-- Test --> | |
| 99 | + <dependency> | |
| 100 | + <groupId>org.springframework.boot</groupId> | |
| 101 | + <artifactId>spring-boot-starter-test</artifactId> | |
| 102 | + <scope>test</scope> | |
| 103 | + </dependency> | |
| 104 | + <dependency> | |
| 105 | + <groupId>org.springframework.security</groupId> | |
| 106 | + <artifactId>spring-security-test</artifactId> | |
| 107 | + <scope>test</scope> | |
| 108 | + </dependency> | |
| 109 | + </dependencies> | |
| 110 | + | |
| 111 | + <build> | |
| 112 | + <finalName>erp-backend</finalName> | |
| 113 | + <plugins> | |
| 114 | + <plugin> | |
| 115 | + <groupId>org.springframework.boot</groupId> | |
| 116 | + <artifactId>spring-boot-maven-plugin</artifactId> | |
| 117 | + </plugin> | |
| 118 | + <plugin> | |
| 119 | + <groupId>org.apache.maven.plugins</groupId> | |
| 120 | + <artifactId>maven-checkstyle-plugin</artifactId> | |
| 121 | + <version>${checkstyle.plugin.version}</version> | |
| 122 | + <configuration> | |
| 123 | + <configLocation>checkstyle.xml</configLocation> | |
| 124 | + <consoleOutput>true</consoleOutput> | |
| 125 | + <failsOnError>true</failsOnError> | |
| 126 | + <includeTestSourceDirectory>false</includeTestSourceDirectory> | |
| 127 | + </configuration> | |
| 128 | + </plugin> | |
| 129 | + </plugins> | |
| 130 | + </build> | |
| 131 | +</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 | +/** | |
| 8 | + * 小羚羊 ERP 后端启动类。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001: 项目首个后端 REQ,初始化 Maven 骨架与公共基础设施。</p> | |
| 11 | + */ | |
| 12 | +@SpringBootApplication | |
| 13 | +@MapperScan("com.xly.erp.**.mapper") | |
| 14 | +public class ErpApplication { | |
| 15 | + | |
| 16 | + public static void main(String[] args) { | |
| 17 | + SpringApplication.run(ErpApplication.class, args); | |
| 18 | + } | |
| 19 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/base/BaseEntity.java
0 → 100644
| 1 | +package com.xly.erp.common.base; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.FieldFill; | |
| 4 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 6 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 7 | +import java.io.Serializable; | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 标准列公共字段基类(docs/03 标准列 + docs/04 § 3.4)。 | |
| 12 | + * | |
| 13 | + * <p>REQ-USR-001 T3:所有业务实体复用的公共列——整数主键、业务 ID、多租户隔离列、创建时间。 | |
| 14 | + * {@code tCreateDate} 在 INSERT 时由 MybatisPlusConfig 的 MetaObjectHandler 自动填充。</p> | |
| 15 | + */ | |
| 16 | +public abstract class BaseEntity implements Serializable { | |
| 17 | + | |
| 18 | + private static final long serialVersionUID = 1L; | |
| 19 | + | |
| 20 | + /** 整数主键 ID(标准列,自增)。 */ | |
| 21 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 22 | + private Integer iIncrement; | |
| 23 | + | |
| 24 | + /** 业务 ID(标准列,可空)。 */ | |
| 25 | + @TableField("sId") | |
| 26 | + private String sId; | |
| 27 | + | |
| 28 | + /** 品牌 ID,多租户隔离(标准列,DB 默认 1111111111)。 */ | |
| 29 | + @TableField("sBrandsId") | |
| 30 | + private String sBrandsId; | |
| 31 | + | |
| 32 | + /** 子公司 ID,组织层级隔离(标准列,DB 默认 1111111111)。 */ | |
| 33 | + @TableField("sSubsidiaryId") | |
| 34 | + private String sSubsidiaryId; | |
| 35 | + | |
| 36 | + /** 创建时间(标准列,INSERT 自动填充)。 */ | |
| 37 | + @TableField(value = "tCreateDate", fill = FieldFill.INSERT) | |
| 38 | + private LocalDateTime tCreateDate; | |
| 39 | + | |
| 40 | + public Integer getIIncrement() { | |
| 41 | + return iIncrement; | |
| 42 | + } | |
| 43 | + | |
| 44 | + public void setIIncrement(Integer iIncrement) { | |
| 45 | + this.iIncrement = iIncrement; | |
| 46 | + } | |
| 47 | + | |
| 48 | + public String getSId() { | |
| 49 | + return sId; | |
| 50 | + } | |
| 51 | + | |
| 52 | + public void setSId(String sId) { | |
| 53 | + this.sId = sId; | |
| 54 | + } | |
| 55 | + | |
| 56 | + public String getSBrandsId() { | |
| 57 | + return sBrandsId; | |
| 58 | + } | |
| 59 | + | |
| 60 | + public void setSBrandsId(String sBrandsId) { | |
| 61 | + this.sBrandsId = sBrandsId; | |
| 62 | + } | |
| 63 | + | |
| 64 | + public String getSSubsidiaryId() { | |
| 65 | + return sSubsidiaryId; | |
| 66 | + } | |
| 67 | + | |
| 68 | + public void setSSubsidiaryId(String sSubsidiaryId) { | |
| 69 | + this.sSubsidiaryId = sSubsidiaryId; | |
| 70 | + } | |
| 71 | + | |
| 72 | + public LocalDateTime getTCreateDate() { | |
| 73 | + return tCreateDate; | |
| 74 | + } | |
| 75 | + | |
| 76 | + public void setTCreateDate(LocalDateTime tCreateDate) { | |
| 77 | + this.tCreateDate = tCreateDate; | |
| 78 | + } | |
| 79 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.DbType; | |
| 4 | +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; | |
| 5 | +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; | |
| 6 | +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; | |
| 7 | +import java.time.LocalDateTime; | |
| 8 | +import org.apache.ibatis.reflection.MetaObject; | |
| 9 | +import org.springframework.context.annotation.Bean; | |
| 10 | +import org.springframework.context.annotation.Configuration; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * MyBatis-Plus 配置(docs/04 § 3.4)。 | |
| 14 | + * | |
| 15 | + * <p>REQ-USR-001 T3:注册创建时间自动填充处理器(INSERT 填 tCreateDate), | |
| 16 | + * 并注册分页插件供后续 REQ(如 REQ-USR-003 查询)复用。</p> | |
| 17 | + */ | |
| 18 | +@Configuration | |
| 19 | +public class MybatisPlusConfig { | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 分页插件(MySQL)。 | |
| 23 | + */ | |
| 24 | + @Bean | |
| 25 | + public MybatisPlusInterceptor mybatisPlusInterceptor() { | |
| 26 | + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); | |
| 27 | + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); | |
| 28 | + return interceptor; | |
| 29 | + } | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 审计字段自动填充:INSERT 时若 tCreateDate 为空则填当前时间。 | |
| 33 | + */ | |
| 34 | + @Bean | |
| 35 | + public MetaObjectHandler metaObjectHandler() { | |
| 36 | + return new MetaObjectHandler() { | |
| 37 | + @Override | |
| 38 | + public void insertFill(MetaObject metaObject) { | |
| 39 | + strictInsertFill(metaObject, "tCreateDate", LocalDateTime.class, LocalDateTime.now()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Override | |
| 43 | + public void updateFill(MetaObject metaObject) { | |
| 44 | + // 本阶段无更新审计字段需求。 | |
| 45 | + } | |
| 46 | + }; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.security.JwtAuthenticationFilter; | |
| 4 | +import org.springframework.context.annotation.Bean; | |
| 5 | +import org.springframework.context.annotation.Configuration; | |
| 6 | +import org.springframework.http.HttpStatus; | |
| 7 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
| 8 | +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; | |
| 9 | +import org.springframework.security.config.http.SessionCreationPolicy; | |
| 10 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 11 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 12 | +import org.springframework.security.web.SecurityFilterChain; | |
| 13 | +import org.springframework.security.web.authentication.HttpStatusEntryPoint; | |
| 14 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Spring Security 配置(docs/04 § 1.7)。 | |
| 18 | + * | |
| 19 | + * <p>REQ-USR-001 T3:无状态 JWT 认证;放行 /api/usr/login、swagger、actuator/health, | |
| 20 | + * 其余需认证;注册 BCryptPasswordEncoder Bean;JwtAuthenticationFilter 挂在 | |
| 21 | + * UsernamePasswordAuthenticationFilter 之前。</p> | |
| 22 | + */ | |
| 23 | +@Configuration | |
| 24 | +public class SecurityConfig { | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 放行清单(无需 token 即可访问)。 | |
| 28 | + * | |
| 29 | + * <p>REQ-USR-001 放行 /api/usr/login;REQ-USR-004 T5 追加 /api/usr/companies(登录页版本下拉)。 | |
| 30 | + * 抽为常量供单测断言与过滤链共用,避免清单漂移。</p> | |
| 31 | + */ | |
| 32 | + public static final String[] PERMIT_ALL_PATHS = { | |
| 33 | + "/api/usr/login", | |
| 34 | + "/api/usr/companies", | |
| 35 | + "/swagger-ui/**", | |
| 36 | + "/swagger-ui.html", | |
| 37 | + "/v3/api-docs/**", | |
| 38 | + "/actuator/health", | |
| 39 | + }; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 | |
| 43 | + */ | |
| 44 | + @Bean | |
| 45 | + public PasswordEncoder passwordEncoder() { | |
| 46 | + return new BCryptPasswordEncoder(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Bean | |
| 50 | + public SecurityFilterChain securityFilterChain(HttpSecurity http, | |
| 51 | + JwtAuthenticationFilter jwtAuthenticationFilter) | |
| 52 | + throws Exception { | |
| 53 | + http | |
| 54 | + .csrf(AbstractHttpConfigurer::disable) | |
| 55 | + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | |
| 56 | + .exceptionHandling(eh -> eh | |
| 57 | + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) | |
| 58 | + .authorizeHttpRequests(auth -> auth | |
| 59 | + .requestMatchers(PERMIT_ALL_PATHS) | |
| 60 | + .permitAll() | |
| 61 | + .anyRequest().authenticated()) | |
| 62 | + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | |
| 63 | + return http.build(); | |
| 64 | + } | |
| 65 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/exception/BusinessException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ResultCode; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 业务异常(docs/04 § 1.5 SSoT)。 | |
| 7 | + * | |
| 8 | + * <p>REQ-USR-001 T2。业务错误统一抛本异常,由 {@link GlobalExceptionHandler} 捕获转 Result。</p> | |
| 9 | + */ | |
| 10 | +public class BusinessException extends RuntimeException { | |
| 11 | + | |
| 12 | + private static final long serialVersionUID = 1L; | |
| 13 | + | |
| 14 | + private final ResultCode resultCode; | |
| 15 | + | |
| 16 | + public BusinessException(ResultCode resultCode) { | |
| 17 | + super(resultCode.getMessage()); | |
| 18 | + this.resultCode = resultCode; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public BusinessException(ResultCode resultCode, String message) { | |
| 22 | + super(message); | |
| 23 | + this.resultCode = resultCode; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public ResultCode getResultCode() { | |
| 27 | + return resultCode; | |
| 28 | + } | |
| 29 | +} | ... | ... |
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.Result; | |
| 4 | +import com.xly.erp.common.response.ResultCode; | |
| 5 | +import org.slf4j.Logger; | |
| 6 | +import org.slf4j.LoggerFactory; | |
| 7 | +import org.springframework.dao.DuplicateKeyException; | |
| 8 | +import org.springframework.validation.FieldError; | |
| 9 | +import org.springframework.web.bind.MethodArgumentNotValidException; | |
| 10 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 11 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 全局异常处理(docs/04 § 1.5 SSoT)。 | |
| 15 | + * | |
| 16 | + * <p>REQ-USR-001 T2:把 BusinessException / 参数校验失败 / 唯一键冲突 / 未捕获异常 | |
| 17 | + * 统一转为 {@link Result},失败响应不抛栈到前端。</p> | |
| 18 | + */ | |
| 19 | +@RestControllerAdvice | |
| 20 | +public class GlobalExceptionHandler { | |
| 21 | + | |
| 22 | + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * 业务异常 → 对应错误码。 | |
| 26 | + */ | |
| 27 | + @ExceptionHandler(BusinessException.class) | |
| 28 | + public <T> Result<T> handleBusinessException(BusinessException ex) { | |
| 29 | + return Result.fail(ex.getResultCode(), ex.getMessage()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * Bean Validation(@Valid)失败 → 40001,message 取首个字段错误提示。 | |
| 34 | + */ | |
| 35 | + @ExceptionHandler(MethodArgumentNotValidException.class) | |
| 36 | + public <T> Result<T> handleValidationException(MethodArgumentNotValidException ex) { | |
| 37 | + FieldError fieldError = ex.getBindingResult().getFieldError(); | |
| 38 | + String message = fieldError != null | |
| 39 | + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() | |
| 40 | + : ResultCode.PARAM_INVALID.getMessage(); | |
| 41 | + return Result.fail(ResultCode.PARAM_INVALID, message); | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * 唯一键冲突(并发兜底)→ 40901 用户名已存在。 | |
| 46 | + */ | |
| 47 | + @ExceptionHandler(DuplicateKeyException.class) | |
| 48 | + public <T> Result<T> handleDuplicateKeyException(DuplicateKeyException ex) { | |
| 49 | + return Result.fail(ResultCode.USERNAME_EXISTS, ResultCode.USERNAME_EXISTS.getMessage()); | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * 兜底:未捕获异常记录 ERROR 日志(含栈),对外只返回通用错误码,不泄露内部细节。 | |
| 54 | + */ | |
| 55 | + @ExceptionHandler(Exception.class) | |
| 56 | + public <T> Result<T> handleException(Exception ex) { | |
| 57 | + LOG.error("系统异常", ex); | |
| 58 | + return Result.fail(ResultCode.SYSTEM_ERROR, ResultCode.SYSTEM_ERROR.getMessage()); | |
| 59 | + } | |
| 60 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/PageResult.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import java.io.Serializable; | |
| 4 | +import java.util.List; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 通用分页响应体(docs/04 § 1.4 / § 3.2 SSoT)。 | |
| 8 | + * | |
| 9 | + * <p>REQ-USR-003 T1【本 REQ 新增公共契约】。分页接口统一返回 {@code Result<PageResult<T>>}, | |
| 10 | + * 含当前页数据 {@code records}、真实总记录数 {@code total}、当前页号 {@code pageNum} | |
| 11 | + * (数据越界钳制后的实际页号)、每页条数 {@code pageSize}。后续分页 REQ 复用。</p> | |
| 12 | + * | |
| 13 | + * @param <T> 当前页元素类型 | |
| 14 | + */ | |
| 15 | +public class PageResult<T> implements Serializable { | |
| 16 | + | |
| 17 | + private static final long serialVersionUID = 1L; | |
| 18 | + | |
| 19 | + /** 当前页数据。 */ | |
| 20 | + private List<T> records; | |
| 21 | + /** 真实总记录数。 */ | |
| 22 | + private long total; | |
| 23 | + /** 当前页号(数据越界钳制后的实际页号)。 */ | |
| 24 | + private long pageNum; | |
| 25 | + /** 每页条数。 */ | |
| 26 | + private long pageSize; | |
| 27 | + | |
| 28 | + public PageResult() { | |
| 29 | + } | |
| 30 | + | |
| 31 | + public PageResult(List<T> records, long total, long pageNum, long pageSize) { | |
| 32 | + this.records = records; | |
| 33 | + this.total = total; | |
| 34 | + this.pageNum = pageNum; | |
| 35 | + this.pageSize = pageSize; | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 静态工厂:从分页要素装配。 | |
| 40 | + */ | |
| 41 | + public static <T> PageResult<T> of(List<T> records, long total, long pageNum, long pageSize) { | |
| 42 | + return new PageResult<>(records, total, pageNum, pageSize); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public List<T> getRecords() { | |
| 46 | + return records; | |
| 47 | + } | |
| 48 | + | |
| 49 | + public void setRecords(List<T> records) { | |
| 50 | + this.records = records; | |
| 51 | + } | |
| 52 | + | |
| 53 | + public long getTotal() { | |
| 54 | + return total; | |
| 55 | + } | |
| 56 | + | |
| 57 | + public void setTotal(long total) { | |
| 58 | + this.total = total; | |
| 59 | + } | |
| 60 | + | |
| 61 | + public long getPageNum() { | |
| 62 | + return pageNum; | |
| 63 | + } | |
| 64 | + | |
| 65 | + public void setPageNum(long pageNum) { | |
| 66 | + this.pageNum = pageNum; | |
| 67 | + } | |
| 68 | + | |
| 69 | + public long getPageSize() { | |
| 70 | + return pageSize; | |
| 71 | + } | |
| 72 | + | |
| 73 | + public void setPageSize(long pageSize) { | |
| 74 | + this.pageSize = pageSize; | |
| 75 | + } | |
| 76 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/Result.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import java.io.Serializable; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 统一响应体(docs/04 § 1.4 SSoT)。 | |
| 7 | + * | |
| 8 | + * <p>REQ-USR-001 T2。所有接口返回 {@code Result<T>}:{@code code=0} 成功,非 0 为错误码。</p> | |
| 9 | + * | |
| 10 | + * @param <T> 业务数据类型 | |
| 11 | + */ | |
| 12 | +public class Result<T> implements Serializable { | |
| 13 | + | |
| 14 | + private static final long serialVersionUID = 1L; | |
| 15 | + | |
| 16 | + private int code; | |
| 17 | + private String message; | |
| 18 | + private T data; | |
| 19 | + | |
| 20 | + public Result() { | |
| 21 | + } | |
| 22 | + | |
| 23 | + public Result(int code, String message, T data) { | |
| 24 | + this.code = code; | |
| 25 | + this.message = message; | |
| 26 | + this.data = data; | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * 成功且携带数据。 | |
| 31 | + */ | |
| 32 | + public static <T> Result<T> success(T data) { | |
| 33 | + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 成功且不携带数据。 | |
| 38 | + */ | |
| 39 | + public static <T> Result<T> success() { | |
| 40 | + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); | |
| 41 | + } | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * 失败,携带错误码与可读提示。 | |
| 45 | + */ | |
| 46 | + public static <T> Result<T> fail(ResultCode code, String message) { | |
| 47 | + return new Result<>(code.getCode(), message, null); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public int getCode() { | |
| 51 | + return code; | |
| 52 | + } | |
| 53 | + | |
| 54 | + public void setCode(int code) { | |
| 55 | + this.code = code; | |
| 56 | + } | |
| 57 | + | |
| 58 | + public String getMessage() { | |
| 59 | + return message; | |
| 60 | + } | |
| 61 | + | |
| 62 | + public void setMessage(String message) { | |
| 63 | + this.message = message; | |
| 64 | + } | |
| 65 | + | |
| 66 | + public T getData() { | |
| 67 | + return data; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public void setData(T data) { | |
| 71 | + this.data = data; | |
| 72 | + } | |
| 73 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/ResultCode.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 统一错误码枚举(docs/05 / spec § 6 SSoT)。 | |
| 5 | + * | |
| 6 | + * <p>REQ-USR-001 T2:一次性建好本枚举,含本 REQ 使用的 0/40001/40301/40901, | |
| 7 | + * 并预留后续 REQ 复用的 40101/40302/40401/42201,避免后续重复修改公共文件。</p> | |
| 8 | + */ | |
| 9 | +public enum ResultCode { | |
| 10 | + | |
| 11 | + /** 成功。 */ | |
| 12 | + SUCCESS(0, "success"), | |
| 13 | + /** 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 */ | |
| 14 | + PARAM_INVALID(40001, "参数校验失败"), | |
| 15 | + /** 认证失败(用户名或密码错误;预留 REQ-USR-004)。 */ | |
| 16 | + UNAUTHORIZED(40101, "认证失败"), | |
| 17 | + /** 无权限(非管理员调用)。 */ | |
| 18 | + FORBIDDEN(40301, "无权限"), | |
| 19 | + /** 账号已禁用(预留 REQ-USR-004)。 */ | |
| 20 | + ACCOUNT_DISABLED(40302, "账号已禁用"), | |
| 21 | + /** 资源不存在(预留 REQ-USR-002)。 */ | |
| 22 | + NOT_FOUND(40401, "资源不存在"), | |
| 23 | + /** 用户名已存在(sUserName 全局唯一冲突)。 */ | |
| 24 | + USERNAME_EXISTS(40901, "用户名已存在"), | |
| 25 | + /** 分页参数非法(预留 REQ-USR-003)。 */ | |
| 26 | + PAGE_PARAM_INVALID(42201, "分页参数非法"), | |
| 27 | + /** 登录过于频繁(连续失败超阈值,账号临时锁定;REQ-USR-004)。 */ | |
| 28 | + LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试"), | |
| 29 | + /** 系统内部错误(兜底)。 */ | |
| 30 | + SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"); | |
| 31 | + | |
| 32 | + private final int code; | |
| 33 | + private final String message; | |
| 34 | + | |
| 35 | + ResultCode(int code, String message) { | |
| 36 | + this.code = code; | |
| 37 | + this.message = message; | |
| 38 | + } | |
| 39 | + | |
| 40 | + public int getCode() { | |
| 41 | + return code; | |
| 42 | + } | |
| 43 | + | |
| 44 | + public String getMessage() { | |
| 45 | + return message; | |
| 46 | + } | |
| 47 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import jakarta.servlet.FilterChain; | |
| 4 | +import jakarta.servlet.ServletException; | |
| 5 | +import jakarta.servlet.http.HttpServletRequest; | |
| 6 | +import jakarta.servlet.http.HttpServletResponse; | |
| 7 | +import java.io.IOException; | |
| 8 | +import java.util.List; | |
| 9 | +import org.springframework.lang.NonNull; | |
| 10 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
| 11 | +import org.springframework.security.core.authority.SimpleGrantedAuthority; | |
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 13 | +import org.springframework.stereotype.Component; | |
| 14 | +import org.springframework.web.filter.OncePerRequestFilter; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * JWT 认证过滤器。 | |
| 18 | + * | |
| 19 | + * <p>REQ-USR-001 T3:解析 {@code Authorization: Bearer <token>},校验通过后把 | |
| 20 | + * sUserName 作为 principal、{@code TYPE_<sUserType>} 作为 authority 放入 SecurityContext。</p> | |
| 21 | + */ | |
| 22 | +@Component | |
| 23 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | |
| 24 | + | |
| 25 | + private static final String HEADER = "Authorization"; | |
| 26 | + private static final String PREFIX = "Bearer "; | |
| 27 | + | |
| 28 | + private final JwtUtil jwtUtil; | |
| 29 | + | |
| 30 | + public JwtAuthenticationFilter(JwtUtil jwtUtil) { | |
| 31 | + this.jwtUtil = jwtUtil; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + protected void doFilterInternal(@NonNull HttpServletRequest request, | |
| 36 | + @NonNull HttpServletResponse response, | |
| 37 | + @NonNull FilterChain filterChain) | |
| 38 | + throws ServletException, IOException { | |
| 39 | + String header = request.getHeader(HEADER); | |
| 40 | + if (header != null && header.startsWith(PREFIX)) { | |
| 41 | + String token = header.substring(PREFIX.length()); | |
| 42 | + if (jwtUtil.validateToken(token)) { | |
| 43 | + String userName = jwtUtil.getUserName(token); | |
| 44 | + String userType = jwtUtil.getUserType(token); | |
| 45 | + List<SimpleGrantedAuthority> authorities = | |
| 46 | + List.of(new SimpleGrantedAuthority(SecurityUtil.TYPE_PREFIX + userType)); | |
| 47 | + UsernamePasswordAuthenticationToken authentication = | |
| 48 | + new UsernamePasswordAuthenticationToken(userName, null, authorities); | |
| 49 | + SecurityContextHolder.getContext().setAuthentication(authentication); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + filterChain.doFilter(request, response); | |
| 53 | + } | |
| 54 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import io.jsonwebtoken.Claims; | |
| 4 | +import io.jsonwebtoken.Jwts; | |
| 5 | +import io.jsonwebtoken.security.Keys; | |
| 6 | +import java.nio.charset.StandardCharsets; | |
| 7 | +import java.util.Date; | |
| 8 | +import javax.crypto.SecretKey; | |
| 9 | +import org.springframework.beans.factory.annotation.Value; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * JWT 工具(docs/04 § 1.7)。 | |
| 14 | + * | |
| 15 | + * <p>REQ-USR-001 T3:签发 / 解析含 sUserName + sUserType claim 的无状态 token; | |
| 16 | + * 密钥取自 config-vars.yaml secrets.jwt_secret(经 application.yml 注入),禁止硬编码。</p> | |
| 17 | + */ | |
| 18 | +@Component | |
| 19 | +public class JwtUtil { | |
| 20 | + | |
| 21 | + /** claim:用户名。 */ | |
| 22 | + public static final String CLAIM_USER_NAME = "sUserName"; | |
| 23 | + /** claim:用户类型。 */ | |
| 24 | + public static final String CLAIM_USER_TYPE = "sUserType"; | |
| 25 | + | |
| 26 | + private final SecretKey secretKey; | |
| 27 | + private final long expireMillis; | |
| 28 | + | |
| 29 | + public JwtUtil(@Value("${jwt.secret}") String secret, | |
| 30 | + @Value("${jwt.expire-millis}") long expireMillis) { | |
| 31 | + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); | |
| 32 | + this.expireMillis = expireMillis; | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * 签发 token,主体为用户名,并附带用户类型 claim。 | |
| 37 | + */ | |
| 38 | + public String generateToken(String userName, String userType) { | |
| 39 | + Date now = new Date(); | |
| 40 | + Date expiry = new Date(now.getTime() + expireMillis); | |
| 41 | + return Jwts.builder() | |
| 42 | + .subject(userName) | |
| 43 | + .claim(CLAIM_USER_NAME, userName) | |
| 44 | + .claim(CLAIM_USER_TYPE, userType) | |
| 45 | + .issuedAt(now) | |
| 46 | + .expiration(expiry) | |
| 47 | + .signWith(secretKey) | |
| 48 | + .compact(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * 校验 token 签名与有效期;非法 / 过期返回 false。 | |
| 53 | + */ | |
| 54 | + public boolean validateToken(String token) { | |
| 55 | + try { | |
| 56 | + parseClaims(token); | |
| 57 | + return true; | |
| 58 | + } catch (RuntimeException ex) { | |
| 59 | + return false; | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + public String getUserName(String token) { | |
| 64 | + return parseClaims(token).get(CLAIM_USER_NAME, String.class); | |
| 65 | + } | |
| 66 | + | |
| 67 | + public String getUserType(String token) { | |
| 68 | + return parseClaims(token).get(CLAIM_USER_TYPE, String.class); | |
| 69 | + } | |
| 70 | + | |
| 71 | + private Claims parseClaims(String token) { | |
| 72 | + return Jwts.parser() | |
| 73 | + .verifyWith(secretKey) | |
| 74 | + .build() | |
| 75 | + .parseSignedClaims(token) | |
| 76 | + .getPayload(); | |
| 77 | + } | |
| 78 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import org.springframework.security.core.Authentication; | |
| 4 | +import org.springframework.security.core.GrantedAuthority; | |
| 5 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 安全上下文工具:取当前登录用户名 / 用户类型。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001 T3。用户名为 {@link Authentication#getName()}(principal), | |
| 11 | + * 用户类型存放在 authorities 中(以 {@code TYPE_} 前缀标识)。</p> | |
| 12 | + */ | |
| 13 | +public final class SecurityUtil { | |
| 14 | + | |
| 15 | + /** 用户类型 authority 前缀。 */ | |
| 16 | + public static final String TYPE_PREFIX = "TYPE_"; | |
| 17 | + | |
| 18 | + private SecurityUtil() { | |
| 19 | + } | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 当前登录用户名(JWT 主体 sUserName);未认证返回 null。 | |
| 23 | + */ | |
| 24 | + public static String currentUserName() { | |
| 25 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |
| 26 | + if (auth == null || !auth.isAuthenticated()) { | |
| 27 | + return null; | |
| 28 | + } | |
| 29 | + return auth.getName(); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 当前登录用户类型(sUserType,用于管理员判定);取不到返回 null。 | |
| 34 | + */ | |
| 35 | + public static String currentUserType() { | |
| 36 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |
| 37 | + if (auth == null || !auth.isAuthenticated()) { | |
| 38 | + return null; | |
| 39 | + } | |
| 40 | + for (GrantedAuthority authority : auth.getAuthorities()) { | |
| 41 | + String role = authority.getAuthority(); | |
| 42 | + if (role != null && role.startsWith(TYPE_PREFIX)) { | |
| 43 | + return role.substring(TYPE_PREFIX.length()); | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 5 | +import com.xly.erp.modules.usr.service.UsrAuthService; | |
| 6 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 7 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 8 | +import jakarta.validation.Valid; | |
| 9 | +import java.util.List; | |
| 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 | +/** | |
| 17 | + * 认证 Controller(docs/05 REQ-USR-004 / spec § 2)。REQ-USR-004 T5。 | |
| 18 | + * | |
| 19 | + * <p>仅做参数校验 + 委派 Service,不写业务逻辑、不直接调 Mapper、无管理员前置 | |
| 20 | + * (登录 / 公司列表为放行端点)。</p> | |
| 21 | + */ | |
| 22 | +@RestController | |
| 23 | +@RequestMapping("/api/usr") | |
| 24 | +public class UsrAuthController { | |
| 25 | + | |
| 26 | + private final UsrAuthService usrAuthService; | |
| 27 | + | |
| 28 | + public UsrAuthController(UsrAuthService usrAuthService) { | |
| 29 | + this.usrAuthService = usrAuthService; | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 登录认证(放行,无需 token):@Valid 校验入参后委派 Service。 | |
| 34 | + */ | |
| 35 | + @PostMapping("/login") | |
| 36 | + public Result<LoginVO> login(@Valid @RequestBody LoginDTO dto) { | |
| 37 | + return Result.success(usrAuthService.login(dto)); | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 公司下拉列表(放行,无参):供登录页「版本」下拉。 | |
| 42 | + */ | |
| 43 | + @GetMapping("/companies") | |
| 44 | + public Result<List<CompanyOptionVO>> listCompanies() { | |
| 45 | + return Result.success(usrAuthService.listCompanies()); | |
| 46 | + } | |
| 47 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BusinessException; | |
| 4 | +import com.xly.erp.common.response.PageResult; | |
| 5 | +import com.xly.erp.common.response.Result; | |
| 6 | +import com.xly.erp.common.response.ResultCode; | |
| 7 | +import com.xly.erp.common.security.SecurityUtil; | |
| 8 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 9 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 10 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 11 | +import com.xly.erp.modules.usr.service.UsrUserService; | |
| 12 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 13 | +import jakarta.validation.Valid; | |
| 14 | +import java.util.Map; | |
| 15 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 16 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 17 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 18 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 19 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 20 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 21 | +import org.springframework.web.bind.annotation.RestController; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * 用户管理 Controller(docs/04 § 1.2 / docs/05 REQ-USR-001)。 | |
| 25 | + * | |
| 26 | + * <p>仅做参数校验 + 管理员权限前置 + 委派 Service,不写业务逻辑、不直接调 Mapper。</p> | |
| 27 | + */ | |
| 28 | +@RestController | |
| 29 | +@RequestMapping("/api/usr") | |
| 30 | +public class UsrUserController { | |
| 31 | + | |
| 32 | + /** 管理员判定口径(spec § 8 D2)。 */ | |
| 33 | + private static final String ADMIN_USER_TYPE = "超级管理员"; | |
| 34 | + | |
| 35 | + private final UsrUserService usrUserService; | |
| 36 | + | |
| 37 | + public UsrUserController(UsrUserService usrUserService) { | |
| 38 | + this.usrUserService = usrUserService; | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * 新增用户:仅超级管理员可调用(spec § 3.9,先于业务校验)。 | |
| 43 | + */ | |
| 44 | + @PostMapping("/users") | |
| 45 | + public Result<Map<String, Object>> createUser(@Valid @RequestBody CreateUserDTO dto) { | |
| 46 | + if (!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())) { | |
| 47 | + throw new BusinessException(ResultCode.FORBIDDEN); | |
| 48 | + } | |
| 49 | + Integer newId = usrUserService.createUser(dto); | |
| 50 | + return Result.success(Map.of("id", newId)); | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * 修改用户:仅超级管理员可调用(spec § 3.9,先于业务校验)。REQ-USR-002。 | |
| 55 | + */ | |
| 56 | + @PutMapping("/users/{id}") | |
| 57 | + public Result<Map<String, Object>> updateUser(@PathVariable Integer id, | |
| 58 | + @Valid @RequestBody UpdateUserDTO dto) { | |
| 59 | + if (!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())) { | |
| 60 | + throw new BusinessException(ResultCode.FORBIDDEN); | |
| 61 | + } | |
| 62 | + Integer updatedId = usrUserService.updateUser(id, dto); | |
| 63 | + return Result.success(Map.of("id", updatedId)); | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 查询用户(REQ-USR-003):任意已认证用户可调用(无管理员前置,spec § 8 D5)。 | |
| 68 | + * query 参数自动绑定到 {@link UserQueryDTO}(非 @RequestBody);仅 @Valid + 委派 Service。 | |
| 69 | + */ | |
| 70 | + @GetMapping("/users") | |
| 71 | + public Result<PageResult<UserVO>> queryUsers(@Valid UserQueryDTO dto) { | |
| 72 | + return Result.success(usrUserService.queryUsers(dto)); | |
| 73 | + } | |
| 74 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.Max; | |
| 5 | +import jakarta.validation.constraints.Min; | |
| 6 | +import jakarta.validation.constraints.NotBlank; | |
| 7 | +import jakarta.validation.constraints.Pattern; | |
| 8 | +import jakarta.validation.constraints.Size; | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 新增用户入参(docs/05 契约 / spec § 2.1)。 | |
| 13 | + * | |
| 14 | + * <p>REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举; | |
| 15 | + * 关联 id 存在性、枚举兜底后越界判定在 Service 处理。</p> | |
| 16 | + * | |
| 17 | + * <p>字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserName} 会被 | |
| 18 | + * Jackson 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对带前缀字段显式 | |
| 19 | + * {@link JsonProperty} 锁定 JSON 键名,确保反序列化绑定正确。</p> | |
| 20 | + */ | |
| 21 | +public class CreateUserDTO { | |
| 22 | + | |
| 23 | + /** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */ | |
| 24 | + @JsonProperty("sUserName") | |
| 25 | + @NotBlank(message = "用户名不能为空") | |
| 26 | + @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线") | |
| 27 | + private String sUserName; | |
| 28 | + | |
| 29 | + /** 用户号:可选。 */ | |
| 30 | + @JsonProperty("sUserNo") | |
| 31 | + @Size(max = 50, message = "用户号长度不能超过 50") | |
| 32 | + private String sUserNo; | |
| 33 | + | |
| 34 | + /** 关联职员 ID:可选,存在性在 Service 校验。 */ | |
| 35 | + @JsonProperty("iEmployeeId") | |
| 36 | + private Integer iEmployeeId; | |
| 37 | + | |
| 38 | + /** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */ | |
| 39 | + @JsonProperty("sUserType") | |
| 40 | + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") | |
| 41 | + private String sUserType; | |
| 42 | + | |
| 43 | + /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ | |
| 44 | + @JsonProperty("sLanguage") | |
| 45 | + @NotBlank(message = "语言不能为空") | |
| 46 | + @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") | |
| 47 | + private String sLanguage; | |
| 48 | + | |
| 49 | + /** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */ | |
| 50 | + @JsonProperty("iCanModifyBill") | |
| 51 | + @Min(value = 0, message = "单据修改权限取值非法") | |
| 52 | + @Max(value = 1, message = "单据修改权限取值非法") | |
| 53 | + private Integer iCanModifyBill; | |
| 54 | + | |
| 55 | + /** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */ | |
| 56 | + @JsonProperty("permissionIds") | |
| 57 | + private List<Integer> permissionIds; | |
| 58 | + | |
| 59 | + /** 初始密码:可选,为空时 Service 兜底 666666。 */ | |
| 60 | + @JsonProperty("initialPassword") | |
| 61 | + private String initialPassword; | |
| 62 | + | |
| 63 | + public String getSUserName() { | |
| 64 | + return sUserName; | |
| 65 | + } | |
| 66 | + | |
| 67 | + public void setSUserName(String sUserName) { | |
| 68 | + this.sUserName = sUserName; | |
| 69 | + } | |
| 70 | + | |
| 71 | + public String getSUserNo() { | |
| 72 | + return sUserNo; | |
| 73 | + } | |
| 74 | + | |
| 75 | + public void setSUserNo(String sUserNo) { | |
| 76 | + this.sUserNo = sUserNo; | |
| 77 | + } | |
| 78 | + | |
| 79 | + public Integer getIEmployeeId() { | |
| 80 | + return iEmployeeId; | |
| 81 | + } | |
| 82 | + | |
| 83 | + public void setIEmployeeId(Integer iEmployeeId) { | |
| 84 | + this.iEmployeeId = iEmployeeId; | |
| 85 | + } | |
| 86 | + | |
| 87 | + public String getSUserType() { | |
| 88 | + return sUserType; | |
| 89 | + } | |
| 90 | + | |
| 91 | + public void setSUserType(String sUserType) { | |
| 92 | + this.sUserType = sUserType; | |
| 93 | + } | |
| 94 | + | |
| 95 | + public String getSLanguage() { | |
| 96 | + return sLanguage; | |
| 97 | + } | |
| 98 | + | |
| 99 | + public void setSLanguage(String sLanguage) { | |
| 100 | + this.sLanguage = sLanguage; | |
| 101 | + } | |
| 102 | + | |
| 103 | + public Integer getICanModifyBill() { | |
| 104 | + return iCanModifyBill; | |
| 105 | + } | |
| 106 | + | |
| 107 | + public void setICanModifyBill(Integer iCanModifyBill) { | |
| 108 | + this.iCanModifyBill = iCanModifyBill; | |
| 109 | + } | |
| 110 | + | |
| 111 | + public List<Integer> getPermissionIds() { | |
| 112 | + return permissionIds; | |
| 113 | + } | |
| 114 | + | |
| 115 | + public void setPermissionIds(List<Integer> permissionIds) { | |
| 116 | + this.permissionIds = permissionIds; | |
| 117 | + } | |
| 118 | + | |
| 119 | + public String getInitialPassword() { | |
| 120 | + return initialPassword; | |
| 121 | + } | |
| 122 | + | |
| 123 | + public void setInitialPassword(String initialPassword) { | |
| 124 | + this.initialPassword = initialPassword; | |
| 125 | + } | |
| 126 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/dto/LoginDTO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | +import jakarta.validation.constraints.NotNull; | |
| 6 | +import jakarta.validation.constraints.Size; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 登录入参(docs/05 契约 / spec § 2.1)。REQ-USR-004 T2。 | |
| 10 | + * | |
| 11 | + * <p>三字段均必填,校验失败由全局处理器统一转 40001。{@code sUserName} 匈牙利前缀字段的 | |
| 12 | + * getter({@code getSUserName})会被 Jackson 推断为属性名 {@code SUserName},与契约 JSON | |
| 13 | + * 键不符,故加 {@link JsonProperty} 锁定键名(与 CreateUserDTO/UserVO 同做法); | |
| 14 | + * {@code password}/{@code companyId} 为常规小驼峰,无需注解。</p> | |
| 15 | + * | |
| 16 | + * <p>{@code password} 为登录明文,仅供服务端 BCrypt 比对,绝不落日志 / 回显。</p> | |
| 17 | + */ | |
| 18 | +public class LoginDTO { | |
| 19 | + | |
| 20 | + /** 登录用户名:必填,最长 50。 */ | |
| 21 | + @JsonProperty("sUserName") | |
| 22 | + @NotBlank(message = "用户名不能为空") | |
| 23 | + @Size(max = 50, message = "用户名长度不能超过 50") | |
| 24 | + private String sUserName; | |
| 25 | + | |
| 26 | + /** 登录密码明文:必填,最长 100;服务端 BCrypt 比对,绝不落日志。 */ | |
| 27 | + @NotBlank(message = "密码不能为空") | |
| 28 | + @Size(max = 100, message = "密码长度不能超过 100") | |
| 29 | + private String password; | |
| 30 | + | |
| 31 | + /** 版本下拉选中的 usr_company.iIncrement:必填,仅存在性校验,不参与认证绑定。 */ | |
| 32 | + @NotNull(message = "版本不能为空") | |
| 33 | + private Integer companyId; | |
| 34 | + | |
| 35 | + public String getSUserName() { | |
| 36 | + return sUserName; | |
| 37 | + } | |
| 38 | + | |
| 39 | + public void setSUserName(String sUserName) { | |
| 40 | + this.sUserName = sUserName; | |
| 41 | + } | |
| 42 | + | |
| 43 | + public String getPassword() { | |
| 44 | + return password; | |
| 45 | + } | |
| 46 | + | |
| 47 | + public void setPassword(String password) { | |
| 48 | + this.password = password; | |
| 49 | + } | |
| 50 | + | |
| 51 | + public Integer getCompanyId() { | |
| 52 | + return companyId; | |
| 53 | + } | |
| 54 | + | |
| 55 | + public void setCompanyId(Integer companyId) { | |
| 56 | + this.companyId = companyId; | |
| 57 | + } | |
| 58 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/dto/UpdateUserDTO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.Max; | |
| 5 | +import jakarta.validation.constraints.Min; | |
| 6 | +import jakarta.validation.constraints.NotBlank; | |
| 7 | +import jakarta.validation.constraints.Pattern; | |
| 8 | +import jakarta.validation.constraints.Size; | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 修改用户入参(docs/05 契约 / spec § 2.1)。REQ-USR-002 T1。 | |
| 13 | + * | |
| 14 | + * <p>用于 {@code PUT /api/usr/users/{id}}:管理员修改已有用户基本信息。 | |
| 15 | + * 部分更新语义(spec § 8 D3)——可选字段({@code sUserNo} / {@code iEmployeeId} / | |
| 16 | + * {@code iCanModifyBill} / {@code iIsVoid})传 {@code null} = 本次不改该列(保持原值), | |
| 17 | + * 非 null 即覆盖;{@code permissionIds} 非 null 全量覆盖授权、{@code []} 清空、{@code null} 不改。</p> | |
| 18 | + * | |
| 19 | + * <p>本 DTO <b>不含</b> {@code sUserName} / {@code sPassword} / 审计字段({@code tCreateDate} / | |
| 20 | + * {@code sCreator})/ 租户字段,这些字段为只读、本接口不接收(spec § 2.1 / § 3)。</p> | |
| 21 | + * | |
| 22 | + * <p>字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserType} 会被 | |
| 23 | + * Jackson 推断为属性名 {@code SUserType},与契约 JSON 键不符;故对带前缀字段显式 | |
| 24 | + * {@link JsonProperty} 锁定 JSON 键名(与 {@code CreateUserDTO} 同样做法)。</p> | |
| 25 | + */ | |
| 26 | +public class UpdateUserDTO { | |
| 27 | + | |
| 28 | + /** 用户号:可选;非 null 覆盖,null 不改。 */ | |
| 29 | + @JsonProperty("sUserNo") | |
| 30 | + @Size(max = 50, message = "用户号长度不能超过 50") | |
| 31 | + private String sUserNo; | |
| 32 | + | |
| 33 | + /** 关联职员 ID:可选,存在性在 Service 校验;非 null 覆盖,null 不改。 */ | |
| 34 | + @JsonProperty("iEmployeeId") | |
| 35 | + private Integer iEmployeeId; | |
| 36 | + | |
| 37 | + /** 用户类型:必填,取值 ∈ {普通用户, 超级管理员}。 */ | |
| 38 | + @JsonProperty("sUserType") | |
| 39 | + @NotBlank(message = "用户类型不能为空") | |
| 40 | + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") | |
| 41 | + private String sUserType; | |
| 42 | + | |
| 43 | + /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ | |
| 44 | + @JsonProperty("sLanguage") | |
| 45 | + @NotBlank(message = "语言不能为空") | |
| 46 | + @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") | |
| 47 | + private String sLanguage; | |
| 48 | + | |
| 49 | + /** 单据修改权限:可选,0 / 1;null 不改。 */ | |
| 50 | + @JsonProperty("iCanModifyBill") | |
| 51 | + @Min(value = 0, message = "单据修改权限取值非法") | |
| 52 | + @Max(value = 1, message = "单据修改权限取值非法") | |
| 53 | + private Integer iCanModifyBill; | |
| 54 | + | |
| 55 | + /** 作废 / 禁用标志:可选,0 正常 / 1 禁用;null 不改。 */ | |
| 56 | + @JsonProperty("iIsVoid") | |
| 57 | + @Min(value = 0, message = "作废标志取值非法") | |
| 58 | + @Max(value = 1, message = "作废标志取值非法") | |
| 59 | + private Integer iIsVoid; | |
| 60 | + | |
| 61 | + /** 权限组 ID 列表:可选;非 null 全量覆盖授权,[] 清空,null 不改。元素存在性在 Service 校验。 */ | |
| 62 | + @JsonProperty("permissionIds") | |
| 63 | + private List<Integer> permissionIds; | |
| 64 | + | |
| 65 | + public String getSUserNo() { | |
| 66 | + return sUserNo; | |
| 67 | + } | |
| 68 | + | |
| 69 | + public void setSUserNo(String sUserNo) { | |
| 70 | + this.sUserNo = sUserNo; | |
| 71 | + } | |
| 72 | + | |
| 73 | + public Integer getIEmployeeId() { | |
| 74 | + return iEmployeeId; | |
| 75 | + } | |
| 76 | + | |
| 77 | + public void setIEmployeeId(Integer iEmployeeId) { | |
| 78 | + this.iEmployeeId = iEmployeeId; | |
| 79 | + } | |
| 80 | + | |
| 81 | + public String getSUserType() { | |
| 82 | + return sUserType; | |
| 83 | + } | |
| 84 | + | |
| 85 | + public void setSUserType(String sUserType) { | |
| 86 | + this.sUserType = sUserType; | |
| 87 | + } | |
| 88 | + | |
| 89 | + public String getSLanguage() { | |
| 90 | + return sLanguage; | |
| 91 | + } | |
| 92 | + | |
| 93 | + public void setSLanguage(String sLanguage) { | |
| 94 | + this.sLanguage = sLanguage; | |
| 95 | + } | |
| 96 | + | |
| 97 | + public Integer getICanModifyBill() { | |
| 98 | + return iCanModifyBill; | |
| 99 | + } | |
| 100 | + | |
| 101 | + public void setICanModifyBill(Integer iCanModifyBill) { | |
| 102 | + this.iCanModifyBill = iCanModifyBill; | |
| 103 | + } | |
| 104 | + | |
| 105 | + public Integer getIIsVoid() { | |
| 106 | + return iIsVoid; | |
| 107 | + } | |
| 108 | + | |
| 109 | + public void setIIsVoid(Integer iIsVoid) { | |
| 110 | + this.iIsVoid = iIsVoid; | |
| 111 | + } | |
| 112 | + | |
| 113 | + public List<Integer> getPermissionIds() { | |
| 114 | + return permissionIds; | |
| 115 | + } | |
| 116 | + | |
| 117 | + public void setPermissionIds(List<Integer> permissionIds) { | |
| 118 | + this.permissionIds = permissionIds; | |
| 119 | + } | |
| 120 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import java.time.LocalDateTime; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 查询条件「已解析」内部载体(Service → Mapper)。REQ-USR-003 T3 / T4。 | |
| 7 | + * | |
| 8 | + * <p>非对外契约:由 Service 把 {@link UserQueryDTO} 的中文 {@code queryField}/{@code matchType}/ | |
| 9 | + * 原始 {@code queryValue} 解析归一为可直接拼 XML {@code <if>} 分支的结构,避免在 XML 内做中文 | |
| 10 | + * 枚举判断与类型解析。{@code column} 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。</p> | |
| 11 | + * | |
| 12 | + * <p>分支种类 {@link Kind}:{@code NONE} 无过滤(全量分页);{@code TEXT} 文本 / 枚举 | |
| 13 | + * ({@code LIKE}/{@code NOT LIKE}/{@code =});{@code BOOL} 布尔({@code =}/{@code <>}); | |
| 14 | + * {@code DATE} 日期区间({@code >=}/{@code <},可取反)。</p> | |
| 15 | + */ | |
| 16 | +public class UserQueryCondition { | |
| 17 | + | |
| 18 | + /** 过滤分支种类。 */ | |
| 19 | + public enum Kind { | |
| 20 | + /** 无过滤。 */ | |
| 21 | + NONE, | |
| 22 | + /** 文本 / 枚举模糊或精确。 */ | |
| 23 | + TEXT, | |
| 24 | + /** 布尔等于 / 不等于。 */ | |
| 25 | + BOOL, | |
| 26 | + /** 日期区间命中 / 取反。 */ | |
| 27 | + DATE | |
| 28 | + } | |
| 29 | + | |
| 30 | + /** 目标列(白名单 token,如 {@code u.sUserName} / {@code e.sDepartment})。 */ | |
| 31 | + private String column; | |
| 32 | + /** 分支种类。 */ | |
| 33 | + private Kind kind = Kind.NONE; | |
| 34 | + | |
| 35 | + /** TEXT:匹配方式(包含 / 不包含 / 等于)。 */ | |
| 36 | + private String matchType; | |
| 37 | + /** TEXT:已转义文本值(含 ESCAPE 转义后的 % _ \)。 */ | |
| 38 | + private String textValue; | |
| 39 | + | |
| 40 | + /** BOOL:归一化布尔值(0 / 1)。 */ | |
| 41 | + private Integer boolValue; | |
| 42 | + /** BOOL:true 表示不等于({@code <>}),false 表示等于({@code =})。 */ | |
| 43 | + private boolean boolNegated; | |
| 44 | + | |
| 45 | + /** DATE:当日区间起(含)。 */ | |
| 46 | + private LocalDateTime dateStart; | |
| 47 | + /** DATE:当日区间止(不含)。 */ | |
| 48 | + private LocalDateTime dateEnd; | |
| 49 | + /** DATE:true 表示「不包含」(区间取反)。 */ | |
| 50 | + private boolean dateNegated; | |
| 51 | + | |
| 52 | + public UserQueryCondition() { | |
| 53 | + } | |
| 54 | + | |
| 55 | + /** 无过滤条件(全量分页)。 */ | |
| 56 | + public static UserQueryCondition none() { | |
| 57 | + return new UserQueryCondition(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** 文本 / 枚举条件:{@code matchType} ∈ {包含,不包含,等于},{@code value} 须由 Service 预先转义。 */ | |
| 61 | + public static UserQueryCondition text(String column, String matchType, String value) { | |
| 62 | + UserQueryCondition c = new UserQueryCondition(); | |
| 63 | + c.column = column; | |
| 64 | + c.kind = Kind.TEXT; | |
| 65 | + c.matchType = matchType; | |
| 66 | + c.textValue = value; | |
| 67 | + return c; | |
| 68 | + } | |
| 69 | + | |
| 70 | + /** 布尔条件:{@code negated=true} 表示「不包含」({@code <>})。 */ | |
| 71 | + public static UserQueryCondition bool(String column, int value, boolean negated) { | |
| 72 | + UserQueryCondition c = new UserQueryCondition(); | |
| 73 | + c.column = column; | |
| 74 | + c.kind = Kind.BOOL; | |
| 75 | + c.boolValue = value; | |
| 76 | + c.boolNegated = negated; | |
| 77 | + return c; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** 日期区间条件:{@code [start, end)};{@code negated=true} 表示「不包含」(区间取反)。 */ | |
| 81 | + public static UserQueryCondition date(String column, LocalDateTime start, LocalDateTime end, | |
| 82 | + boolean negated) { | |
| 83 | + UserQueryCondition c = new UserQueryCondition(); | |
| 84 | + c.column = column; | |
| 85 | + c.kind = Kind.DATE; | |
| 86 | + c.dateStart = start; | |
| 87 | + c.dateEnd = end; | |
| 88 | + c.dateNegated = negated; | |
| 89 | + return c; | |
| 90 | + } | |
| 91 | + | |
| 92 | + // ---- XML <if> 用便捷判定(getter 形式,OGNL 直接读取)---- | |
| 93 | + | |
| 94 | + public boolean isText() { | |
| 95 | + return kind == Kind.TEXT; | |
| 96 | + } | |
| 97 | + | |
| 98 | + public boolean isBool() { | |
| 99 | + return kind == Kind.BOOL; | |
| 100 | + } | |
| 101 | + | |
| 102 | + public boolean isDate() { | |
| 103 | + return kind == Kind.DATE; | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** 文本「等于」。 */ | |
| 107 | + public boolean isTextEquals() { | |
| 108 | + return kind == Kind.TEXT && "等于".equals(matchType); | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** 文本「不包含」。 */ | |
| 112 | + public boolean isTextNotContains() { | |
| 113 | + return kind == Kind.TEXT && "不包含".equals(matchType); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** 文本「包含」(默认)。 */ | |
| 117 | + public boolean isTextContains() { | |
| 118 | + return kind == Kind.TEXT && !"等于".equals(matchType) && !"不包含".equals(matchType); | |
| 119 | + } | |
| 120 | + | |
| 121 | + public String getColumn() { | |
| 122 | + return column; | |
| 123 | + } | |
| 124 | + | |
| 125 | + public Kind getKind() { | |
| 126 | + return kind; | |
| 127 | + } | |
| 128 | + | |
| 129 | + public String getMatchType() { | |
| 130 | + return matchType; | |
| 131 | + } | |
| 132 | + | |
| 133 | + public String getTextValue() { | |
| 134 | + return textValue; | |
| 135 | + } | |
| 136 | + | |
| 137 | + public Integer getBoolValue() { | |
| 138 | + return boolValue; | |
| 139 | + } | |
| 140 | + | |
| 141 | + public boolean isBoolNegated() { | |
| 142 | + return boolNegated; | |
| 143 | + } | |
| 144 | + | |
| 145 | + public LocalDateTime getDateStart() { | |
| 146 | + return dateStart; | |
| 147 | + } | |
| 148 | + | |
| 149 | + public LocalDateTime getDateEnd() { | |
| 150 | + return dateEnd; | |
| 151 | + } | |
| 152 | + | |
| 153 | + public boolean isDateNegated() { | |
| 154 | + return dateNegated; | |
| 155 | + } | |
| 156 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryDTO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Pattern; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 查询用户入参(docs/05 契约 / spec § 2.1)。REQ-USR-003 T2。 | |
| 8 | + * | |
| 9 | + * <p>query 参数绑定(非 JSON body):{@code queryField}/{@code matchType}/{@code queryValue}/ | |
| 10 | + * {@code pageNum}/{@code pageSize} 均为小驼峰,query 参数名直接同名绑定,全部可选。</p> | |
| 11 | + * | |
| 12 | + * <p>校验口径:{@code queryField}/{@code matchType} 走 {@link Pattern}(null 跳过,默认值由 | |
| 13 | + * Service 兜底),越界 → 40001;{@code queryValue} {@link Size}(max=100),超长 → 40001。 | |
| 14 | + * {@code pageNum}/{@code pageSize} **不**加 @Min/@Max(避免 @Valid 失败被全局处理器统一转 | |
| 15 | + * 40001,与 spec 要求的 42201 冲突,spec § 8 D8)——范围在 Service 入口显式判定抛 42201。</p> | |
| 16 | + */ | |
| 17 | +public class UserQueryDTO { | |
| 18 | + | |
| 19 | + /** 查询字段;null 时 Service 兜底「用户名」。越界 → 40001。 */ | |
| 20 | + @Pattern(regexp = "^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$", | |
| 21 | + message = "查询字段取值非法") | |
| 22 | + private String queryField; | |
| 23 | + | |
| 24 | + /** 匹配方式;null 时 Service 兜底「包含」。越界 → 40001。 */ | |
| 25 | + @Pattern(regexp = "^(包含|不包含|等于)$", message = "匹配方式取值非法") | |
| 26 | + private String matchType; | |
| 27 | + | |
| 28 | + /** 查询值;null / trim 后空 = 不施加条件。 */ | |
| 29 | + @Size(max = 100, message = "查询值长度不能超过 100") | |
| 30 | + private String queryValue; | |
| 31 | + | |
| 32 | + /** 页码,从 1 起;范围在 Service 入口判定(42201)。 */ | |
| 33 | + private Integer pageNum; | |
| 34 | + | |
| 35 | + /** 每页条数,1..100;范围在 Service 入口判定(42201)。 */ | |
| 36 | + private Integer pageSize; | |
| 37 | + | |
| 38 | + public String getQueryField() { | |
| 39 | + return queryField; | |
| 40 | + } | |
| 41 | + | |
| 42 | + public void setQueryField(String queryField) { | |
| 43 | + this.queryField = queryField; | |
| 44 | + } | |
| 45 | + | |
| 46 | + public String getMatchType() { | |
| 47 | + return matchType; | |
| 48 | + } | |
| 49 | + | |
| 50 | + public void setMatchType(String matchType) { | |
| 51 | + this.matchType = matchType; | |
| 52 | + } | |
| 53 | + | |
| 54 | + public String getQueryValue() { | |
| 55 | + return queryValue; | |
| 56 | + } | |
| 57 | + | |
| 58 | + public void setQueryValue(String queryValue) { | |
| 59 | + this.queryValue = queryValue; | |
| 60 | + } | |
| 61 | + | |
| 62 | + public Integer getPageNum() { | |
| 63 | + return pageNum; | |
| 64 | + } | |
| 65 | + | |
| 66 | + public void setPageNum(Integer pageNum) { | |
| 67 | + this.pageNum = pageNum; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public Integer getPageSize() { | |
| 71 | + return pageSize; | |
| 72 | + } | |
| 73 | + | |
| 74 | + public void setPageSize(Integer pageSize) { | |
| 75 | + this.pageSize = pageSize; | |
| 76 | + } | |
| 77 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/entity/UsrCompany.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 5 | +import com.xly.erp.common.base.BaseEntity; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 公司实体,映射 {@code usr_company}(docs/03 SSoT)。REQ-USR-004 T3。 | |
| 9 | + * | |
| 10 | + * <p>继承 {@link BaseEntity} 复用标准列({@code iIncrement}/{@code sId}/租户列/{@code tCreateDate}); | |
| 11 | + * 业务列 {@code sCompanyName}(公司名称,登录版本下拉显示来源)、{@code sVersion}(版本/账套标识,可空)。 | |
| 12 | + * 仅供登录流程只读(companyId 存在性校验 + 公司下拉列表)。</p> | |
| 13 | + */ | |
| 14 | +@TableName("usr_company") | |
| 15 | +public class UsrCompany extends BaseEntity { | |
| 16 | + | |
| 17 | + private static final long serialVersionUID = 1L; | |
| 18 | + | |
| 19 | + /** 公司名称(登录页版本下拉的显示来源)。 */ | |
| 20 | + @TableField("sCompanyName") | |
| 21 | + private String sCompanyName; | |
| 22 | + | |
| 23 | + /** 版本 / 账套标识(可空)。 */ | |
| 24 | + @TableField("sVersion") | |
| 25 | + private String sVersion; | |
| 26 | + | |
| 27 | + public String getSCompanyName() { | |
| 28 | + return sCompanyName; | |
| 29 | + } | |
| 30 | + | |
| 31 | + public void setSCompanyName(String sCompanyName) { | |
| 32 | + this.sCompanyName = sCompanyName; | |
| 33 | + } | |
| 34 | + | |
| 35 | + public String getSVersion() { | |
| 36 | + return sVersion; | |
| 37 | + } | |
| 38 | + | |
| 39 | + public void setSVersion(String sVersion) { | |
| 40 | + this.sVersion = sVersion; | |
| 41 | + } | |
| 42 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/entity/UsrEmployee.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 5 | +import com.xly.erp.common.base.BaseEntity; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 职员实体,映射 {@code usr_employee}(docs/03 SSoT)。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001 T4。本 REQ 仅用于校验 iEmployeeId 存在性(读)。</p> | |
| 11 | + */ | |
| 12 | +@TableName("usr_employee") | |
| 13 | +public class UsrEmployee extends BaseEntity { | |
| 14 | + | |
| 15 | + private static final long serialVersionUID = 1L; | |
| 16 | + | |
| 17 | + /** 职员 / 员工姓名。 */ | |
| 18 | + @TableField("sEmployeeName") | |
| 19 | + private String sEmployeeName; | |
| 20 | + | |
| 21 | + /** 员工编号。 */ | |
| 22 | + @TableField("sEmployeeNo") | |
| 23 | + private String sEmployeeNo; | |
| 24 | + | |
| 25 | + /** 所属部门。 */ | |
| 26 | + @TableField("sDepartment") | |
| 27 | + private String sDepartment; | |
| 28 | + | |
| 29 | + public String getSEmployeeName() { | |
| 30 | + return sEmployeeName; | |
| 31 | + } | |
| 32 | + | |
| 33 | + public void setSEmployeeName(String sEmployeeName) { | |
| 34 | + this.sEmployeeName = sEmployeeName; | |
| 35 | + } | |
| 36 | + | |
| 37 | + public String getSEmployeeNo() { | |
| 38 | + return sEmployeeNo; | |
| 39 | + } | |
| 40 | + | |
| 41 | + public void setSEmployeeNo(String sEmployeeNo) { | |
| 42 | + this.sEmployeeNo = sEmployeeNo; | |
| 43 | + } | |
| 44 | + | |
| 45 | + public String getSDepartment() { | |
| 46 | + return sDepartment; | |
| 47 | + } | |
| 48 | + | |
| 49 | + public void setSDepartment(String sDepartment) { | |
| 50 | + this.sDepartment = sDepartment; | |
| 51 | + } | |
| 52 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/entity/UsrPermission.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 5 | +import com.xly.erp.common.base.BaseEntity; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 权限实体,映射 {@code usr_permission}(docs/03 SSoT)。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001 T4。本 REQ 仅用于校验 permissionIds 元素存在性(读)。</p> | |
| 11 | + */ | |
| 12 | +@TableName("usr_permission") | |
| 13 | +public class UsrPermission extends BaseEntity { | |
| 14 | + | |
| 15 | + private static final long serialVersionUID = 1L; | |
| 16 | + | |
| 17 | + /** 权限名称。 */ | |
| 18 | + @TableField("sPermissionName") | |
| 19 | + private String sPermissionName; | |
| 20 | + | |
| 21 | + /** 权限编码(系统内唯一)。 */ | |
| 22 | + @TableField("sPermissionCode") | |
| 23 | + private String sPermissionCode; | |
| 24 | + | |
| 25 | + /** 权限分类。 */ | |
| 26 | + @TableField("sPermissionCategory") | |
| 27 | + private String sPermissionCategory; | |
| 28 | + | |
| 29 | + public String getSPermissionName() { | |
| 30 | + return sPermissionName; | |
| 31 | + } | |
| 32 | + | |
| 33 | + public void setSPermissionName(String sPermissionName) { | |
| 34 | + this.sPermissionName = sPermissionName; | |
| 35 | + } | |
| 36 | + | |
| 37 | + public String getSPermissionCode() { | |
| 38 | + return sPermissionCode; | |
| 39 | + } | |
| 40 | + | |
| 41 | + public void setSPermissionCode(String sPermissionCode) { | |
| 42 | + this.sPermissionCode = sPermissionCode; | |
| 43 | + } | |
| 44 | + | |
| 45 | + public String getSPermissionCategory() { | |
| 46 | + return sPermissionCategory; | |
| 47 | + } | |
| 48 | + | |
| 49 | + public void setSPermissionCategory(String sPermissionCategory) { | |
| 50 | + this.sPermissionCategory = sPermissionCategory; | |
| 51 | + } | |
| 52 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUser.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 5 | +import com.fasterxml.jackson.annotation.JsonIgnore; | |
| 6 | +import com.xly.erp.common.base.BaseEntity; | |
| 7 | +import java.time.LocalDateTime; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 用户实体,映射 {@code usr_user}(docs/03 SSoT)。 | |
| 11 | + * | |
| 12 | + * <p>REQ-USR-001 T4。继承 {@link BaseEntity} 复用标准列;密码列 {@code sPassword} | |
| 13 | + * 标注 {@link JsonIgnore} 防止序列化输出。</p> | |
| 14 | + */ | |
| 15 | +@TableName("usr_user") | |
| 16 | +public class UsrUser extends BaseEntity { | |
| 17 | + | |
| 18 | + private static final long serialVersionUID = 1L; | |
| 19 | + | |
| 20 | + /** 用户名,登录账号,全局唯一。 */ | |
| 21 | + @TableField("sUserName") | |
| 22 | + private String sUserName; | |
| 23 | + | |
| 24 | + /** 用户号。 */ | |
| 25 | + @TableField("sUserNo") | |
| 26 | + private String sUserNo; | |
| 27 | + | |
| 28 | + /** 登录密码,BCrypt 哈希存储;禁止序列化输出。 */ | |
| 29 | + @JsonIgnore | |
| 30 | + @TableField("sPassword") | |
| 31 | + private String sPassword; | |
| 32 | + | |
| 33 | + /** 关联职员 ID(可选)。 */ | |
| 34 | + @TableField("iEmployeeId") | |
| 35 | + private Integer iEmployeeId; | |
| 36 | + | |
| 37 | + /** 用户类型:普通用户 / 超级管理员。 */ | |
| 38 | + @TableField("sUserType") | |
| 39 | + private String sUserType; | |
| 40 | + | |
| 41 | + /** 界面语言:中文 / 英文 / 繁体。 */ | |
| 42 | + @TableField("sLanguage") | |
| 43 | + private String sLanguage; | |
| 44 | + | |
| 45 | + /** 单据修改权限:0 否 / 1 是。 */ | |
| 46 | + @TableField("iCanModifyBill") | |
| 47 | + private Integer iCanModifyBill; | |
| 48 | + | |
| 49 | + /** 作废 / 禁用标志:0 正常 / 1 已作废。 */ | |
| 50 | + @TableField("iIsVoid") | |
| 51 | + private Integer iIsVoid; | |
| 52 | + | |
| 53 | + /** 最后登录时间。 */ | |
| 54 | + @TableField("tLastLoginDate") | |
| 55 | + private LocalDateTime tLastLoginDate; | |
| 56 | + | |
| 57 | + /** 制单人(创建该用户的操作员用户名)。 */ | |
| 58 | + @TableField("sCreator") | |
| 59 | + private String sCreator; | |
| 60 | + | |
| 61 | + public String getSUserName() { | |
| 62 | + return sUserName; | |
| 63 | + } | |
| 64 | + | |
| 65 | + public void setSUserName(String sUserName) { | |
| 66 | + this.sUserName = sUserName; | |
| 67 | + } | |
| 68 | + | |
| 69 | + public String getSUserNo() { | |
| 70 | + return sUserNo; | |
| 71 | + } | |
| 72 | + | |
| 73 | + public void setSUserNo(String sUserNo) { | |
| 74 | + this.sUserNo = sUserNo; | |
| 75 | + } | |
| 76 | + | |
| 77 | + public String getSPassword() { | |
| 78 | + return sPassword; | |
| 79 | + } | |
| 80 | + | |
| 81 | + public void setSPassword(String sPassword) { | |
| 82 | + this.sPassword = sPassword; | |
| 83 | + } | |
| 84 | + | |
| 85 | + public Integer getIEmployeeId() { | |
| 86 | + return iEmployeeId; | |
| 87 | + } | |
| 88 | + | |
| 89 | + public void setIEmployeeId(Integer iEmployeeId) { | |
| 90 | + this.iEmployeeId = iEmployeeId; | |
| 91 | + } | |
| 92 | + | |
| 93 | + public String getSUserType() { | |
| 94 | + return sUserType; | |
| 95 | + } | |
| 96 | + | |
| 97 | + public void setSUserType(String sUserType) { | |
| 98 | + this.sUserType = sUserType; | |
| 99 | + } | |
| 100 | + | |
| 101 | + public String getSLanguage() { | |
| 102 | + return sLanguage; | |
| 103 | + } | |
| 104 | + | |
| 105 | + public void setSLanguage(String sLanguage) { | |
| 106 | + this.sLanguage = sLanguage; | |
| 107 | + } | |
| 108 | + | |
| 109 | + public Integer getICanModifyBill() { | |
| 110 | + return iCanModifyBill; | |
| 111 | + } | |
| 112 | + | |
| 113 | + public void setICanModifyBill(Integer iCanModifyBill) { | |
| 114 | + this.iCanModifyBill = iCanModifyBill; | |
| 115 | + } | |
| 116 | + | |
| 117 | + public Integer getIIsVoid() { | |
| 118 | + return iIsVoid; | |
| 119 | + } | |
| 120 | + | |
| 121 | + public void setIIsVoid(Integer iIsVoid) { | |
| 122 | + this.iIsVoid = iIsVoid; | |
| 123 | + } | |
| 124 | + | |
| 125 | + public LocalDateTime getTLastLoginDate() { | |
| 126 | + return tLastLoginDate; | |
| 127 | + } | |
| 128 | + | |
| 129 | + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { | |
| 130 | + this.tLastLoginDate = tLastLoginDate; | |
| 131 | + } | |
| 132 | + | |
| 133 | + public String getSCreator() { | |
| 134 | + return sCreator; | |
| 135 | + } | |
| 136 | + | |
| 137 | + public void setSCreator(String sCreator) { | |
| 138 | + this.sCreator = sCreator; | |
| 139 | + } | |
| 140 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUserPermission.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 5 | +import com.xly.erp.common.base.BaseEntity; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 用户权限关联实体,映射 {@code usr_user_permission}(docs/03 SSoT)。 | |
| 9 | + * | |
| 10 | + * <p>REQ-USR-001 T4。用户 ↔ 权限多对多授权;唯一索引 {@code uk_usr_user_permission} | |
| 11 | + * on (iUserId, iPermissionId)。</p> | |
| 12 | + */ | |
| 13 | +@TableName("usr_user_permission") | |
| 14 | +public class UsrUserPermission extends BaseEntity { | |
| 15 | + | |
| 16 | + private static final long serialVersionUID = 1L; | |
| 17 | + | |
| 18 | + /** 用户 ID,外键 -> usr_user.iIncrement。 */ | |
| 19 | + @TableField("iUserId") | |
| 20 | + private Integer iUserId; | |
| 21 | + | |
| 22 | + /** 权限 ID,外键 -> usr_permission.iIncrement。 */ | |
| 23 | + @TableField("iPermissionId") | |
| 24 | + private Integer iPermissionId; | |
| 25 | + | |
| 26 | + public UsrUserPermission() { | |
| 27 | + } | |
| 28 | + | |
| 29 | + public UsrUserPermission(Integer iUserId, Integer iPermissionId) { | |
| 30 | + this.iUserId = iUserId; | |
| 31 | + this.iPermissionId = iPermissionId; | |
| 32 | + } | |
| 33 | + | |
| 34 | + public Integer getIUserId() { | |
| 35 | + return iUserId; | |
| 36 | + } | |
| 37 | + | |
| 38 | + public void setIUserId(Integer iUserId) { | |
| 39 | + this.iUserId = iUserId; | |
| 40 | + } | |
| 41 | + | |
| 42 | + public Integer getIPermissionId() { | |
| 43 | + return iPermissionId; | |
| 44 | + } | |
| 45 | + | |
| 46 | + public void setIPermissionId(Integer iPermissionId) { | |
| 47 | + this.iPermissionId = iPermissionId; | |
| 48 | + } | |
| 49 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapper.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 公司 Mapper(MyBatis-Plus)。REQ-USR-004 T3。 | |
| 9 | + * | |
| 10 | + * <p>无自定义方法,复用 BaseMapper 的 {@code selectById}(companyId 存在性校验) | |
| 11 | + * 与 {@code selectList}(公司下拉列表)。</p> | |
| 12 | + */ | |
| 13 | +@Mapper | |
| 14 | +public interface UsrCompanyMapper extends BaseMapper<UsrCompany> { | |
| 15 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrEmployeeMapper.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.modules.usr.entity.UsrEmployee; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 职员 Mapper(MyBatis-Plus),本 REQ 用于存在性校验。REQ-USR-001 T4。 | |
| 9 | + */ | |
| 10 | +@Mapper | |
| 11 | +public interface UsrEmployeeMapper extends BaseMapper<UsrEmployee> { | |
| 12 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrPermissionMapper.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 权限 Mapper(MyBatis-Plus),本 REQ 用于存在性校验。REQ-USR-001 T4。 | |
| 9 | + */ | |
| 10 | +@Mapper | |
| 11 | +public interface UsrPermissionMapper extends BaseMapper<UsrPermission> { | |
| 12 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.baomidou.mybatisplus.core.metadata.IPage; | |
| 5 | +import com.xly.erp.modules.usr.dto.UserQueryCondition; | |
| 6 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 7 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 8 | +import org.apache.ibatis.annotations.Mapper; | |
| 9 | +import org.apache.ibatis.annotations.Param; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 用户 Mapper(MyBatis-Plus)。REQ-USR-001 T4 / REQ-USR-003 T3。 | |
| 13 | + */ | |
| 14 | +@Mapper | |
| 15 | +public interface UsrUserMapper extends BaseMapper<UsrUser> { | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * 跨表分页查询(REQ-USR-003):{@code usr_user LEFT JOIN usr_employee} 取员工名 / 部门, | |
| 19 | + * 按已解析 {@link UserQueryCondition} 施加单字段单匹配过滤;MP 分页插件自动补 LIMIT 与 COUNT(*)。 | |
| 20 | + * | |
| 21 | + * <p>不 SELECT {@code sPassword} 与租户列;所有值用 {@code #{}} 预编译占位, | |
| 22 | + * 仅白名单列 token 经 {@code ${}} 注入(由 Service 从固定映射产出,防注入)。</p> | |
| 23 | + * | |
| 24 | + * @param page 分页参数(current/size) | |
| 25 | + * @param cond 已解析查询条件 | |
| 26 | + * @return 当前页 UserVO 列表(含真实 total) | |
| 27 | + */ | |
| 28 | + IPage<UserVO> selectUserPage(IPage<UserVO> page, @Param("cond") UserQueryCondition cond); | |
| 29 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserPermissionMapper.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 用户权限关联 Mapper(MyBatis-Plus)。REQ-USR-001 T4。 | |
| 9 | + */ | |
| 10 | +@Mapper | |
| 11 | +public interface UsrUserPermissionMapper extends BaseMapper<UsrUserPermission> { | |
| 12 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 4 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 5 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 认证服务(spec § 3)。REQ-USR-004 T4。 | |
| 10 | + */ | |
| 11 | +public interface UsrAuthService { | |
| 12 | + | |
| 13 | + /** | |
| 14 | + * 登录认证:基础参数校验 → 查用户 → BCrypt 比对 → 判禁用 → companyId 存在性 → | |
| 15 | + * 签发 JWT + 更新登录时间;含连续失败限流。 | |
| 16 | + * | |
| 17 | + * @param dto 登录入参 | |
| 18 | + * @return 登录输出(token + 用户基础信息,绝不含密码) | |
| 19 | + */ | |
| 20 | + LoginVO login(LoginDTO dto); | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * 公司下拉列表(登录页「版本」选项),只读无副作用。 | |
| 24 | + * | |
| 25 | + * @return 全部公司下拉项 | |
| 26 | + */ | |
| 27 | + List<CompanyOptionVO> listCompanies(); | |
| 28 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.PageResult; | |
| 4 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 5 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 6 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 7 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 用户业务服务(docs/04 § 1.2)。REQ-USR-001 / REQ-USR-002 / REQ-USR-003。 | |
| 11 | + */ | |
| 12 | +public interface UsrUserService { | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 新增用户:用户名查重 → 默认值兜底 / 校验 → BCrypt 哈希密码 → 落库 → | |
| 16 | + * 关联职员 / 权限校验与授权写入。 | |
| 17 | + * | |
| 18 | + * @param dto 新增用户入参 | |
| 19 | + * @return 新建用户主键 iIncrement | |
| 20 | + */ | |
| 21 | + Integer createUser(CreateUserDTO dto); | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 修改用户(REQ-USR-002):校验目标用户存在 → 校验关联职员 / 权限存在 → | |
| 25 | + * 按"部分更新(null 不改)"语义更新 {@code usr_user} 主记录(不动 sUserName / | |
| 26 | + * sPassword / 审计列 / 租户列)→ 按"全量覆盖"语义重写该用户在 {@code usr_user_permission} | |
| 27 | + * 的授权。整体单事务。 | |
| 28 | + * | |
| 29 | + * @param id 目标用户主键 iIncrement | |
| 30 | + * @param dto 修改用户入参 | |
| 31 | + * @return 被修改用户主键 iIncrement(等于入参 id) | |
| 32 | + */ | |
| 33 | + Integer updateUser(Integer id, UpdateUserDTO dto); | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * 查询用户(REQ-USR-003,纯只读):分页参数判定(42201)→ 单字段单匹配条件解析 | |
| 37 | + * (文本转义 / 枚举 / 布尔归一化 / 日期区间,非法 40001)→ {@code usr_user LEFT JOIN | |
| 38 | + * usr_employee} 分页查询 → 数据越界钳制到最后一页 → 装配 {@link PageResult}。 | |
| 39 | + * 空条件返回全量分页;响应不含密码 / 租户列。 | |
| 40 | + * | |
| 41 | + * @param dto 查询入参(全部可选) | |
| 42 | + * @return 分页结果 {@code PageResult<UserVO>} | |
| 43 | + */ | |
| 44 | + PageResult<UserVO> queryUsers(UserQueryDTO dto); | |
| 45 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 4 | +import com.xly.erp.common.exception.BusinessException; | |
| 5 | +import com.xly.erp.common.response.ResultCode; | |
| 6 | +import com.xly.erp.common.security.JwtUtil; | |
| 7 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 8 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 9 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 10 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | |
| 11 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 12 | +import com.xly.erp.modules.usr.service.UsrAuthService; | |
| 13 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 14 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 15 | +import java.time.LocalDateTime; | |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.List; | |
| 18 | +import java.util.concurrent.ConcurrentHashMap; | |
| 19 | +import org.springframework.beans.factory.annotation.Value; | |
| 20 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 21 | +import org.springframework.stereotype.Service; | |
| 22 | +import org.springframework.transaction.annotation.Transactional; | |
| 23 | +import org.springframework.util.StringUtils; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * 认证业务实现(spec § 3)。REQ-USR-004 T4。 | |
| 27 | + * | |
| 28 | + * <p>认证判定顺序(spec § 3 规则 1 / § 8 D3,固定): | |
| 29 | + * ①限流窗判定(命中锁定 → 42901,置于最前)→ ②按 sUserName(trim) 查 usr_user,null → 40101(记一次失败) | |
| 30 | + * → ③BCrypt 比对密码,不匹配 → 40101(记一次失败,与②同码同 message 防枚举)→ ④iIsVoid=1 → 40302 | |
| 31 | + * (先验密码再判禁用,禁用不计入失败计数避免锁死)→ ⑤companyId 存在性校验,不存在 → 40001 | |
| 32 | + * → ⑥签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。</p> | |
| 33 | + * | |
| 34 | + * <p>限流为进程内(内存)按用户名计数(spec § 8 D7),不依赖 Redis;阈值 / 窗口来自 | |
| 35 | + * application.yml auth.login.max-fail / auth.login.lock-seconds。密码明文绝不进日志 / 异常 message。</p> | |
| 36 | + */ | |
| 37 | +@Service | |
| 38 | +public class UsrAuthServiceImpl implements UsrAuthService { | |
| 39 | + | |
| 40 | + private final UsrUserMapper usrUserMapper; | |
| 41 | + private final UsrCompanyMapper usrCompanyMapper; | |
| 42 | + private final PasswordEncoder passwordEncoder; | |
| 43 | + private final JwtUtil jwtUtil; | |
| 44 | + | |
| 45 | + /** 达到该连续失败次数后锁定。 */ | |
| 46 | + private final int maxFail; | |
| 47 | + /** 锁定窗秒数。 */ | |
| 48 | + private final long lockSeconds; | |
| 49 | + | |
| 50 | + /** 进程内按用户名的失败计数 / 锁定状态(线程安全)。 */ | |
| 51 | + private final ConcurrentHashMap<String, LoginAttempt> attempts = new ConcurrentHashMap<>(); | |
| 52 | + | |
| 53 | + public UsrAuthServiceImpl(UsrUserMapper usrUserMapper, | |
| 54 | + UsrCompanyMapper usrCompanyMapper, | |
| 55 | + PasswordEncoder passwordEncoder, | |
| 56 | + JwtUtil jwtUtil, | |
| 57 | + @Value("${auth.login.max-fail:5}") int maxFail, | |
| 58 | + @Value("${auth.login.lock-seconds:300}") long lockSeconds) { | |
| 59 | + this.usrUserMapper = usrUserMapper; | |
| 60 | + this.usrCompanyMapper = usrCompanyMapper; | |
| 61 | + this.passwordEncoder = passwordEncoder; | |
| 62 | + this.jwtUtil = jwtUtil; | |
| 63 | + this.maxFail = maxFail; | |
| 64 | + this.lockSeconds = lockSeconds; | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Override | |
| 68 | + @Transactional(rollbackFor = Exception.class) | |
| 69 | + public LoginVO login(LoginDTO dto) { | |
| 70 | + String userName = dto.getSUserName() == null ? null : dto.getSUserName().trim(); | |
| 71 | + | |
| 72 | + // ① 限流窗判定(命中锁定 → 42901,置于最前)。 | |
| 73 | + if (isLocked(userName)) { | |
| 74 | + throw new BusinessException(ResultCode.LOGIN_RATE_LIMITED); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ② 按用户名查用户,未查到 → 40101(防枚举,统一文案)。 | |
| 78 | + UsrUser user = usrUserMapper.selectOne( | |
| 79 | + Wrappers.<UsrUser>lambdaQuery().eq(UsrUser::getSUserName, userName)); | |
| 80 | + if (user == null) { | |
| 81 | + recordFailure(userName); | |
| 82 | + throw new BusinessException(ResultCode.UNAUTHORIZED); | |
| 83 | + } | |
| 84 | + | |
| 85 | + // ③ 先 BCrypt 比对密码,不匹配 → 40101(与②同码同 message)。 | |
| 86 | + if (!passwordEncoder.matches(dto.getPassword(), user.getSPassword())) { | |
| 87 | + recordFailure(userName); | |
| 88 | + throw new BusinessException(ResultCode.UNAUTHORIZED); | |
| 89 | + } | |
| 90 | + | |
| 91 | + // ④ 再判禁用 → 40302(先验密码再返禁用码;禁用不计入失败计数避免锁死禁用账号)。 | |
| 92 | + if (user.getIIsVoid() != null && user.getIIsVoid() == 1) { | |
| 93 | + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // ⑤ companyId 存在性校验(不参与认证绑定),不存在 → 40001(中性提示,不含枚举信息)。 | |
| 97 | + if (usrCompanyMapper.selectById(dto.getCompanyId()) == null) { | |
| 98 | + throw new BusinessException(ResultCode.PARAM_INVALID); | |
| 99 | + } | |
| 100 | + | |
| 101 | + // ⑥ 签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 装配 LoginVO。 | |
| 102 | + String token = jwtUtil.generateToken(user.getSUserName(), user.getSUserType()); | |
| 103 | + | |
| 104 | + UsrUser loginTimeUpdate = new UsrUser(); | |
| 105 | + loginTimeUpdate.setIIncrement(user.getIIncrement()); | |
| 106 | + loginTimeUpdate.setTLastLoginDate(LocalDateTime.now()); | |
| 107 | + usrUserMapper.updateById(loginTimeUpdate); | |
| 108 | + | |
| 109 | + attempts.remove(userName); | |
| 110 | + | |
| 111 | + return toLoginVO(token, user); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Override | |
| 115 | + @Transactional(readOnly = true) | |
| 116 | + public List<CompanyOptionVO> listCompanies() { | |
| 117 | + List<UsrCompany> companies = usrCompanyMapper.selectList(null); | |
| 118 | + List<CompanyOptionVO> result = new ArrayList<>(); | |
| 119 | + for (UsrCompany c : companies) { | |
| 120 | + CompanyOptionVO vo = new CompanyOptionVO(); | |
| 121 | + vo.setId(c.getIIncrement()); | |
| 122 | + vo.setSCompanyName(c.getSCompanyName()); | |
| 123 | + vo.setSVersion(c.getSVersion()); | |
| 124 | + result.add(vo); | |
| 125 | + } | |
| 126 | + return result; | |
| 127 | + } | |
| 128 | + | |
| 129 | + private LoginVO toLoginVO(String token, UsrUser user) { | |
| 130 | + LoginVO.UserInfo info = new LoginVO.UserInfo(); | |
| 131 | + info.setId(user.getIIncrement()); | |
| 132 | + info.setSUserName(user.getSUserName()); | |
| 133 | + info.setSUserType(user.getSUserType()); | |
| 134 | + info.setSLanguage(user.getSLanguage()); | |
| 135 | + | |
| 136 | + LoginVO vo = new LoginVO(); | |
| 137 | + vo.setToken(token); | |
| 138 | + vo.setUser(info); | |
| 139 | + return vo; | |
| 140 | + } | |
| 141 | + | |
| 142 | + /** 当前用户名是否处于锁定窗内(连续失败已达阈值且未过期)。 */ | |
| 143 | + private boolean isLocked(String userName) { | |
| 144 | + if (!StringUtils.hasText(userName)) { | |
| 145 | + return false; | |
| 146 | + } | |
| 147 | + LoginAttempt attempt = attempts.get(userName); | |
| 148 | + if (attempt == null) { | |
| 149 | + return false; | |
| 150 | + } | |
| 151 | + if (attempt.failCount >= maxFail && attempt.lockUntilEpochSec > nowEpochSec()) { | |
| 152 | + return true; | |
| 153 | + } | |
| 154 | + // 锁定窗已过:重置计数,允许再次尝试。 | |
| 155 | + if (attempt.lockUntilEpochSec > 0 && attempt.lockUntilEpochSec <= nowEpochSec()) { | |
| 156 | + attempts.remove(userName); | |
| 157 | + } | |
| 158 | + return false; | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** 记一次失败:累计计数,达阈值则设置锁定到期时间。 */ | |
| 162 | + private void recordFailure(String userName) { | |
| 163 | + if (!StringUtils.hasText(userName)) { | |
| 164 | + return; | |
| 165 | + } | |
| 166 | + attempts.compute(userName, (k, prev) -> { | |
| 167 | + LoginAttempt a = prev == null ? new LoginAttempt() : prev; | |
| 168 | + a.failCount++; | |
| 169 | + if (a.failCount >= maxFail) { | |
| 170 | + a.lockUntilEpochSec = nowEpochSec() + lockSeconds; | |
| 171 | + } | |
| 172 | + return a; | |
| 173 | + }); | |
| 174 | + } | |
| 175 | + | |
| 176 | + private long nowEpochSec() { | |
| 177 | + return System.currentTimeMillis() / 1000L; | |
| 178 | + } | |
| 179 | + | |
| 180 | + /** 单个用户名的失败计数与锁定到期(epoch 秒)。 */ | |
| 181 | + private static final class LoginAttempt { | |
| 182 | + private int failCount; | |
| 183 | + private long lockUntilEpochSec; | |
| 184 | + } | |
| 185 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.metadata.IPage; | |
| 4 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 5 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | |
| 6 | +import com.xly.erp.common.exception.BusinessException; | |
| 7 | +import com.xly.erp.common.response.PageResult; | |
| 8 | +import com.xly.erp.common.response.ResultCode; | |
| 9 | +import com.xly.erp.common.security.SecurityUtil; | |
| 10 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 11 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 12 | +import com.xly.erp.modules.usr.dto.UserQueryCondition; | |
| 13 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 14 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 15 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 16 | +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; | |
| 17 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 18 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 19 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 20 | +import com.xly.erp.modules.usr.service.UsrUserService; | |
| 21 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 22 | +import java.time.LocalDate; | |
| 23 | +import java.time.LocalDateTime; | |
| 24 | +import java.time.LocalTime; | |
| 25 | +import java.time.format.DateTimeFormatter; | |
| 26 | +import java.time.format.DateTimeParseException; | |
| 27 | +import java.util.LinkedHashSet; | |
| 28 | +import java.util.List; | |
| 29 | +import java.util.Map; | |
| 30 | +import org.springframework.dao.DuplicateKeyException; | |
| 31 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 32 | +import org.springframework.stereotype.Service; | |
| 33 | +import org.springframework.transaction.annotation.Transactional; | |
| 34 | +import org.springframework.util.StringUtils; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 新增用户业务实现(spec § 3)。REQ-USR-001 T5 / T6。 | |
| 38 | + * | |
| 39 | + * <p>流程:用户名查重(40901)→ 关联职员存在性校验(40001)→ 默认值兜底与枚举越界校验(40001) | |
| 40 | + * → BCrypt 哈希密码 → 填审计字段并落库(DuplicateKey 兜底转 40901)→ 权限存在性校验(40001) | |
| 41 | + * → 去重批量授权写入。整体 {@code @Transactional}。</p> | |
| 42 | + */ | |
| 43 | +@Service | |
| 44 | +public class UsrUserServiceImpl implements UsrUserService { | |
| 45 | + | |
| 46 | + /** 默认初始密码(config-vars admin_init.password 与 spec § 8 D5 一致)。 */ | |
| 47 | + private static final String DEFAULT_PASSWORD = "666666"; | |
| 48 | + /** 默认用户类型。 */ | |
| 49 | + private static final String DEFAULT_USER_TYPE = "普通用户"; | |
| 50 | + /** 默认单据修改权限。 */ | |
| 51 | + private static final int DEFAULT_CAN_MODIFY_BILL = 0; | |
| 52 | + /** 新建即生效。 */ | |
| 53 | + private static final int NOT_VOID = 0; | |
| 54 | + | |
| 55 | + // ---- REQ-USR-003 查询用户:常量 / 白名单 ---- | |
| 56 | + | |
| 57 | + /** 默认查询字段(spec § 2.1)。 */ | |
| 58 | + private static final String DEFAULT_QUERY_FIELD = "用户名"; | |
| 59 | + /** 默认匹配方式(spec § 2.1)。 */ | |
| 60 | + private static final String DEFAULT_MATCH_TYPE = "包含"; | |
| 61 | + /** 默认页码。 */ | |
| 62 | + private static final long DEFAULT_PAGE_NUM = 1L; | |
| 63 | + /** 默认每页条数。 */ | |
| 64 | + private static final long DEFAULT_PAGE_SIZE = 10L; | |
| 65 | + /** 每页条数上限(spec § 3.7)。 */ | |
| 66 | + private static final long MAX_PAGE_SIZE = 100L; | |
| 67 | + | |
| 68 | + /** 中文 queryField → 白名单列 token(带表别名,绝不拼接用户输入列名,防注入;spec § 3.4)。 */ | |
| 69 | + private static final Map<String, String> FIELD_COLUMN = Map.of( | |
| 70 | + "用户名", "u.sUserName", | |
| 71 | + "员工名", "e.sEmployeeName", | |
| 72 | + "用户号", "u.sUserNo", | |
| 73 | + "部门", "e.sDepartment", | |
| 74 | + "用户类型", "u.sUserType", | |
| 75 | + "作废", "u.iIsVoid", | |
| 76 | + "登录日期", "u.tLastLoginDate", | |
| 77 | + "制单人", "u.sCreator"); | |
| 78 | + | |
| 79 | + /** 布尔字段集合(按 0/1 归一化)。 */ | |
| 80 | + private static final String FIELD_VOID = "作废"; | |
| 81 | + /** 日期字段集合(按当日区间解析)。 */ | |
| 82 | + private static final String FIELD_LOGIN_DATE = "登录日期"; | |
| 83 | + | |
| 84 | + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); | |
| 85 | + private static final DateTimeFormatter DATETIME_FMT = | |
| 86 | + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |
| 87 | + | |
| 88 | + private final UsrUserMapper usrUserMapper; | |
| 89 | + private final UsrUserPermissionMapper usrUserPermissionMapper; | |
| 90 | + private final UsrEmployeeMapper usrEmployeeMapper; | |
| 91 | + private final UsrPermissionMapper usrPermissionMapper; | |
| 92 | + private final PasswordEncoder passwordEncoder; | |
| 93 | + | |
| 94 | + public UsrUserServiceImpl(UsrUserMapper usrUserMapper, | |
| 95 | + UsrUserPermissionMapper usrUserPermissionMapper, | |
| 96 | + UsrEmployeeMapper usrEmployeeMapper, | |
| 97 | + UsrPermissionMapper usrPermissionMapper, | |
| 98 | + PasswordEncoder passwordEncoder) { | |
| 99 | + this.usrUserMapper = usrUserMapper; | |
| 100 | + this.usrUserPermissionMapper = usrUserPermissionMapper; | |
| 101 | + this.usrEmployeeMapper = usrEmployeeMapper; | |
| 102 | + this.usrPermissionMapper = usrPermissionMapper; | |
| 103 | + this.passwordEncoder = passwordEncoder; | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Override | |
| 107 | + @Transactional(rollbackFor = Exception.class) | |
| 108 | + public Integer createUser(CreateUserDTO dto) { | |
| 109 | + // 1. 用户名查重(命中唯一索引前先查)。 | |
| 110 | + Long existing = usrUserMapper.selectCount( | |
| 111 | + Wrappers.<UsrUser>lambdaQuery().eq(UsrUser::getSUserName, dto.getSUserName())); | |
| 112 | + if (existing != null && existing > 0) { | |
| 113 | + throw new BusinessException(ResultCode.USERNAME_EXISTS); | |
| 114 | + } | |
| 115 | + | |
| 116 | + // 2. 关联职员存在性校验(可选)。 | |
| 117 | + if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) { | |
| 118 | + throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在"); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // 3. 权限存在性校验 + 去重(可选)。 | |
| 122 | + List<Integer> dedupedPermissionIds = null; | |
| 123 | + if (dto.getPermissionIds() != null && !dto.getPermissionIds().isEmpty()) { | |
| 124 | + dedupedPermissionIds = dto.getPermissionIds().stream() | |
| 125 | + .filter(java.util.Objects::nonNull) | |
| 126 | + .distinct() | |
| 127 | + .toList(); | |
| 128 | + for (Integer permissionId : dedupedPermissionIds) { | |
| 129 | + if (usrPermissionMapper.selectById(permissionId) == null) { | |
| 130 | + throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId); | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + // 4. 默认值兜底 + 枚举越界二次校验。 | |
| 136 | + String userType = StringUtils.hasText(dto.getSUserType()) ? dto.getSUserType() : DEFAULT_USER_TYPE; | |
| 137 | + if (!DEFAULT_USER_TYPE.equals(userType) && !"超级管理员".equals(userType)) { | |
| 138 | + throw new BusinessException(ResultCode.PARAM_INVALID, "用户类型取值非法"); | |
| 139 | + } | |
| 140 | + Integer canModifyBill = dto.getICanModifyBill() != null ? dto.getICanModifyBill() : DEFAULT_CAN_MODIFY_BILL; | |
| 141 | + if (canModifyBill != 0 && canModifyBill != 1) { | |
| 142 | + throw new BusinessException(ResultCode.PARAM_INVALID, "单据修改权限取值非法"); | |
| 143 | + } | |
| 144 | + String rawPassword = StringUtils.hasText(dto.getInitialPassword()) | |
| 145 | + ? dto.getInitialPassword() : DEFAULT_PASSWORD; | |
| 146 | + | |
| 147 | + // 5. 组装实体 + 审计字段。 | |
| 148 | + UsrUser user = new UsrUser(); | |
| 149 | + user.setSUserName(dto.getSUserName()); | |
| 150 | + user.setSUserNo(dto.getSUserNo()); | |
| 151 | + user.setIEmployeeId(dto.getIEmployeeId()); | |
| 152 | + user.setSUserType(userType); | |
| 153 | + user.setSLanguage(dto.getSLanguage()); | |
| 154 | + user.setICanModifyBill(canModifyBill); | |
| 155 | + user.setSPassword(passwordEncoder.encode(rawPassword)); | |
| 156 | + user.setIIsVoid(NOT_VOID); | |
| 157 | + user.setTLastLoginDate(null); | |
| 158 | + user.setSCreator(SecurityUtil.currentUserName()); | |
| 159 | + | |
| 160 | + // 6. 落库(并发唯一冲突兜底转 40901)。 | |
| 161 | + try { | |
| 162 | + usrUserMapper.insert(user); | |
| 163 | + } catch (DuplicateKeyException ex) { | |
| 164 | + throw new BusinessException(ResultCode.USERNAME_EXISTS); | |
| 165 | + } | |
| 166 | + | |
| 167 | + Integer newUserId = user.getIIncrement(); | |
| 168 | + | |
| 169 | + // 7. 权限批量授权写入。 | |
| 170 | + if (dedupedPermissionIds != null && !dedupedPermissionIds.isEmpty()) { | |
| 171 | + for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) { | |
| 172 | + usrUserPermissionMapper.insert(new UsrUserPermission(newUserId, permissionId)); | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + return newUserId; | |
| 177 | + } | |
| 178 | + | |
| 179 | + @Override | |
| 180 | + @Transactional(rollbackFor = Exception.class) | |
| 181 | + public Integer updateUser(Integer id, UpdateUserDTO dto) { | |
| 182 | + // 1. 目标用户存在性校验(不存在 → 40401,不写入任何表)。 | |
| 183 | + UsrUser existing = usrUserMapper.selectById(id); | |
| 184 | + if (existing == null) { | |
| 185 | + throw new BusinessException(ResultCode.NOT_FOUND); | |
| 186 | + } | |
| 187 | + | |
| 188 | + // 2. 关联职员存在性校验(可选)——置于主记录更新前,非法即整体回滚不留副作用。 | |
| 189 | + if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) { | |
| 190 | + throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在"); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // 3. 权限存在性校验 + 去重(可选)——非 null 才参与全量覆盖,元素须存在。 | |
| 194 | + List<Integer> dedupedPermissionIds = null; | |
| 195 | + if (dto.getPermissionIds() != null) { | |
| 196 | + dedupedPermissionIds = dto.getPermissionIds().stream() | |
| 197 | + .filter(java.util.Objects::nonNull) | |
| 198 | + .distinct() | |
| 199 | + .toList(); | |
| 200 | + for (Integer permissionId : dedupedPermissionIds) { | |
| 201 | + if (usrPermissionMapper.selectById(permissionId) == null) { | |
| 202 | + throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId); | |
| 203 | + } | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + // 4. 组装目标实体:仅 set 主键 + 非 null 可更新列。 | |
| 208 | + // 不 set sUserName / sPassword / sCreator / tCreateDate / 租户列, | |
| 209 | + // 依赖 MP updateById「null 字段不参与 SET」语义保持原值不被覆盖。 | |
| 210 | + UsrUser target = new UsrUser(); | |
| 211 | + target.setIIncrement(id); | |
| 212 | + target.setSUserType(dto.getSUserType()); | |
| 213 | + target.setSLanguage(dto.getSLanguage()); | |
| 214 | + if (dto.getSUserNo() != null) { | |
| 215 | + target.setSUserNo(dto.getSUserNo()); | |
| 216 | + } | |
| 217 | + if (dto.getIEmployeeId() != null) { | |
| 218 | + target.setIEmployeeId(dto.getIEmployeeId()); | |
| 219 | + } | |
| 220 | + if (dto.getICanModifyBill() != null) { | |
| 221 | + target.setICanModifyBill(dto.getICanModifyBill()); | |
| 222 | + } | |
| 223 | + if (dto.getIIsVoid() != null) { | |
| 224 | + target.setIIsVoid(dto.getIIsVoid()); | |
| 225 | + } | |
| 226 | + usrUserMapper.updateById(target); | |
| 227 | + | |
| 228 | + // 5. 权限组全量覆盖(permissionIds 非 null):先删该用户全部旧授权, | |
| 229 | + // 再对去重集合逐行插入(空集合则只删不插 = 清空;null 不进入本分支 = 不改)。 | |
| 230 | + if (dedupedPermissionIds != null) { | |
| 231 | + usrUserPermissionMapper.delete(Wrappers.<UsrUserPermission>lambdaQuery() | |
| 232 | + .eq(UsrUserPermission::getIUserId, id)); | |
| 233 | + for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) { | |
| 234 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permissionId)); | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + return id; | |
| 239 | + } | |
| 240 | + | |
| 241 | + @Override | |
| 242 | + @Transactional(readOnly = true) | |
| 243 | + public PageResult<UserVO> queryUsers(UserQueryDTO dto) { | |
| 244 | + // 1. 分页参数判定(先于一切;非法 → 42201,spec § 8 D8)。 | |
| 245 | + long pageNum = dto.getPageNum() != null ? dto.getPageNum() : DEFAULT_PAGE_NUM; | |
| 246 | + long pageSize = dto.getPageSize() != null ? dto.getPageSize() : DEFAULT_PAGE_SIZE; | |
| 247 | + if (pageNum < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { | |
| 248 | + throw new BusinessException(ResultCode.PAGE_PARAM_INVALID); | |
| 249 | + } | |
| 250 | + | |
| 251 | + // 2. 条件解析(单字段单匹配;空值不施加过滤)。 | |
| 252 | + UserQueryCondition cond = parseCondition(dto); | |
| 253 | + | |
| 254 | + // 3. 分页查询。 | |
| 255 | + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(pageNum, pageSize), cond); | |
| 256 | + long total = page.getTotal(); | |
| 257 | + | |
| 258 | + // 4. 数据越界钳制到最后一页(spec § 3.7)。 | |
| 259 | + long pages = total == 0 ? 1 : (total + pageSize - 1) / pageSize; | |
| 260 | + if (total == 0) { | |
| 261 | + return PageResult.of(page.getRecords(), 0L, DEFAULT_PAGE_NUM, pageSize); | |
| 262 | + } | |
| 263 | + if (pageNum > pages) { | |
| 264 | + // 用钳后页号回查一次,保证 records 为最后一页真实数据。 | |
| 265 | + page = usrUserMapper.selectUserPage(new Page<>(pages, pageSize), cond); | |
| 266 | + return PageResult.of(page.getRecords(), total, pages, pageSize); | |
| 267 | + } | |
| 268 | + return PageResult.of(page.getRecords(), total, pageNum, pageSize); | |
| 269 | + } | |
| 270 | + | |
| 271 | + /** | |
| 272 | + * 把 DTO 的中文 queryField / matchType / 原始 queryValue 解析归一为 Mapper 可直接拼 XML 的条件。 | |
| 273 | + * queryValue 为 null / trim 后空 → 无过滤;按字段类型分文本 / 枚举 / 布尔 / 日期解析(非法 → 40001)。 | |
| 274 | + */ | |
| 275 | + private UserQueryCondition parseCondition(UserQueryDTO dto) { | |
| 276 | + String value = dto.getQueryValue(); | |
| 277 | + if (value == null || value.trim().isEmpty()) { | |
| 278 | + return UserQueryCondition.none(); | |
| 279 | + } | |
| 280 | + String field = StringUtils.hasText(dto.getQueryField()) ? dto.getQueryField() : DEFAULT_QUERY_FIELD; | |
| 281 | + String matchType = StringUtils.hasText(dto.getMatchType()) ? dto.getMatchType() : DEFAULT_MATCH_TYPE; | |
| 282 | + String column = FIELD_COLUMN.get(field); | |
| 283 | + if (column == null) { | |
| 284 | + throw new BusinessException(ResultCode.PARAM_INVALID, "查询字段取值非法"); | |
| 285 | + } | |
| 286 | + String trimmed = value.trim(); | |
| 287 | + | |
| 288 | + if (FIELD_VOID.equals(field)) { | |
| 289 | + int boolValue = normalizeBool(trimmed); | |
| 290 | + boolean negated = "不包含".equals(matchType); | |
| 291 | + return UserQueryCondition.bool(column, boolValue, negated); | |
| 292 | + } | |
| 293 | + if (FIELD_LOGIN_DATE.equals(field)) { | |
| 294 | + LocalDateTime start = parseDayStart(trimmed); | |
| 295 | + LocalDateTime end = start.toLocalDate().plusDays(1).atStartOfDay(); | |
| 296 | + boolean negated = "不包含".equals(matchType); | |
| 297 | + return UserQueryCondition.date(column, start, end, negated); | |
| 298 | + } | |
| 299 | + // 文本 / 枚举:等于走 = 不转义;包含 / 不包含走 LIKE,对 % _ \ 转义。 | |
| 300 | + if ("等于".equals(matchType)) { | |
| 301 | + return UserQueryCondition.text(column, matchType, trimmed); | |
| 302 | + } | |
| 303 | + return UserQueryCondition.text(column, matchType, escapeLike(trimmed)); | |
| 304 | + } | |
| 305 | + | |
| 306 | + /** 布尔归一化:接受 0/1、是/否、true/false(spec § 8 D6);不可解析 → 40001。 */ | |
| 307 | + private int normalizeBool(String value) { | |
| 308 | + String v = value.trim().toLowerCase(); | |
| 309 | + if ("1".equals(v) || "是".equals(value.trim()) || "true".equals(v)) { | |
| 310 | + return 1; | |
| 311 | + } | |
| 312 | + if ("0".equals(v) || "否".equals(value.trim()) || "false".equals(v)) { | |
| 313 | + return 0; | |
| 314 | + } | |
| 315 | + throw new BusinessException(ResultCode.PARAM_INVALID, "作废取值不可解析"); | |
| 316 | + } | |
| 317 | + | |
| 318 | + /** 日期解析为当日起点:支持 yyyy-MM-dd 与 yyyy-MM-dd HH:mm:ss(spec § 8 D6);非法 → 40001。 */ | |
| 319 | + private LocalDateTime parseDayStart(String value) { | |
| 320 | + try { | |
| 321 | + if (value.length() > 10) { | |
| 322 | + LocalDateTime dt = LocalDateTime.parse(value, DATETIME_FMT); | |
| 323 | + return dt.toLocalDate().atStartOfDay(); | |
| 324 | + } | |
| 325 | + LocalDate d = LocalDate.parse(value, DATE_FMT); | |
| 326 | + return d.atTime(LocalTime.MIDNIGHT); | |
| 327 | + } catch (DateTimeParseException ex) { | |
| 328 | + throw new BusinessException(ResultCode.PARAM_INVALID, "登录日期取值非法"); | |
| 329 | + } | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** LIKE 通配符转义:对 \ % _ 前置反斜杠(配合 XML 的 ESCAPE '\\',spec § 8 D3)。 */ | |
| 333 | + private String escapeLike(String value) { | |
| 334 | + return value.replace("\\", "\\\\") | |
| 335 | + .replace("%", "\\%") | |
| 336 | + .replace("_", "\\_"); | |
| 337 | + } | |
| 338 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/vo/CompanyOptionVO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import java.io.Serializable; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 公司下拉项输出(spec § 2.3)。REQ-USR-004 T2。 | |
| 8 | + * | |
| 9 | + * <p>供登录页「版本」下拉展示。匈牙利前缀字段({@code sCompanyName}/{@code sVersion})的 | |
| 10 | + * getter 会被 Jackson 推断为大驼峰键,与契约不符,故加 {@link JsonProperty} 锁键; | |
| 11 | + * {@code id} 为常规小驼峰,无需注解。{@code sVersion} 可为 null。</p> | |
| 12 | + */ | |
| 13 | +public class CompanyOptionVO implements Serializable { | |
| 14 | + | |
| 15 | + private static final long serialVersionUID = 1L; | |
| 16 | + | |
| 17 | + /** 公司主键 iIncrement。 */ | |
| 18 | + private Integer id; | |
| 19 | + | |
| 20 | + /** 公司名称(usr_company.sCompanyName)。 */ | |
| 21 | + @JsonProperty("sCompanyName") | |
| 22 | + private String sCompanyName; | |
| 23 | + | |
| 24 | + /** 版本 / 账套标识(usr_company.sVersion,可 null)。 */ | |
| 25 | + @JsonProperty("sVersion") | |
| 26 | + private String sVersion; | |
| 27 | + | |
| 28 | + public Integer getId() { | |
| 29 | + return id; | |
| 30 | + } | |
| 31 | + | |
| 32 | + public void setId(Integer id) { | |
| 33 | + this.id = id; | |
| 34 | + } | |
| 35 | + | |
| 36 | + public String getSCompanyName() { | |
| 37 | + return sCompanyName; | |
| 38 | + } | |
| 39 | + | |
| 40 | + public void setSCompanyName(String sCompanyName) { | |
| 41 | + this.sCompanyName = sCompanyName; | |
| 42 | + } | |
| 43 | + | |
| 44 | + public String getSVersion() { | |
| 45 | + return sVersion; | |
| 46 | + } | |
| 47 | + | |
| 48 | + public void setSVersion(String sVersion) { | |
| 49 | + this.sVersion = sVersion; | |
| 50 | + } | |
| 51 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/vo/LoginVO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import java.io.Serializable; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 登录输出(spec § 2.2 契约)。REQ-USR-004 T3。 | |
| 8 | + * | |
| 9 | + * <p>{@code token} 为 JWT(不含 {@code Bearer } 前缀);嵌套 {@code user} 为登录用户基础信息。 | |
| 10 | + * 严格不含 {@code sPassword} / 租户列。匈牙利前缀字段经 {@link JsonProperty} 锁小驼峰键。</p> | |
| 11 | + */ | |
| 12 | +public class LoginVO implements Serializable { | |
| 13 | + | |
| 14 | + private static final long serialVersionUID = 1L; | |
| 15 | + | |
| 16 | + /** JWT 令牌(不含 Bearer 前缀,前端自行拼 Authorization 头)。 */ | |
| 17 | + private String token; | |
| 18 | + | |
| 19 | + /** 登录用户基础信息(嵌套对象 user{...})。 */ | |
| 20 | + private UserInfo user; | |
| 21 | + | |
| 22 | + public String getToken() { | |
| 23 | + return token; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public void setToken(String token) { | |
| 27 | + this.token = token; | |
| 28 | + } | |
| 29 | + | |
| 30 | + public UserInfo getUser() { | |
| 31 | + return user; | |
| 32 | + } | |
| 33 | + | |
| 34 | + public void setUser(UserInfo user) { | |
| 35 | + this.user = user; | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 登录用户基础信息(嵌套于 {@code user}),来源 usr_user,绝不含密码。 | |
| 40 | + */ | |
| 41 | + public static class UserInfo implements Serializable { | |
| 42 | + | |
| 43 | + private static final long serialVersionUID = 1L; | |
| 44 | + | |
| 45 | + /** 用户主键 iIncrement。 */ | |
| 46 | + private Integer id; | |
| 47 | + | |
| 48 | + /** 用户名。 */ | |
| 49 | + @JsonProperty("sUserName") | |
| 50 | + private String sUserName; | |
| 51 | + | |
| 52 | + /** 用户类型。 */ | |
| 53 | + @JsonProperty("sUserType") | |
| 54 | + private String sUserType; | |
| 55 | + | |
| 56 | + /** 界面语言。 */ | |
| 57 | + @JsonProperty("sLanguage") | |
| 58 | + private String sLanguage; | |
| 59 | + | |
| 60 | + public Integer getId() { | |
| 61 | + return id; | |
| 62 | + } | |
| 63 | + | |
| 64 | + public void setId(Integer id) { | |
| 65 | + this.id = id; | |
| 66 | + } | |
| 67 | + | |
| 68 | + public String getSUserName() { | |
| 69 | + return sUserName; | |
| 70 | + } | |
| 71 | + | |
| 72 | + public void setSUserName(String sUserName) { | |
| 73 | + this.sUserName = sUserName; | |
| 74 | + } | |
| 75 | + | |
| 76 | + public String getSUserType() { | |
| 77 | + return sUserType; | |
| 78 | + } | |
| 79 | + | |
| 80 | + public void setSUserType(String sUserType) { | |
| 81 | + this.sUserType = sUserType; | |
| 82 | + } | |
| 83 | + | |
| 84 | + public String getSLanguage() { | |
| 85 | + return sLanguage; | |
| 86 | + } | |
| 87 | + | |
| 88 | + public void setSLanguage(String sLanguage) { | |
| 89 | + this.sLanguage = sLanguage; | |
| 90 | + } | |
| 91 | + } | |
| 92 | +} | ... | ... |
backend/src/main/java/com/xly/erp/modules/usr/vo/UserVO.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import java.io.Serializable; | |
| 5 | +import java.time.LocalDateTime; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 查询用户输出(spec § 2.2 契约)。REQ-USR-003 T2。 | |
| 9 | + * | |
| 10 | + * <p>严格不含 {@code sPassword} 与租户列({@code sId}/{@code sBrandsId}/{@code sSubsidiaryId})。 | |
| 11 | + * 跨表字段 {@code employeeName}/{@code department} 来自 {@code usr_employee}(LEFT JOIN,可 null)。</p> | |
| 12 | + * | |
| 13 | + * <p>带匈牙利前缀字段({@code sUserName} 等)的 getter 形如 {@code getSUserName} 会被 Jackson | |
| 14 | + * 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对这些字段加 {@link JsonProperty} | |
| 15 | + * 锁定小驼峰键名(与 CreateUserDTO/UpdateUserDTO 同做法)。日期字段 {@code tLastLoginDate}/ | |
| 16 | + * {@code tCreateDate} 的 getter({@code getTLastLoginDate} 等)同样被 Jackson 推断为 | |
| 17 | + * {@code tlastLoginDate}(首段连续大写被小写化),与契约键不符,故一并加 @JsonProperty 锁键。 | |
| 18 | + * {@code employeeName}/{@code department}/{@code id} 为常规小驼峰,无需 @JsonProperty。</p> | |
| 19 | + */ | |
| 20 | +public class UserVO implements Serializable { | |
| 21 | + | |
| 22 | + private static final long serialVersionUID = 1L; | |
| 23 | + | |
| 24 | + /** 用户主键 iIncrement。 */ | |
| 25 | + private Integer id; | |
| 26 | + | |
| 27 | + /** 用户名。 */ | |
| 28 | + @JsonProperty("sUserName") | |
| 29 | + private String sUserName; | |
| 30 | + | |
| 31 | + /** 员工名(usr_employee.sEmployeeName,LEFT JOIN 可 null)。 */ | |
| 32 | + private String employeeName; | |
| 33 | + | |
| 34 | + /** 用户号。 */ | |
| 35 | + @JsonProperty("sUserNo") | |
| 36 | + private String sUserNo; | |
| 37 | + | |
| 38 | + /** 部门(usr_employee.sDepartment,LEFT JOIN 可 null)。 */ | |
| 39 | + private String department; | |
| 40 | + | |
| 41 | + /** 用户类型。 */ | |
| 42 | + @JsonProperty("sUserType") | |
| 43 | + private String sUserType; | |
| 44 | + | |
| 45 | + /** 界面语言。 */ | |
| 46 | + @JsonProperty("sLanguage") | |
| 47 | + private String sLanguage; | |
| 48 | + | |
| 49 | + /** 作废标志:0 正常 / 1 已作废。 */ | |
| 50 | + @JsonProperty("iIsVoid") | |
| 51 | + private Integer iIsVoid; | |
| 52 | + | |
| 53 | + /** 最后登录时间(可 null)。 */ | |
| 54 | + @JsonProperty("tLastLoginDate") | |
| 55 | + private LocalDateTime tLastLoginDate; | |
| 56 | + | |
| 57 | + /** 制单人。 */ | |
| 58 | + @JsonProperty("sCreator") | |
| 59 | + private String sCreator; | |
| 60 | + | |
| 61 | + /** 创建时间。 */ | |
| 62 | + @JsonProperty("tCreateDate") | |
| 63 | + private LocalDateTime tCreateDate; | |
| 64 | + | |
| 65 | + public Integer getId() { | |
| 66 | + return id; | |
| 67 | + } | |
| 68 | + | |
| 69 | + public void setId(Integer id) { | |
| 70 | + this.id = id; | |
| 71 | + } | |
| 72 | + | |
| 73 | + public String getSUserName() { | |
| 74 | + return sUserName; | |
| 75 | + } | |
| 76 | + | |
| 77 | + public void setSUserName(String sUserName) { | |
| 78 | + this.sUserName = sUserName; | |
| 79 | + } | |
| 80 | + | |
| 81 | + public String getEmployeeName() { | |
| 82 | + return employeeName; | |
| 83 | + } | |
| 84 | + | |
| 85 | + public void setEmployeeName(String employeeName) { | |
| 86 | + this.employeeName = employeeName; | |
| 87 | + } | |
| 88 | + | |
| 89 | + public String getSUserNo() { | |
| 90 | + return sUserNo; | |
| 91 | + } | |
| 92 | + | |
| 93 | + public void setSUserNo(String sUserNo) { | |
| 94 | + this.sUserNo = sUserNo; | |
| 95 | + } | |
| 96 | + | |
| 97 | + public String getDepartment() { | |
| 98 | + return department; | |
| 99 | + } | |
| 100 | + | |
| 101 | + public void setDepartment(String department) { | |
| 102 | + this.department = department; | |
| 103 | + } | |
| 104 | + | |
| 105 | + public String getSUserType() { | |
| 106 | + return sUserType; | |
| 107 | + } | |
| 108 | + | |
| 109 | + public void setSUserType(String sUserType) { | |
| 110 | + this.sUserType = sUserType; | |
| 111 | + } | |
| 112 | + | |
| 113 | + public String getSLanguage() { | |
| 114 | + return sLanguage; | |
| 115 | + } | |
| 116 | + | |
| 117 | + public void setSLanguage(String sLanguage) { | |
| 118 | + this.sLanguage = sLanguage; | |
| 119 | + } | |
| 120 | + | |
| 121 | + public Integer getIIsVoid() { | |
| 122 | + return iIsVoid; | |
| 123 | + } | |
| 124 | + | |
| 125 | + public void setIIsVoid(Integer iIsVoid) { | |
| 126 | + this.iIsVoid = iIsVoid; | |
| 127 | + } | |
| 128 | + | |
| 129 | + public LocalDateTime getTLastLoginDate() { | |
| 130 | + return tLastLoginDate; | |
| 131 | + } | |
| 132 | + | |
| 133 | + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { | |
| 134 | + this.tLastLoginDate = tLastLoginDate; | |
| 135 | + } | |
| 136 | + | |
| 137 | + public String getSCreator() { | |
| 138 | + return sCreator; | |
| 139 | + } | |
| 140 | + | |
| 141 | + public void setSCreator(String sCreator) { | |
| 142 | + this.sCreator = sCreator; | |
| 143 | + } | |
| 144 | + | |
| 145 | + public LocalDateTime getTCreateDate() { | |
| 146 | + return tCreateDate; | |
| 147 | + } | |
| 148 | + | |
| 149 | + public void setTCreateDate(LocalDateTime tCreateDate) { | |
| 150 | + this.tCreateDate = tCreateDate; | |
| 151 | + } | |
| 152 | +} | ... | ... |
backend/src/main/resources/application-test.yml
0 → 100644
| 1 | +# application-test.yml — 测试 profile。 | |
| 2 | +# 连同一测试库(xlyweberp_vibe_erp_test,由 setup-test-db.mjs DROP+CREATE 后由 Flyway apply V1)。 | |
| 3 | +# 测试期间 Flyway 自动 apply sql/migrations/ 下的 V1+;DB 凭据沿用主配置默认(来自 config-vars.yaml)。 | |
| 4 | +spring: | |
| 5 | + datasource: | |
| 6 | + driver-class-name: com.mysql.cj.jdbc.Driver | |
| 7 | + url: jdbc:mysql://${DB_HOST:118.178.19.35}:${DB_PORT:3318}/${DB_SCHEMA:xlyweberp_vibe_erp_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true | |
| 8 | + username: ${DB_USER:xlyprint} | |
| 9 | + password: ${DB_PASSWORD:xlyXLYprint2016} | |
| 10 | + flyway: | |
| 11 | + enabled: true | |
| 12 | + locations: filesystem:../sql/migrations | |
| 13 | + baseline-on-migrate: true | |
| 14 | + | |
| 15 | +jwt: | |
| 16 | + secret: ${JWT_SECRET:a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2} | |
| 17 | + expire-millis: 43200000 | ... | ... |
backend/src/main/resources/application.yml
0 → 100644
| 1 | +# application.yml — 小羚羊 ERP 后端主配置。 | |
| 2 | +# 端口 / 数据源 / MyBatis-Plus / Flyway / JWT。 | |
| 3 | +# 敏感值(DB 凭据 / JWT 密钥)通过环境变量注入,单一来源为仓库根 config-vars.yaml, | |
| 4 | +# 由工具脚本 / 部署环境导出为下列 env,禁止硬编码进源码。 | |
| 5 | +server: | |
| 6 | + port: ${BACKEND_HTTP_PORT:5172} | |
| 7 | + | |
| 8 | +spring: | |
| 9 | + application: | |
| 10 | + name: xly-erp-backend | |
| 11 | + datasource: | |
| 12 | + driver-class-name: com.mysql.cj.jdbc.Driver | |
| 13 | + url: jdbc:mysql://${DB_HOST:118.178.19.35}:${DB_PORT:3318}/${DB_SCHEMA:xlyweberp_vibe_erp_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true | |
| 14 | + username: ${DB_USER:xlyprint} | |
| 15 | + password: ${DB_PASSWORD:xlyXLYprint2016} | |
| 16 | + flyway: | |
| 17 | + enabled: true | |
| 18 | + locations: filesystem:../sql/migrations | |
| 19 | + baseline-on-migrate: true | |
| 20 | + table: flyway_schema_history | |
| 21 | + | |
| 22 | +mybatis-plus: | |
| 23 | + configuration: | |
| 24 | + map-underscore-to-camel-case: false | |
| 25 | + global-config: | |
| 26 | + db-config: | |
| 27 | + id-type: auto | |
| 28 | + | |
| 29 | +# JWT 配置:密钥单一来源 config-vars.yaml secrets.jwt_secret,通过 env 注入。 | |
| 30 | +jwt: | |
| 31 | + secret: ${JWT_SECRET:a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2} | |
| 32 | + # 过期时间(毫秒),默认 12 小时 | |
| 33 | + expire-millis: ${JWT_EXPIRE_MILLIS:43200000} | |
| 34 | + | |
| 35 | +# 登录限流(REQ-USR-004 spec § 8 D7):进程内按用户名连续失败计数, | |
| 36 | +# 达 max-fail 次后锁定 lock-seconds 秒。config-vars 无该键,采用默认值并允许 env 覆盖。 | |
| 37 | +auth: | |
| 38 | + login: | |
| 39 | + max-fail: ${AUTH_LOGIN_MAX_FAIL:5} | |
| 40 | + lock-seconds: ${AUTH_LOGIN_LOCK_SECONDS:300} | |
| 41 | + | |
| 42 | +logging: | |
| 43 | + level: | |
| 44 | + com.xly.erp: INFO | ... | ... |
backend/src/main/resources/mapper/usr/UsrUserMapper.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<!-- REQ-USR-003 T3:查询用户跨表分页 SQL(LEFT JOIN + 动态单条件)。 | |
| 5 | + map-underscore-to-camel-case=false,故用显式 resultMap 映射列别名到 UserVO 字段; | |
| 6 | + 不 SELECT u.sPassword 与租户列;值用 #{} 预编译占位,列 token 由 Service 白名单产出。 --> | |
| 7 | +<mapper namespace="com.xly.erp.modules.usr.mapper.UsrUserMapper"> | |
| 8 | + | |
| 9 | + <resultMap id="userVOMap" type="com.xly.erp.modules.usr.vo.UserVO"> | |
| 10 | + <id property="id" column="id"/> | |
| 11 | + <result property="sUserName" column="sUserName"/> | |
| 12 | + <result property="employeeName" column="employeeName"/> | |
| 13 | + <result property="sUserNo" column="sUserNo"/> | |
| 14 | + <result property="department" column="department"/> | |
| 15 | + <result property="sUserType" column="sUserType"/> | |
| 16 | + <result property="sLanguage" column="sLanguage"/> | |
| 17 | + <result property="iIsVoid" column="iIsVoid"/> | |
| 18 | + <result property="tLastLoginDate" column="tLastLoginDate"/> | |
| 19 | + <result property="sCreator" column="sCreator"/> | |
| 20 | + <result property="tCreateDate" column="tCreateDate"/> | |
| 21 | + </resultMap> | |
| 22 | + | |
| 23 | + <select id="selectUserPage" resultMap="userVOMap"> | |
| 24 | + SELECT | |
| 25 | + u.iIncrement AS id, | |
| 26 | + u.sUserName AS sUserName, | |
| 27 | + e.sEmployeeName AS employeeName, | |
| 28 | + u.sUserNo AS sUserNo, | |
| 29 | + e.sDepartment AS department, | |
| 30 | + u.sUserType AS sUserType, | |
| 31 | + u.sLanguage AS sLanguage, | |
| 32 | + u.iIsVoid AS iIsVoid, | |
| 33 | + u.tLastLoginDate AS tLastLoginDate, | |
| 34 | + u.sCreator AS sCreator, | |
| 35 | + u.tCreateDate AS tCreateDate | |
| 36 | + FROM usr_user u | |
| 37 | + LEFT JOIN usr_employee e ON u.iEmployeeId = e.iIncrement | |
| 38 | + <where> | |
| 39 | + <if test="cond != null and cond.text"> | |
| 40 | + <choose> | |
| 41 | + <when test="cond.textEquals"> | |
| 42 | + ${cond.column} = #{cond.textValue} | |
| 43 | + </when> | |
| 44 | + <when test="cond.textNotContains"> | |
| 45 | + ${cond.column} NOT LIKE CONCAT('%', #{cond.textValue}, '%') ESCAPE '\\' | |
| 46 | + </when> | |
| 47 | + <otherwise> | |
| 48 | + ${cond.column} LIKE CONCAT('%', #{cond.textValue}, '%') ESCAPE '\\' | |
| 49 | + </otherwise> | |
| 50 | + </choose> | |
| 51 | + </if> | |
| 52 | + <if test="cond != null and cond.bool"> | |
| 53 | + <choose> | |
| 54 | + <when test="cond.boolNegated"> | |
| 55 | + ${cond.column} <> #{cond.boolValue} | |
| 56 | + </when> | |
| 57 | + <otherwise> | |
| 58 | + ${cond.column} = #{cond.boolValue} | |
| 59 | + </otherwise> | |
| 60 | + </choose> | |
| 61 | + </if> | |
| 62 | + <if test="cond != null and cond.date"> | |
| 63 | + <choose> | |
| 64 | + <when test="cond.dateNegated"> | |
| 65 | + (${cond.column} < #{cond.dateStart} OR ${cond.column} >= #{cond.dateEnd}) | |
| 66 | + </when> | |
| 67 | + <otherwise> | |
| 68 | + ${cond.column} >= #{cond.dateStart} AND ${cond.column} < #{cond.dateEnd} | |
| 69 | + </otherwise> | |
| 70 | + </choose> | |
| 71 | + </if> | |
| 72 | + </where> | |
| 73 | + ORDER BY u.iIncrement | |
| 74 | + </select> | |
| 75 | + | |
| 76 | +</mapper> | ... | ... |
backend/src/test/java/com/xly/erp/ErpApplicationTests.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 | +/** | |
| 8 | + * 骨架健康自检:证明 pom 依赖 / 启动类 / application.yml 自洽, | |
| 9 | + * Flyway 能对测试库 apply V1,MyBatis-Plus 装配成功。 | |
| 10 | + * | |
| 11 | + * <p>REQ-USR-001 T1。</p> | |
| 12 | + */ | |
| 13 | +@SpringBootTest | |
| 14 | +@ActiveProfiles("test") | |
| 15 | +class ErpApplicationTests { | |
| 16 | + | |
| 17 | + @Test | |
| 18 | + void contextLoads() { | |
| 19 | + // Spring 上下文成功加载即通过。 | |
| 20 | + } | |
| 21 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 7 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T3:BCryptPasswordEncoder Bean 行为(轻量单元,直接 new SecurityConfig 取 Bean)。 | |
| 11 | + */ | |
| 12 | +class SecurityConfigTest { | |
| 13 | + | |
| 14 | + @Test | |
| 15 | + void bcryptEncoderEncodesAndMatches() { | |
| 16 | + SecurityConfig config = new SecurityConfig(); | |
| 17 | + PasswordEncoder encoder = config.passwordEncoder(); | |
| 18 | + assertThat(encoder).isInstanceOf(BCryptPasswordEncoder.class); | |
| 19 | + | |
| 20 | + String hash = encoder.encode("666666"); | |
| 21 | + assertThat(hash).isNotEqualTo("666666"); | |
| 22 | + assertThat(encoder.matches("666666", hash)).isTrue(); | |
| 23 | + assertThat(encoder.matches("wrong", hash)).isFalse(); | |
| 24 | + } | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * REQ-USR-004 T5:放行清单含 GET /api/usr/companies(与既有 /api/usr/login 同口径放行)。 | |
| 28 | + */ | |
| 29 | + @Test | |
| 30 | + void companiesEndpointPermitted() { | |
| 31 | + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/companies"); | |
| 32 | + // 不删除既有放行项:/api/usr/login 仍在清单。 | |
| 33 | + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/login"); | |
| 34 | + } | |
| 35 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.xly.erp.common.response.Result; | |
| 6 | +import com.xly.erp.common.response.ResultCode; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T2:全局异常处理器把 BusinessException 转为统一 Result。 | |
| 11 | + */ | |
| 12 | +class GlobalExceptionHandlerTest { | |
| 13 | + | |
| 14 | + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void businessExceptionMapsToResult() { | |
| 18 | + BusinessException ex = new BusinessException(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 19 | + Result<Void> r = handler.handleBusinessException(ex); | |
| 20 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 21 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 22 | + } | |
| 23 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/PageResultTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import java.util.List; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * REQ-USR-003 T1:通用分页响应体 PageResult。 | |
| 10 | + * | |
| 11 | + * <p>验证四字段(records/total/pageNum/pageSize)装配读回一致,空 records 允许。</p> | |
| 12 | + */ | |
| 13 | +class PageResultTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + void ofAssemblesAllFields() { | |
| 17 | + List<String> records = List.of("a", "b", "c"); | |
| 18 | + PageResult<String> page = PageResult.of(records, 23L, 2L, 10L); | |
| 19 | + | |
| 20 | + assertThat(page.getRecords()).isEqualTo(records); | |
| 21 | + assertThat(page.getTotal()).isEqualTo(23L); | |
| 22 | + assertThat(page.getPageNum()).isEqualTo(2L); | |
| 23 | + assertThat(page.getPageSize()).isEqualTo(10L); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void emptyRecordsAllowed() { | |
| 28 | + PageResult<String> page = PageResult.of(List.of(), 0L, 1L, 10L); | |
| 29 | + | |
| 30 | + assertThat(page.getRecords()).isNotNull().isEmpty(); | |
| 31 | + assertThat(page.getTotal()).isZero(); | |
| 32 | + assertThat(page.getPageNum()).isEqualTo(1L); | |
| 33 | + } | |
| 34 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-004 T1:登录相关错误码断言。 | |
| 9 | + * | |
| 10 | + * <p>新增登录限流码 {@code LOGIN_RATE_LIMITED=42901};并确认登录流程复用的既有码 | |
| 11 | + * {@code UNAUTHORIZED=40101} / {@code ACCOUNT_DISABLED=40302} / {@code PARAM_INVALID=40001} | |
| 12 | + * 取值不变(不重复定义、不漂移)。</p> | |
| 13 | + */ | |
| 14 | +class ResultCodeLoginTest { | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void loginRateLimitedCodeIs42901() { | |
| 18 | + assertThat(ResultCode.LOGIN_RATE_LIMITED.getCode()).isEqualTo(42901); | |
| 19 | + assertThat(ResultCode.LOGIN_RATE_LIMITED.getMessage()).isNotBlank(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + void existingLoginCodesPresent() { | |
| 24 | + assertThat(ResultCode.UNAUTHORIZED.getCode()).isEqualTo(40101); | |
| 25 | + assertThat(ResultCode.ACCOUNT_DISABLED.getCode()).isEqualTo(40302); | |
| 26 | + assertThat(ResultCode.PARAM_INVALID.getCode()).isEqualTo(40001); | |
| 27 | + } | |
| 28 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ResultTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T2:统一响应体 Result 行为校验。 | |
| 9 | + */ | |
| 10 | +class ResultTest { | |
| 11 | + | |
| 12 | + @Test | |
| 13 | + void successCarriesCodeZeroAndData() { | |
| 14 | + Result<String> r = Result.success("hello"); | |
| 15 | + assertThat(r.getCode()).isEqualTo(0); | |
| 16 | + assertThat(r.getData()).isEqualTo("hello"); | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + void failCarriesBusinessCodeAndMessage() { | |
| 21 | + Result<Void> r = Result.fail(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 22 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 23 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 24 | + assertThat(r.getData()).isNull(); | |
| 25 | + } | |
| 26 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T3:JWT 签发 / 解析往返。 | |
| 9 | + * | |
| 10 | + * <p>用 config-vars 的 jwt_secret 签发含 sUserName + sUserType claim 的 token, | |
| 11 | + * 解析后能取回相同值且校验通过。</p> | |
| 12 | + */ | |
| 13 | +class JwtUtilTest { | |
| 14 | + | |
| 15 | + private static final String SECRET = | |
| 16 | + "a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2"; | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + void generateThenParseRoundTrip() { | |
| 20 | + JwtUtil jwtUtil = new JwtUtil(SECRET, 43200000L); | |
| 21 | + String token = jwtUtil.generateToken("admin", "超级管理员"); | |
| 22 | + | |
| 23 | + assertThat(jwtUtil.validateToken(token)).isTrue(); | |
| 24 | + assertThat(jwtUtil.getUserName(token)).isEqualTo("admin"); | |
| 25 | + assertThat(jwtUtil.getUserType(token)).isEqualTo("超级管理员"); | |
| 26 | + } | |
| 27 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Value; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.test.context.ActiveProfiles; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * REQ-USR-004 T6:登录限流配置项可解析(spec § 8 D7)。 | |
| 12 | + * | |
| 13 | + * <p>@SpringBootTest + test profile 下断言 auth.login.max-fail / auth.login.lock-seconds | |
| 14 | + * 已声明且能解析为整数(默认 5 / 300),确保 Service @Value 注入不会因缺键启动失败。</p> | |
| 15 | + */ | |
| 16 | +@SpringBootTest | |
| 17 | +@ActiveProfiles("test") | |
| 18 | +class AuthLoginConfigIT { | |
| 19 | + | |
| 20 | + @Value("${auth.login.max-fail}") | |
| 21 | + private int maxFail; | |
| 22 | + | |
| 23 | + @Value("${auth.login.lock-seconds}") | |
| 24 | + private long lockSeconds; | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void loginConfigDefaultsBound() { | |
| 28 | + assertThat(maxFail).isEqualTo(5); | |
| 29 | + assertThat(lockSeconds).isEqualTo(300L); | |
| 30 | + } | |
| 31 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 5 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 7 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 8 | + | |
| 9 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 10 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 11 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 16 | +import java.nio.charset.StandardCharsets; | |
| 17 | +import java.time.LocalDateTime; | |
| 18 | +import java.util.Base64; | |
| 19 | +import java.util.HashMap; | |
| 20 | +import java.util.Map; | |
| 21 | +import org.junit.jupiter.api.AfterEach; | |
| 22 | +import org.junit.jupiter.api.BeforeEach; | |
| 23 | +import org.junit.jupiter.api.Test; | |
| 24 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 25 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 26 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 27 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 28 | +import org.springframework.test.context.ActiveProfiles; | |
| 29 | +import org.springframework.test.web.servlet.MockMvc; | |
| 30 | +import org.springframework.test.web.servlet.MvcResult; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。 | |
| 34 | + * | |
| 35 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1); | |
| 36 | + * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture | |
| 37 | + * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。</p> | |
| 38 | + */ | |
| 39 | +@SpringBootTest | |
| 40 | +@AutoConfigureMockMvc | |
| 41 | +@ActiveProfiles("test") | |
| 42 | +class UsrLoginIT { | |
| 43 | + | |
| 44 | + private static final String USER_PREFIX = "it_login_"; | |
| 45 | + private static final String RL_USER_PREFIX = "it_login_rl_"; | |
| 46 | + private static final String CO_PREFIX = "IT_LOGIN_CO_"; | |
| 47 | + private static final String RAW_PWD = "666666"; | |
| 48 | + | |
| 49 | + @Autowired | |
| 50 | + private MockMvc mockMvc; | |
| 51 | + | |
| 52 | + @Autowired | |
| 53 | + private ObjectMapper objectMapper; | |
| 54 | + | |
| 55 | + @Autowired | |
| 56 | + private PasswordEncoder passwordEncoder; | |
| 57 | + | |
| 58 | + @Autowired | |
| 59 | + private UsrUserMapper usrUserMapper; | |
| 60 | + | |
| 61 | + @Autowired | |
| 62 | + private UsrCompanyMapper usrCompanyMapper; | |
| 63 | + | |
| 64 | + private Integer companyId; | |
| 65 | + | |
| 66 | + @BeforeEach | |
| 67 | + void seedCompany() { | |
| 68 | + UsrCompany c = new UsrCompany(); | |
| 69 | + c.setSCompanyName(CO_PREFIX + "总部"); | |
| 70 | + c.setSVersion("企业版"); | |
| 71 | + usrCompanyMapper.insert(c); | |
| 72 | + companyId = c.getIIncrement(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @AfterEach | |
| 76 | + void cleanup() { | |
| 77 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 78 | + .likeRight(UsrUser::getSUserName, USER_PREFIX)); | |
| 79 | + usrCompanyMapper.delete(Wrappers.<UsrCompany>lambdaQuery() | |
| 80 | + .likeRight(UsrCompany::getSCompanyName, CO_PREFIX)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private Integer insertUser(String userName, int isVoid) { | |
| 84 | + UsrUser u = new UsrUser(); | |
| 85 | + u.setSUserName(userName); | |
| 86 | + u.setSPassword(passwordEncoder.encode(RAW_PWD)); | |
| 87 | + u.setSUserType("普通用户"); | |
| 88 | + u.setSLanguage("中文"); | |
| 89 | + u.setICanModifyBill(0); | |
| 90 | + u.setIIsVoid(isVoid); | |
| 91 | + u.setSCreator("it_login_seed"); | |
| 92 | + usrUserMapper.insert(u); | |
| 93 | + return u.getIIncrement(); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private String loginBody(String userName, String password, Integer compId) throws Exception { | |
| 97 | + Map<String, Object> body = new HashMap<>(); | |
| 98 | + if (userName != null) { | |
| 99 | + body.put("sUserName", userName); | |
| 100 | + } | |
| 101 | + if (password != null) { | |
| 102 | + body.put("password", password); | |
| 103 | + } | |
| 104 | + if (compId != null) { | |
| 105 | + body.put("companyId", compId); | |
| 106 | + } | |
| 107 | + return objectMapper.writeValueAsString(body); | |
| 108 | + } | |
| 109 | + | |
| 110 | + private MvcResult doLogin(String userName, String password, Integer compId) throws Exception { | |
| 111 | + return mockMvc.perform(post("/api/usr/login") | |
| 112 | + .contentType("application/json") | |
| 113 | + .content(loginBody(userName, password, compId))) | |
| 114 | + .andReturn(); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void ac1LoginSuccess() throws Exception { | |
| 119 | + String userName = USER_PREFIX + "ac1"; | |
| 120 | + Integer id = insertUser(userName, 0); | |
| 121 | + | |
| 122 | + MvcResult result = mockMvc.perform(post("/api/usr/login") | |
| 123 | + .contentType("application/json") | |
| 124 | + .content(loginBody(userName, RAW_PWD, companyId))) | |
| 125 | + .andExpect(status().isOk()) | |
| 126 | + .andExpect(jsonPath("$.code").value(0)) | |
| 127 | + .andExpect(jsonPath("$.data.token").isNotEmpty()) | |
| 128 | + .andExpect(jsonPath("$.data.user.id").value(id)) | |
| 129 | + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) | |
| 130 | + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")) | |
| 131 | + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) | |
| 132 | + .andReturn(); | |
| 133 | + | |
| 134 | + String resp = result.getResponse().getContentAsString(); | |
| 135 | + assertThat(resp).doesNotContain("sPassword"); | |
| 136 | + assertThat(resp.toLowerCase()).doesNotContain("password"); | |
| 137 | + assertThat(resp).doesNotContain(RAW_PWD); | |
| 138 | + } | |
| 139 | + | |
| 140 | + @Test | |
| 141 | + void ac2TokenAcceptedByProtectedApi() throws Exception { | |
| 142 | + String userName = USER_PREFIX + "ac2"; | |
| 143 | + insertUser(userName, 0); | |
| 144 | + MvcResult login = doLogin(userName, RAW_PWD, companyId); | |
| 145 | + String token = objectMapper.readTree(login.getResponse().getContentAsString()) | |
| 146 | + .path("data").path("token").asText(); | |
| 147 | + assertThat(token).isNotBlank(); | |
| 148 | + | |
| 149 | + // 用 token 调受保护接口(REQ-USR-003)→ 非 401。 | |
| 150 | + mockMvc.perform(get("/api/usr/users") | |
| 151 | + .param("pageNum", "1").param("pageSize", "10") | |
| 152 | + .header("Authorization", "Bearer " + token)) | |
| 153 | + .andExpect(status().isOk()) | |
| 154 | + .andExpect(jsonPath("$.code").value(0)); | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 158 | + void ac3LoginUpdatesLastLoginDate() throws Exception { | |
| 159 | + String userName = USER_PREFIX + "ac3"; | |
| 160 | + Integer id = insertUser(userName, 0); | |
| 161 | + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); | |
| 162 | + | |
| 163 | + doLogin(userName, RAW_PWD, companyId); | |
| 164 | + | |
| 165 | + LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate(); | |
| 166 | + assertThat(after).isNotNull(); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + void ac4WrongPassword40101() throws Exception { | |
| 171 | + String userName = USER_PREFIX + "ac4"; | |
| 172 | + insertUser(userName, 0); | |
| 173 | + | |
| 174 | + MvcResult result = doLogin(userName, "wrongpwd", companyId); | |
| 175 | + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); | |
| 176 | + assertThat(resp.path("code").asInt()).isEqualTo(40101); | |
| 177 | + assertThat(resp.path("data").isNull()).isTrue(); | |
| 178 | + assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误"); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Test | |
| 182 | + void ac5UserNotFound40101SameAsWrongPassword() throws Exception { | |
| 183 | + String userName = USER_PREFIX + "ac5"; | |
| 184 | + insertUser(userName, 0); | |
| 185 | + | |
| 186 | + // 密码错误响应 | |
| 187 | + String wrongPwdMsg = objectMapper.readTree( | |
| 188 | + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()) | |
| 189 | + .path("message").asText(); | |
| 190 | + | |
| 191 | + // 用户不存在响应 | |
| 192 | + JsonNode notFound = objectMapper.readTree( | |
| 193 | + doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId) | |
| 194 | + .getResponse().getContentAsString()); | |
| 195 | + | |
| 196 | + assertThat(notFound.path("code").asInt()).isEqualTo(40101); | |
| 197 | + assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg); | |
| 198 | + } | |
| 199 | + | |
| 200 | + @Test | |
| 201 | + void ac6DisabledUser40302() throws Exception { | |
| 202 | + String userName = USER_PREFIX + "ac6"; | |
| 203 | + Integer id = insertUser(userName, 1); | |
| 204 | + | |
| 205 | + MvcResult result = doLogin(userName, RAW_PWD, companyId); | |
| 206 | + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); | |
| 207 | + assertThat(resp.path("code").asInt()).isEqualTo(40302); | |
| 208 | + assertThat(resp.path("data").path("token").isMissingNode() | |
| 209 | + || resp.path("data").isNull()).isTrue(); | |
| 210 | + | |
| 211 | + // 禁用登录不更新 tLastLoginDate。 | |
| 212 | + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); | |
| 213 | + } | |
| 214 | + | |
| 215 | + @Test | |
| 216 | + void ac7MissingParam40001() throws Exception { | |
| 217 | + String userName = USER_PREFIX + "ac7"; | |
| 218 | + insertUser(userName, 0); | |
| 219 | + | |
| 220 | + // 缺 sUserName | |
| 221 | + assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId) | |
| 222 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 223 | + // 缺 password | |
| 224 | + assertThat(objectMapper.readTree(doLogin(userName, null, companyId) | |
| 225 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 226 | + // 缺 companyId | |
| 227 | + assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null) | |
| 228 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 229 | + } | |
| 230 | + | |
| 231 | + @Test | |
| 232 | + void ac8IllegalCompanyId40001() throws Exception { | |
| 233 | + String userName = USER_PREFIX + "ac8"; | |
| 234 | + insertUser(userName, 0); | |
| 235 | + | |
| 236 | + JsonNode resp = objectMapper.readTree( | |
| 237 | + doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString()); | |
| 238 | + assertThat(resp.path("code").asInt()).isEqualTo(40001); | |
| 239 | + } | |
| 240 | + | |
| 241 | + @Test | |
| 242 | + void ac9RateLimitAfter5Fails42901() throws Exception { | |
| 243 | + String userName = RL_USER_PREFIX + "ac9"; | |
| 244 | + insertUser(userName, 0); | |
| 245 | + | |
| 246 | + // 连续 5 次密码错误 → 每次 40101 | |
| 247 | + for (int i = 0; i < 5; i++) { | |
| 248 | + JsonNode resp = objectMapper.readTree( | |
| 249 | + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()); | |
| 250 | + assertThat(resp.path("code").asInt()).isEqualTo(40101); | |
| 251 | + } | |
| 252 | + // 第 6 次即使密码正确也 42901(锁定窗内) | |
| 253 | + JsonNode locked = objectMapper.readTree( | |
| 254 | + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()); | |
| 255 | + assertThat(locked.path("code").asInt()).isEqualTo(42901); | |
| 256 | + | |
| 257 | + // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。 | |
| 258 | + String resetUser = RL_USER_PREFIX + "ac9_reset"; | |
| 259 | + insertUser(resetUser, 0); | |
| 260 | + for (int i = 0; i < 3; i++) { | |
| 261 | + doLogin(resetUser, "wrongpwd", companyId); | |
| 262 | + } | |
| 263 | + // 一次成功清零 | |
| 264 | + assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId) | |
| 265 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0); | |
| 266 | + // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数) | |
| 267 | + JsonNode afterReset = objectMapper.readTree( | |
| 268 | + doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString()); | |
| 269 | + assertThat(afterReset.path("code").asInt()).isEqualTo(40101); | |
| 270 | + } | |
| 271 | + | |
| 272 | + @Test | |
| 273 | + void ac10CompaniesListNoToken() throws Exception { | |
| 274 | + mockMvc.perform(get("/api/usr/companies")) | |
| 275 | + .andExpect(status().isOk()) | |
| 276 | + .andExpect(jsonPath("$.code").value(0)) | |
| 277 | + .andExpect(jsonPath("$.data").isArray()) | |
| 278 | + .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists()); | |
| 279 | + } | |
| 280 | + | |
| 281 | + @Test | |
| 282 | + void ac11PasswordNeverLeaks() throws Exception { | |
| 283 | + String userName = USER_PREFIX + "ac11"; | |
| 284 | + insertUser(userName, 0); | |
| 285 | + | |
| 286 | + String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString(); | |
| 287 | + String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString(); | |
| 288 | + | |
| 289 | + for (String resp : new String[] {ok, fail}) { | |
| 290 | + assertThat(resp).doesNotContain("sPassword"); | |
| 291 | + assertThat(resp.toLowerCase()).doesNotContain("password"); | |
| 292 | + assertThat(resp).doesNotContain(RAW_PWD); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + @Test | |
| 297 | + void ac12JwtHasExpiry() throws Exception { | |
| 298 | + String userName = USER_PREFIX + "ac12"; | |
| 299 | + insertUser(userName, 0); | |
| 300 | + | |
| 301 | + String token = objectMapper.readTree( | |
| 302 | + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()) | |
| 303 | + .path("data").path("token").asText(); | |
| 304 | + assertThat(token).isNotBlank(); | |
| 305 | + | |
| 306 | + // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。 | |
| 307 | + String[] segments = token.split("\\."); | |
| 308 | + assertThat(segments.length).isEqualTo(3); | |
| 309 | + String payloadJson = new String( | |
| 310 | + Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8); | |
| 311 | + JsonNode payload = objectMapper.readTree(payloadJson); | |
| 312 | + assertThat(payload.has("exp")).isTrue(); | |
| 313 | + assertThat(payload.has("iat")).isTrue(); | |
| 314 | + assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong()); | |
| 315 | + } | |
| 316 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 5 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 7 | + | |
| 8 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 9 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 10 | +import com.xly.erp.common.security.JwtUtil; | |
| 11 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 16 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 17 | +import java.util.HashMap; | |
| 18 | +import java.util.List; | |
| 19 | +import java.util.Map; | |
| 20 | +import org.junit.jupiter.api.AfterEach; | |
| 21 | +import org.junit.jupiter.api.Test; | |
| 22 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 23 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 24 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 25 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 26 | +import org.springframework.test.context.ActiveProfiles; | |
| 27 | +import org.springframework.test.web.servlet.MockMvc; | |
| 28 | +import org.springframework.test.web.servlet.MvcResult; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * REQ-USR-001 T8:新增用户端到端验收回归(spec § 7)。 | |
| 32 | + * | |
| 33 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 34 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入; | |
| 35 | + * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。</p> | |
| 36 | + */ | |
| 37 | +@SpringBootTest | |
| 38 | +@AutoConfigureMockMvc | |
| 39 | +@ActiveProfiles("test") | |
| 40 | +class UsrUserCreateIT { | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + private MockMvc mockMvc; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + private ObjectMapper objectMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + private JwtUtil jwtUtil; | |
| 50 | + | |
| 51 | + @Autowired | |
| 52 | + private PasswordEncoder passwordEncoder; | |
| 53 | + | |
| 54 | + @Autowired | |
| 55 | + private UsrUserMapper usrUserMapper; | |
| 56 | + | |
| 57 | + @Autowired | |
| 58 | + private UsrUserPermissionMapper usrUserPermissionMapper; | |
| 59 | + | |
| 60 | + @Autowired | |
| 61 | + private UsrPermissionMapper usrPermissionMapper; | |
| 62 | + | |
| 63 | + private static final String CALLER = "it_admin"; | |
| 64 | + | |
| 65 | + @AfterEach | |
| 66 | + void cleanup() { | |
| 67 | + // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。 | |
| 68 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 69 | + .likeRight(UsrUser::getSUserName, "it_user_")); | |
| 70 | + usrPermissionMapper.delete(Wrappers.<UsrPermission>lambdaQuery() | |
| 71 | + .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_")); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private String adminToken() { | |
| 75 | + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private String normalToken() { | |
| 79 | + return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户"); | |
| 80 | + } | |
| 81 | + | |
| 82 | + private Map<String, Object> baseBody(String userName) { | |
| 83 | + Map<String, Object> body = new HashMap<>(); | |
| 84 | + body.put("sUserName", userName); | |
| 85 | + body.put("sLanguage", "中文"); | |
| 86 | + return body; | |
| 87 | + } | |
| 88 | + | |
| 89 | + private int insertPermissionFixture(String codeSuffix) { | |
| 90 | + UsrPermission perm = new UsrPermission(); | |
| 91 | + perm.setSPermissionName("IT权限" + codeSuffix); | |
| 92 | + perm.setSPermissionCode("IT_PERM_" + codeSuffix); | |
| 93 | + usrPermissionMapper.insert(perm); | |
| 94 | + return perm.getIIncrement(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void ac1NormalCreatePersistsHashedPassword() throws Exception { | |
| 99 | + String userName = "it_user_ac1"; | |
| 100 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 101 | + .header("Authorization", adminToken()) | |
| 102 | + .contentType("application/json") | |
| 103 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 104 | + .andExpect(status().isOk()) | |
| 105 | + .andExpect(jsonPath("$.code").value(0)) | |
| 106 | + .andExpect(jsonPath("$.data.id").isNumber()) | |
| 107 | + .andReturn(); | |
| 108 | + | |
| 109 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 110 | + Map<?, ?> data = (Map<?, ?>) resp.get("data"); | |
| 111 | + Integer newId = (Integer) data.get("id"); | |
| 112 | + | |
| 113 | + UsrUser saved = usrUserMapper.selectById(newId); | |
| 114 | + assertThat(saved).isNotNull(); | |
| 115 | + assertThat(saved.getSPassword()).isNotEqualTo("666666"); | |
| 116 | + assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue(); | |
| 117 | + assertThat(saved.getIIsVoid()).isZero(); | |
| 118 | + assertThat(saved.getSCreator()).isEqualTo(CALLER); | |
| 119 | + assertThat(saved.getTCreateDate()).isNotNull(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test | |
| 123 | + void ac2DuplicateUserNameRollsBack() throws Exception { | |
| 124 | + String userName = "it_user_ac2"; | |
| 125 | + mockMvc.perform(post("/api/usr/users") | |
| 126 | + .header("Authorization", adminToken()) | |
| 127 | + .contentType("application/json") | |
| 128 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 129 | + .andExpect(jsonPath("$.code").value(0)); | |
| 130 | + | |
| 131 | + long before = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 132 | + .eq(UsrUser::getSUserName, userName)); | |
| 133 | + | |
| 134 | + mockMvc.perform(post("/api/usr/users") | |
| 135 | + .header("Authorization", adminToken()) | |
| 136 | + .contentType("application/json") | |
| 137 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 138 | + .andExpect(jsonPath("$.code").value(40901)); | |
| 139 | + | |
| 140 | + long after = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 141 | + .eq(UsrUser::getSUserName, userName)); | |
| 142 | + assertThat(after).isEqualTo(before).isEqualTo(1L); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void ac4PermissionGrantWritesRows() throws Exception { | |
| 147 | + int permA = insertPermissionFixture("A"); | |
| 148 | + int permB = insertPermissionFixture("B"); | |
| 149 | + String userName = "it_user_ac4"; | |
| 150 | + Map<String, Object> body = baseBody(userName); | |
| 151 | + body.put("permissionIds", List.of(permA, permB, permA)); | |
| 152 | + | |
| 153 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 154 | + .header("Authorization", adminToken()) | |
| 155 | + .contentType("application/json") | |
| 156 | + .content(objectMapper.writeValueAsString(body))) | |
| 157 | + .andExpect(jsonPath("$.code").value(0)) | |
| 158 | + .andReturn(); | |
| 159 | + | |
| 160 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 161 | + Integer newId = (Integer) ((Map<?, ?>) resp.get("data")).get("id"); | |
| 162 | + | |
| 163 | + long grants = usrUserPermissionMapper.selectCount(Wrappers.<UsrUserPermission>lambdaQuery() | |
| 164 | + .eq(UsrUserPermission::getIUserId, newId)); | |
| 165 | + assertThat(grants).isEqualTo(2L); | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Test | |
| 169 | + void ac5NonAdminForbidden() throws Exception { | |
| 170 | + String userName = "it_user_ac5"; | |
| 171 | + | |
| 172 | + // 普通用户 token → 40301 | |
| 173 | + mockMvc.perform(post("/api/usr/users") | |
| 174 | + .header("Authorization", normalToken()) | |
| 175 | + .contentType("application/json") | |
| 176 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 177 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 178 | + | |
| 179 | + // 无 token → 401(安全链拦截,未认证) | |
| 180 | + mockMvc.perform(post("/api/usr/users") | |
| 181 | + .contentType("application/json") | |
| 182 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 183 | + .andExpect(status().isUnauthorized()); | |
| 184 | + | |
| 185 | + long created = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 186 | + .eq(UsrUser::getSUserName, userName)); | |
| 187 | + assertThat(created).isZero(); | |
| 188 | + } | |
| 189 | + | |
| 190 | + @Test | |
| 191 | + void ac7ResponseHasNoPassword() throws Exception { | |
| 192 | + String userName = "it_user_ac7"; | |
| 193 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 194 | + .header("Authorization", adminToken()) | |
| 195 | + .contentType("application/json") | |
| 196 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 197 | + .andExpect(jsonPath("$.code").value(0)) | |
| 198 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 199 | + .andExpect(jsonPath("$.data.password").doesNotExist()) | |
| 200 | + .andReturn(); | |
| 201 | + | |
| 202 | + String responseBody = result.getResponse().getContentAsString(); | |
| 203 | + assertThat(responseBody).doesNotContain("666666"); | |
| 204 | + assertThat(responseBody.toLowerCase()).doesNotContain("password"); | |
| 205 | + } | |
| 206 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 5 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 7 | + | |
| 8 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 9 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 10 | +import com.xly.erp.common.security.JwtUtil; | |
| 11 | +import com.xly.erp.modules.usr.entity.UsrEmployee; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 13 | +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 15 | +import java.time.LocalDateTime; | |
| 16 | +import org.junit.jupiter.api.AfterEach; | |
| 17 | +import org.junit.jupiter.api.BeforeEach; | |
| 18 | +import org.junit.jupiter.api.Test; | |
| 19 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 20 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 21 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 22 | +import org.springframework.test.context.ActiveProfiles; | |
| 23 | +import org.springframework.test.web.servlet.MockMvc; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * REQ-USR-003 T6:查询用户端到端验收回归(spec § 7 AC1-AC13)。 | |
| 27 | + * | |
| 28 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 29 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;查询无管理员限制 | |
| 30 | + * (任意已认证用户可调用,spec § 8 D5)。每个用例自管理 fixture 清理(命名前缀 | |
| 31 | + * {@code it3_user_} / {@code IT3职员} / {@code IT3_EMP_})。</p> | |
| 32 | + */ | |
| 33 | +@SpringBootTest | |
| 34 | +@AutoConfigureMockMvc | |
| 35 | +@ActiveProfiles("test") | |
| 36 | +class UsrUserQueryIT { | |
| 37 | + | |
| 38 | + private static final String USER_PREFIX = "it3_user_"; | |
| 39 | + private static final String EMP_NAME_PREFIX = "IT3职员"; | |
| 40 | + private static final String EMP_NO_PREFIX = "IT3_EMP_"; | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + private MockMvc mockMvc; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + private ObjectMapper objectMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + private JwtUtil jwtUtil; | |
| 50 | + | |
| 51 | + @Autowired | |
| 52 | + private UsrUserMapper usrUserMapper; | |
| 53 | + | |
| 54 | + @Autowired | |
| 55 | + private UsrEmployeeMapper usrEmployeeMapper; | |
| 56 | + | |
| 57 | + @BeforeEach | |
| 58 | + void cleanupBefore() { | |
| 59 | + purge(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @AfterEach | |
| 63 | + void cleanupAfter() { | |
| 64 | + purge(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + private void purge() { | |
| 68 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 69 | + .likeRight(UsrUser::getSUserName, USER_PREFIX)); | |
| 70 | + usrEmployeeMapper.delete(Wrappers.<UsrEmployee>lambdaQuery() | |
| 71 | + .likeRight(UsrEmployee::getSEmployeeName, EMP_NAME_PREFIX)); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private String token() { | |
| 75 | + return "Bearer " + jwtUtil.generateToken("it3_caller", "普通用户"); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private Integer seedEmployee(String nameSuffix, String dept) { | |
| 79 | + UsrEmployee emp = new UsrEmployee(); | |
| 80 | + emp.setSEmployeeName(EMP_NAME_PREFIX + nameSuffix); | |
| 81 | + emp.setSEmployeeNo(EMP_NO_PREFIX + nameSuffix); | |
| 82 | + emp.setSDepartment(dept); | |
| 83 | + usrEmployeeMapper.insert(emp); | |
| 84 | + return emp.getIIncrement(); | |
| 85 | + } | |
| 86 | + | |
| 87 | + private UsrUser seedUser(String nameSuffix, String creator, String userType) { | |
| 88 | + UsrUser u = new UsrUser(); | |
| 89 | + u.setSUserName(USER_PREFIX + nameSuffix); | |
| 90 | + u.setSPassword("$2a$dummyhashvalue000000000000000000000"); | |
| 91 | + u.setSUserType(userType); | |
| 92 | + u.setSLanguage("中文"); | |
| 93 | + u.setICanModifyBill(0); | |
| 94 | + u.setIIsVoid(0); | |
| 95 | + u.setSCreator(creator); | |
| 96 | + usrUserMapper.insert(u); | |
| 97 | + return u; | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + void ac1EmptyConditionFullPage() throws Exception { | |
| 102 | + seedUser("a1x", "it3_creator", "普通用户"); | |
| 103 | + seedUser("a1y", "it3_creator", "普通用户"); | |
| 104 | + | |
| 105 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 106 | + .param("pageNum", "1").param("pageSize", "10")) | |
| 107 | + .andExpect(status().isOk()) | |
| 108 | + .andExpect(jsonPath("$.code").value(0)) | |
| 109 | + .andExpect(jsonPath("$.data.pageNum").value(1)) | |
| 110 | + .andExpect(jsonPath("$.data.pageSize").value(10)) | |
| 111 | + .andExpect(jsonPath("$.data.records").isArray()) | |
| 112 | + .andExpect(jsonPath("$.data.records[0].sUserName").exists()) | |
| 113 | + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) | |
| 114 | + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void ac2TextContains() throws Exception { | |
| 119 | + seedUser("contains_target", "it3_creator", "普通用户"); | |
| 120 | + seedUser("other_one", "it3_creator", "普通用户"); | |
| 121 | + | |
| 122 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 123 | + .param("queryField", "用户名").param("matchType", "包含") | |
| 124 | + .param("queryValue", "contains_target")) | |
| 125 | + .andExpect(jsonPath("$.code").value(0)) | |
| 126 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "contains_target')]").exists()) | |
| 127 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "other_one')]").doesNotExist()); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + void ac3TextEquals() throws Exception { | |
| 132 | + seedUser("eq", "it3_creator", "普通用户"); | |
| 133 | + seedUser("eq_suffix", "it3_creator", "普通用户"); | |
| 134 | + String exact = USER_PREFIX + "eq"; | |
| 135 | + | |
| 136 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 137 | + .param("queryField", "用户名").param("matchType", "等于") | |
| 138 | + .param("queryValue", exact)) | |
| 139 | + .andExpect(jsonPath("$.code").value(0)) | |
| 140 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 141 | + .andExpect(jsonPath("$.data.records[0].sUserName").value(exact)); | |
| 142 | + } | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + void ac4TextNotContains() throws Exception { | |
| 146 | + seedUser("nc1", "it3_creator_keep", "普通用户"); | |
| 147 | + seedUser("nc2", "it3_creator_drop", "普通用户"); | |
| 148 | + | |
| 149 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 150 | + .param("queryField", "制单人").param("matchType", "不包含") | |
| 151 | + .param("queryValue", "drop").param("pageSize", "100")) | |
| 152 | + .andExpect(jsonPath("$.code").value(0)) | |
| 153 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc1')]").exists()) | |
| 154 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc2')]").doesNotExist()); | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 158 | + void ac5EnumEquals() throws Exception { | |
| 159 | + seedUser("admin_u", "it3_creator", "超级管理员"); | |
| 160 | + seedUser("normal_u", "it3_creator", "普通用户"); | |
| 161 | + | |
| 162 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 163 | + .param("queryField", "用户类型").param("matchType", "等于") | |
| 164 | + .param("queryValue", "超级管理员").param("pageSize", "100")) | |
| 165 | + .andExpect(jsonPath("$.code").value(0)) | |
| 166 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "admin_u')]").exists()) | |
| 167 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "normal_u')]").doesNotExist()); | |
| 168 | + } | |
| 169 | + | |
| 170 | + @Test | |
| 171 | + void ac6BoolFilter() throws Exception { | |
| 172 | + UsrUser voided = seedUser("voided", "it3_creator", "普通用户"); | |
| 173 | + voided.setIIsVoid(1); | |
| 174 | + usrUserMapper.updateById(voided); | |
| 175 | + seedUser("active", "it3_creator", "普通用户"); | |
| 176 | + | |
| 177 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 178 | + .param("queryField", "作废").param("queryValue", "1").param("pageSize", "100")) | |
| 179 | + .andExpect(jsonPath("$.code").value(0)) | |
| 180 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").exists()) | |
| 181 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").doesNotExist()); | |
| 182 | + | |
| 183 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 184 | + .param("queryField", "作废").param("queryValue", "0").param("pageSize", "100")) | |
| 185 | + .andExpect(jsonPath("$.code").value(0)) | |
| 186 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").exists()) | |
| 187 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").doesNotExist()); | |
| 188 | + | |
| 189 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 190 | + .param("queryField", "作废").param("queryValue", "abc")) | |
| 191 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 192 | + } | |
| 193 | + | |
| 194 | + @Test | |
| 195 | + void ac7DateFilter() throws Exception { | |
| 196 | + UsrUser dated = seedUser("dated", "it3_creator", "普通用户"); | |
| 197 | + dated.setTLastLoginDate(LocalDateTime.of(2026, 6, 1, 12, 0, 0)); | |
| 198 | + usrUserMapper.updateById(dated); | |
| 199 | + | |
| 200 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 201 | + .param("queryField", "登录日期").param("matchType", "等于") | |
| 202 | + .param("queryValue", "2026-06-01").param("pageSize", "100")) | |
| 203 | + .andExpect(jsonPath("$.code").value(0)) | |
| 204 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "dated')]").exists()); | |
| 205 | + | |
| 206 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 207 | + .param("queryField", "登录日期").param("matchType", "等于") | |
| 208 | + .param("queryValue", "2026-13-99")) | |
| 209 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 210 | + } | |
| 211 | + | |
| 212 | + @Test | |
| 213 | + void ac8CrossTableEmployeeDept() throws Exception { | |
| 214 | + Integer empId = seedEmployee("fin", "财务部IT3"); | |
| 215 | + UsrUser linked = seedUser("linked", "it3_creator", "普通用户"); | |
| 216 | + linked.setIEmployeeId(empId); | |
| 217 | + usrUserMapper.updateById(linked); | |
| 218 | + seedUser("unlinked", "it3_creator", "普通用户"); | |
| 219 | + | |
| 220 | + // 全量:关联用户带员工名 / 部门,未关联用户两列为 null。 | |
| 221 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "100")) | |
| 222 | + .andExpect(jsonPath("$.code").value(0)) | |
| 223 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX | |
| 224 | + + "linked')].department").value(org.hamcrest.Matchers.hasItem("财务部IT3"))) | |
| 225 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX | |
| 226 | + + "linked')].employeeName").value(org.hamcrest.Matchers.hasItem(EMP_NAME_PREFIX + "fin"))); | |
| 227 | + | |
| 228 | + // 按部门跨表过滤:仅返回所属部门含「财务」的用户。 | |
| 229 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 230 | + .param("queryField", "部门").param("matchType", "包含") | |
| 231 | + .param("queryValue", "财务").param("pageSize", "100")) | |
| 232 | + .andExpect(jsonPath("$.code").value(0)) | |
| 233 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "linked')]").exists()) | |
| 234 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "unlinked')]").doesNotExist()); | |
| 235 | + } | |
| 236 | + | |
| 237 | + @Test | |
| 238 | + void ac9NoMatchEmptyList() throws Exception { | |
| 239 | + seedUser("present", "it3_creator", "普通用户"); | |
| 240 | + | |
| 241 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 242 | + .param("queryField", "用户名").param("matchType", "等于") | |
| 243 | + .param("queryValue", "it3_user_zzz_no_such_user_xyz")) | |
| 244 | + .andExpect(jsonPath("$.code").value(0)) | |
| 245 | + .andExpect(jsonPath("$.data.total").value(0)) | |
| 246 | + .andExpect(jsonPath("$.data.records").isEmpty()); | |
| 247 | + } | |
| 248 | + | |
| 249 | + @Test | |
| 250 | + void ac10DataOutOfRangeLastPage() throws Exception { | |
| 251 | + // 插 3 条同制单人 fixture,按制单人精确过滤 → total=3,pageSize=2 → 2 页;请求 page99 钳到末页 2。 | |
| 252 | + seedUser("clamp1", "it3_clamp_creator", "普通用户"); | |
| 253 | + seedUser("clamp2", "it3_clamp_creator", "普通用户"); | |
| 254 | + seedUser("clamp3", "it3_clamp_creator", "普通用户"); | |
| 255 | + | |
| 256 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 257 | + .param("queryField", "制单人").param("matchType", "等于") | |
| 258 | + .param("queryValue", "it3_clamp_creator") | |
| 259 | + .param("pageNum", "99").param("pageSize", "2")) | |
| 260 | + .andExpect(jsonPath("$.code").value(0)) | |
| 261 | + .andExpect(jsonPath("$.data.total").value(3)) | |
| 262 | + .andExpect(jsonPath("$.data.pageNum").value(2)) | |
| 263 | + .andExpect(jsonPath("$.data.records.length()").value(1)); | |
| 264 | + } | |
| 265 | + | |
| 266 | + @Test | |
| 267 | + void ac11PageParamInvalid() throws Exception { | |
| 268 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageNum", "0")) | |
| 269 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 270 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "0")) | |
| 271 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 272 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "500")) | |
| 273 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 274 | + } | |
| 275 | + | |
| 276 | + @Test | |
| 277 | + void ac12NoToken() throws Exception { | |
| 278 | + mockMvc.perform(get("/api/usr/users")) | |
| 279 | + .andExpect(status().isUnauthorized()); | |
| 280 | + mockMvc.perform(get("/api/usr/users").header("Authorization", "Bearer invalid.token.value")) | |
| 281 | + .andExpect(status().isUnauthorized()); | |
| 282 | + } | |
| 283 | + | |
| 284 | + @Test | |
| 285 | + void ac13PasswordNeverLeaks() throws Exception { | |
| 286 | + seedUser("leakcheck", "it3_creator", "普通用户"); | |
| 287 | + String body = mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 288 | + .param("pageSize", "100")) | |
| 289 | + .andExpect(jsonPath("$.code").value(0)) | |
| 290 | + .andReturn().getResponse().getContentAsString(); | |
| 291 | + | |
| 292 | + assertThat(body).doesNotContain("sPassword"); | |
| 293 | + assertThat(body.toLowerCase()).doesNotContain("password"); | |
| 294 | + assertThat(body).doesNotContain("$2a$"); | |
| 295 | + } | |
| 296 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 5 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 7 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 8 | + | |
| 9 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 10 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 11 | +import com.xly.erp.common.security.JwtUtil; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 14 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 16 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 17 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 18 | +import java.util.HashMap; | |
| 19 | +import java.util.List; | |
| 20 | +import java.util.Map; | |
| 21 | +import org.junit.jupiter.api.AfterEach; | |
| 22 | +import org.junit.jupiter.api.Test; | |
| 23 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 24 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 25 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 26 | +import org.springframework.test.context.ActiveProfiles; | |
| 27 | +import org.springframework.test.web.servlet.MockMvc; | |
| 28 | +import org.springframework.test.web.servlet.MvcResult; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * REQ-USR-002 T5:修改用户端到端验收回归(spec § 7)。 | |
| 32 | + * | |
| 33 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 34 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;真实库读写做端到端确认。 | |
| 35 | + * 每个用例自管理 fixture 清理(命名前缀 {@code it2_user_} / {@code IT2_PERM_})。</p> | |
| 36 | + * | |
| 37 | + * <p>边界说明(spec § 7):AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / | |
| 38 | + * REQ-USR-003 查询接口,尚未实现;本 IT 以「PUT iIsVoid=1 / 改 sUserType 后 selectById | |
| 39 | + * 读回库内 iIsVoid==1 / sUserType 为新值」做后端落库层等价验证,登录 / 查询联动留待对应 REQ | |
| 40 | + * 的 IT 覆盖。</p> | |
| 41 | + */ | |
| 42 | +@SpringBootTest | |
| 43 | +@AutoConfigureMockMvc | |
| 44 | +@ActiveProfiles("test") | |
| 45 | +class UsrUserUpdateIT { | |
| 46 | + | |
| 47 | + @Autowired | |
| 48 | + private MockMvc mockMvc; | |
| 49 | + | |
| 50 | + @Autowired | |
| 51 | + private ObjectMapper objectMapper; | |
| 52 | + | |
| 53 | + @Autowired | |
| 54 | + private JwtUtil jwtUtil; | |
| 55 | + | |
| 56 | + @Autowired | |
| 57 | + private UsrUserMapper usrUserMapper; | |
| 58 | + | |
| 59 | + @Autowired | |
| 60 | + private UsrUserPermissionMapper usrUserPermissionMapper; | |
| 61 | + | |
| 62 | + @Autowired | |
| 63 | + private UsrPermissionMapper usrPermissionMapper; | |
| 64 | + | |
| 65 | + private static final String CALLER = "it2_admin"; | |
| 66 | + | |
| 67 | + @AfterEach | |
| 68 | + void cleanup() { | |
| 69 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 70 | + .likeRight(UsrUser::getSUserName, "it2_user_")); | |
| 71 | + usrPermissionMapper.delete(Wrappers.<UsrPermission>lambdaQuery() | |
| 72 | + .likeRight(UsrPermission::getSPermissionCode, "IT2_PERM_")); | |
| 73 | + } | |
| 74 | + | |
| 75 | + private String adminToken() { | |
| 76 | + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); | |
| 77 | + } | |
| 78 | + | |
| 79 | + private String normalToken() { | |
| 80 | + return "Bearer " + jwtUtil.generateToken("it2_normal", "普通用户"); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private Map<String, Object> createBody(String userName) { | |
| 84 | + Map<String, Object> body = new HashMap<>(); | |
| 85 | + body.put("sUserName", userName); | |
| 86 | + body.put("sLanguage", "中文"); | |
| 87 | + return body; | |
| 88 | + } | |
| 89 | + | |
| 90 | + private int createUser(String userName) throws Exception { | |
| 91 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 92 | + .header("Authorization", adminToken()) | |
| 93 | + .contentType("application/json") | |
| 94 | + .content(objectMapper.writeValueAsString(createBody(userName)))) | |
| 95 | + .andExpect(jsonPath("$.code").value(0)) | |
| 96 | + .andReturn(); | |
| 97 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 98 | + return (Integer) ((Map<?, ?>) resp.get("data")).get("id"); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private int insertPermissionFixture(String codeSuffix) { | |
| 102 | + UsrPermission perm = new UsrPermission(); | |
| 103 | + perm.setSPermissionName("IT2权限" + codeSuffix); | |
| 104 | + perm.setSPermissionCode("IT2_PERM_" + codeSuffix); | |
| 105 | + usrPermissionMapper.insert(perm); | |
| 106 | + return perm.getIIncrement(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // ---------------- AC1:基本信息修改落库,且身份 / 密码 / 审计列不变 ---------------- | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + void ac1UpdateBasicInfoPersists() throws Exception { | |
| 113 | + int id = createUser("it2_user_ac1"); | |
| 114 | + UsrUser before = usrUserMapper.selectById(id); | |
| 115 | + | |
| 116 | + Map<String, Object> body = new HashMap<>(); | |
| 117 | + body.put("sUserType", "超级管理员"); | |
| 118 | + body.put("sLanguage", "英文"); | |
| 119 | + body.put("iCanModifyBill", 1); | |
| 120 | + body.put("sUserNo", "NO-AC1"); | |
| 121 | + | |
| 122 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 123 | + .header("Authorization", adminToken()) | |
| 124 | + .contentType("application/json") | |
| 125 | + .content(objectMapper.writeValueAsString(body))) | |
| 126 | + .andExpect(status().isOk()) | |
| 127 | + .andExpect(jsonPath("$.code").value(0)) | |
| 128 | + .andExpect(jsonPath("$.data.id").value(id)); | |
| 129 | + | |
| 130 | + UsrUser after = usrUserMapper.selectById(id); | |
| 131 | + assertThat(after.getSUserType()).isEqualTo("超级管理员"); | |
| 132 | + assertThat(after.getSLanguage()).isEqualTo("英文"); | |
| 133 | + assertThat(after.getICanModifyBill()).isEqualTo(1); | |
| 134 | + assertThat(after.getSUserNo()).isEqualTo("NO-AC1"); | |
| 135 | + // 身份 / 密码 / 审计列字节级不变。 | |
| 136 | + assertThat(after.getSUserName()).isEqualTo(before.getSUserName()); | |
| 137 | + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); | |
| 138 | + assertThat(after.getSCreator()).isEqualTo(before.getSCreator()); | |
| 139 | + assertThat(after.getTCreateDate()).isEqualTo(before.getTCreateDate()); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // ---------------- AC2:目标用户不存在 → 40401,无写入 ---------------- | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + void ac2UpdateNonExistentReturns40401() throws Exception { | |
| 146 | + Map<String, Object> body = new HashMap<>(); | |
| 147 | + body.put("sUserType", "普通用户"); | |
| 148 | + body.put("sLanguage", "中文"); | |
| 149 | + | |
| 150 | + mockMvc.perform(put("/api/usr/users/2000000001") | |
| 151 | + .header("Authorization", adminToken()) | |
| 152 | + .contentType("application/json") | |
| 153 | + .content(objectMapper.writeValueAsString(body))) | |
| 154 | + .andExpect(status().isOk()) | |
| 155 | + .andExpect(jsonPath("$.code").value(40401)); | |
| 156 | + | |
| 157 | + assertThat(usrUserMapper.selectById(2000000001)).isNull(); | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ---------------- AC3:非法参数(不存在职员)→ 40001,整体回滚无副作用 ---------------- | |
| 161 | + | |
| 162 | + @Test | |
| 163 | + void ac3InvalidParamRollsBack() throws Exception { | |
| 164 | + int id = createUser("it2_user_ac3"); | |
| 165 | + UsrUser before = usrUserMapper.selectById(id); | |
| 166 | + | |
| 167 | + Map<String, Object> body = new HashMap<>(); | |
| 168 | + body.put("sUserType", "超级管理员"); | |
| 169 | + body.put("sLanguage", "英文"); | |
| 170 | + body.put("iEmployeeId", 2000000002); // 不存在的职员 | |
| 171 | + | |
| 172 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 173 | + .header("Authorization", adminToken()) | |
| 174 | + .contentType("application/json") | |
| 175 | + .content(objectMapper.writeValueAsString(body))) | |
| 176 | + .andExpect(status().isOk()) | |
| 177 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 178 | + | |
| 179 | + UsrUser after = usrUserMapper.selectById(id); | |
| 180 | + // 目标用户行各列与调用前一致(无副作用)。 | |
| 181 | + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); | |
| 182 | + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); | |
| 183 | + assertThat(after.getIEmployeeId()).isEqualTo(before.getIEmployeeId()); | |
| 184 | + } | |
| 185 | + | |
| 186 | + // ---------------- AC6:权限组全量覆盖 / 清空 / 不改 ---------------- | |
| 187 | + | |
| 188 | + @Test | |
| 189 | + void ac6PermissionOverwrite() throws Exception { | |
| 190 | + int id = createUser("it2_user_ac6"); | |
| 191 | + int permA = insertPermissionFixture("A"); | |
| 192 | + int permB = insertPermissionFixture("B"); | |
| 193 | + int permC = insertPermissionFixture("C"); | |
| 194 | + // 预置该用户授权为 {a, c} | |
| 195 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); | |
| 196 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permC)); | |
| 197 | + | |
| 198 | + Map<String, Object> body = new HashMap<>(); | |
| 199 | + body.put("sUserType", "普通用户"); | |
| 200 | + body.put("sLanguage", "中文"); | |
| 201 | + body.put("permissionIds", List.of(permA, permB, permA)); // 去重后 {a, b} | |
| 202 | + | |
| 203 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 204 | + .header("Authorization", adminToken()) | |
| 205 | + .contentType("application/json") | |
| 206 | + .content(objectMapper.writeValueAsString(body))) | |
| 207 | + .andExpect(jsonPath("$.code").value(0)); | |
| 208 | + | |
| 209 | + List<UsrUserPermission> grants = usrUserPermissionMapper.selectList( | |
| 210 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 211 | + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) | |
| 212 | + .containsExactlyInAnyOrder(permA, permB); // c 被删、b 新增、a 去重一次 | |
| 213 | + | |
| 214 | + // 传 [] → 清空全部授权 | |
| 215 | + Map<String, Object> clearBody = new HashMap<>(); | |
| 216 | + clearBody.put("sUserType", "普通用户"); | |
| 217 | + clearBody.put("sLanguage", "中文"); | |
| 218 | + clearBody.put("permissionIds", List.of()); | |
| 219 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 220 | + .header("Authorization", adminToken()) | |
| 221 | + .contentType("application/json") | |
| 222 | + .content(objectMapper.writeValueAsString(clearBody))) | |
| 223 | + .andExpect(jsonPath("$.code").value(0)); | |
| 224 | + long afterClear = usrUserPermissionMapper.selectCount( | |
| 225 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 226 | + assertThat(afterClear).isZero(); | |
| 227 | + | |
| 228 | + // 预置一条授权后,不传 permissionIds → 授权不变 | |
| 229 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); | |
| 230 | + Map<String, Object> noPermBody = new HashMap<>(); | |
| 231 | + noPermBody.put("sUserType", "普通用户"); | |
| 232 | + noPermBody.put("sLanguage", "中文"); | |
| 233 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 234 | + .header("Authorization", adminToken()) | |
| 235 | + .contentType("application/json") | |
| 236 | + .content(objectMapper.writeValueAsString(noPermBody))) | |
| 237 | + .andExpect(jsonPath("$.code").value(0)); | |
| 238 | + long afterNoChange = usrUserPermissionMapper.selectCount( | |
| 239 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 240 | + assertThat(afterNoChange).isEqualTo(1L); | |
| 241 | + } | |
| 242 | + | |
| 243 | + // ---------------- AC7:非管理员 / 无 token 被拦截,目标行未被修改 ---------------- | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + void ac7NonAdminAndNoTokenBlocked() throws Exception { | |
| 247 | + int id = createUser("it2_user_ac7"); | |
| 248 | + UsrUser before = usrUserMapper.selectById(id); | |
| 249 | + | |
| 250 | + Map<String, Object> body = new HashMap<>(); | |
| 251 | + body.put("sUserType", "超级管理员"); | |
| 252 | + body.put("sLanguage", "英文"); | |
| 253 | + | |
| 254 | + // 普通用户 token → 40301 | |
| 255 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 256 | + .header("Authorization", normalToken()) | |
| 257 | + .contentType("application/json") | |
| 258 | + .content(objectMapper.writeValueAsString(body))) | |
| 259 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 260 | + | |
| 261 | + // 无 token → 401(安全链拦截) | |
| 262 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 263 | + .contentType("application/json") | |
| 264 | + .content(objectMapper.writeValueAsString(body))) | |
| 265 | + .andExpect(status().isUnauthorized()); | |
| 266 | + | |
| 267 | + UsrUser after = usrUserMapper.selectById(id); | |
| 268 | + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); | |
| 269 | + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); | |
| 270 | + } | |
| 271 | + | |
| 272 | + // ---------------- AC8:密码不变且不出现在响应;iIsVoid 落库(AC4/AC5 落库层等价验证) ---------------- | |
| 273 | + | |
| 274 | + @Test | |
| 275 | + void ac8PasswordUnchangedAndAbsentFromResponse() throws Exception { | |
| 276 | + int id = createUser("it2_user_ac8"); | |
| 277 | + UsrUser before = usrUserMapper.selectById(id); | |
| 278 | + | |
| 279 | + Map<String, Object> body = new HashMap<>(); | |
| 280 | + body.put("sUserType", "超级管理员"); // AC5 角色变更落库层等价验证 | |
| 281 | + body.put("sLanguage", "中文"); | |
| 282 | + body.put("iIsVoid", 1); // AC4 禁用落库层等价验证 | |
| 283 | + | |
| 284 | + MvcResult result = mockMvc.perform(put("/api/usr/users/" + id) | |
| 285 | + .header("Authorization", adminToken()) | |
| 286 | + .contentType("application/json") | |
| 287 | + .content(objectMapper.writeValueAsString(body))) | |
| 288 | + .andExpect(jsonPath("$.code").value(0)) | |
| 289 | + .andExpect(jsonPath("$.data.id").value(id)) | |
| 290 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 291 | + .andExpect(jsonPath("$.data.password").doesNotExist()) | |
| 292 | + .andReturn(); | |
| 293 | + | |
| 294 | + String responseBody = result.getResponse().getContentAsString(); | |
| 295 | + assertThat(responseBody.toLowerCase()).doesNotContain("password"); | |
| 296 | + | |
| 297 | + UsrUser after = usrUserMapper.selectById(id); | |
| 298 | + // 密码列字节级不变。 | |
| 299 | + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); | |
| 300 | + // AC4 / AC5 落库层等价验证:禁用状态 / 角色变更读回库内为新值。 | |
| 301 | + assertThat(after.getIIsVoid()).isEqualTo(1); | |
| 302 | + assertThat(after.getSUserType()).isEqualTo("超级管理员"); | |
| 303 | + } | |
| 304 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import static org.mockito.ArgumentMatchers.any; | |
| 4 | +import static org.mockito.Mockito.never; | |
| 5 | +import static org.mockito.Mockito.verify; | |
| 6 | +import static org.mockito.Mockito.when; | |
| 7 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 8 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 9 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 10 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 11 | + | |
| 12 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 13 | +import com.xly.erp.common.exception.BusinessException; | |
| 14 | +import com.xly.erp.common.exception.GlobalExceptionHandler; | |
| 15 | +import com.xly.erp.common.response.ResultCode; | |
| 16 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 17 | +import com.xly.erp.modules.usr.service.UsrAuthService; | |
| 18 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 19 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 20 | +import java.util.List; | |
| 21 | +import org.junit.jupiter.api.BeforeEach; | |
| 22 | +import org.junit.jupiter.api.Test; | |
| 23 | +import org.mockito.Mockito; | |
| 24 | +import org.springframework.test.web.servlet.MockMvc; | |
| 25 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * REQ-USR-004 T5:UsrAuthController(MockMvc standaloneSetup + 真实 GlobalExceptionHandler)。 | |
| 29 | + * | |
| 30 | + * <p>Service 用 Mockito mock;验证登录成功 code=0 + token + 嵌套 user、缺字段 40001、 | |
| 31 | + * 业务异常透传转码(40101/40302/42901)、公司列表 code=0。响应体不含密码。</p> | |
| 32 | + */ | |
| 33 | +class UsrAuthControllerTest { | |
| 34 | + | |
| 35 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 36 | + private final UsrAuthService usrAuthService = Mockito.mock(UsrAuthService.class); | |
| 37 | + private MockMvc mockMvc; | |
| 38 | + | |
| 39 | + @BeforeEach | |
| 40 | + void setUp() { | |
| 41 | + UsrAuthController controller = new UsrAuthController(usrAuthService); | |
| 42 | + mockMvc = MockMvcBuilders.standaloneSetup(controller) | |
| 43 | + .setControllerAdvice(new GlobalExceptionHandler()) | |
| 44 | + .build(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + private String validBody() throws Exception { | |
| 48 | + LoginDTO dto = new LoginDTO(); | |
| 49 | + dto.setSUserName("admin"); | |
| 50 | + dto.setPassword("666666"); | |
| 51 | + dto.setCompanyId(1); | |
| 52 | + return objectMapper.writeValueAsString(dto); | |
| 53 | + } | |
| 54 | + | |
| 55 | + private LoginVO sampleVO() { | |
| 56 | + LoginVO.UserInfo info = new LoginVO.UserInfo(); | |
| 57 | + info.setId(1); | |
| 58 | + info.setSUserName("admin"); | |
| 59 | + info.setSUserType("超级管理员"); | |
| 60 | + info.setSLanguage("中文"); | |
| 61 | + LoginVO vo = new LoginVO(); | |
| 62 | + vo.setToken("jwt.token.value"); | |
| 63 | + vo.setUser(info); | |
| 64 | + return vo; | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void loginReturnsCodeZeroWithTokenAndUser() throws Exception { | |
| 69 | + when(usrAuthService.login(any(LoginDTO.class))).thenReturn(sampleVO()); | |
| 70 | + | |
| 71 | + mockMvc.perform(post("/api/usr/login") | |
| 72 | + .contentType("application/json") | |
| 73 | + .content(validBody())) | |
| 74 | + .andExpect(status().isOk()) | |
| 75 | + .andExpect(jsonPath("$.code").value(0)) | |
| 76 | + .andExpect(jsonPath("$.data.token").value("jwt.token.value")) | |
| 77 | + .andExpect(jsonPath("$.data.user.id").value(1)) | |
| 78 | + .andExpect(jsonPath("$.data.user.sUserName").value("admin")) | |
| 79 | + .andExpect(jsonPath("$.data.user.sUserType").value("超级管理员")) | |
| 80 | + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) | |
| 81 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 82 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void loginMissingFieldReturns40001() throws Exception { | |
| 87 | + // 缺 companyId(@NotNull 失败) | |
| 88 | + String body = "{\"sUserName\":\"admin\",\"password\":\"666666\"}"; | |
| 89 | + mockMvc.perform(post("/api/usr/login") | |
| 90 | + .contentType("application/json") | |
| 91 | + .content(body)) | |
| 92 | + .andExpect(status().isOk()) | |
| 93 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 94 | + verify(usrAuthService, never()).login(any(LoginDTO.class)); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void loginAuthFailReturns40101() throws Exception { | |
| 99 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 100 | + .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED)); | |
| 101 | + mockMvc.perform(post("/api/usr/login") | |
| 102 | + .contentType("application/json") | |
| 103 | + .content(validBody())) | |
| 104 | + .andExpect(status().isOk()) | |
| 105 | + .andExpect(jsonPath("$.code").value(40101)); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + void loginDisabledReturns40302() throws Exception { | |
| 110 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 111 | + .thenThrow(new BusinessException(ResultCode.ACCOUNT_DISABLED)); | |
| 112 | + mockMvc.perform(post("/api/usr/login") | |
| 113 | + .contentType("application/json") | |
| 114 | + .content(validBody())) | |
| 115 | + .andExpect(status().isOk()) | |
| 116 | + .andExpect(jsonPath("$.code").value(40302)); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + void loginRateLimitedReturns42901() throws Exception { | |
| 121 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 122 | + .thenThrow(new BusinessException(ResultCode.LOGIN_RATE_LIMITED)); | |
| 123 | + mockMvc.perform(post("/api/usr/login") | |
| 124 | + .contentType("application/json") | |
| 125 | + .content(validBody())) | |
| 126 | + .andExpect(status().isOk()) | |
| 127 | + .andExpect(jsonPath("$.code").value(42901)); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + void companiesReturnsCodeZeroList() throws Exception { | |
| 132 | + CompanyOptionVO vo = new CompanyOptionVO(); | |
| 133 | + vo.setId(3); | |
| 134 | + vo.setSCompanyName("总部"); | |
| 135 | + vo.setSVersion("企业版"); | |
| 136 | + when(usrAuthService.listCompanies()).thenReturn(List.of(vo)); | |
| 137 | + | |
| 138 | + mockMvc.perform(get("/api/usr/companies")) | |
| 139 | + .andExpect(status().isOk()) | |
| 140 | + .andExpect(jsonPath("$.code").value(0)) | |
| 141 | + .andExpect(jsonPath("$.data[0].id").value(3)) | |
| 142 | + .andExpect(jsonPath("$.data[0].sCompanyName").value("总部")); | |
| 143 | + } | |
| 144 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import static org.mockito.ArgumentMatchers.any; | |
| 4 | +import static org.mockito.Mockito.never; | |
| 5 | +import static org.mockito.Mockito.verify; | |
| 6 | +import static org.mockito.Mockito.when; | |
| 7 | +import static org.mockito.ArgumentMatchers.eq; | |
| 8 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 9 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 10 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 11 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 12 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 13 | + | |
| 14 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 15 | +import com.xly.erp.common.exception.BusinessException; | |
| 16 | +import com.xly.erp.common.exception.GlobalExceptionHandler; | |
| 17 | +import com.xly.erp.common.response.PageResult; | |
| 18 | +import com.xly.erp.common.response.ResultCode; | |
| 19 | +import com.xly.erp.common.security.SecurityUtil; | |
| 20 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 21 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 22 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 23 | +import com.xly.erp.modules.usr.service.UsrUserService; | |
| 24 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 25 | +import java.util.List; | |
| 26 | +import org.junit.jupiter.api.AfterEach; | |
| 27 | +import org.junit.jupiter.api.BeforeEach; | |
| 28 | +import org.junit.jupiter.api.Test; | |
| 29 | +import org.mockito.MockedStatic; | |
| 30 | +import org.mockito.Mockito; | |
| 31 | +import org.springframework.test.web.servlet.MockMvc; | |
| 32 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * REQ-USR-001 T7:UsrUserController 与管理员权限前置。 | |
| 36 | + * | |
| 37 | + * <p>采用 MockMvc standaloneSetup 做 Controller 层单元测试(不加载 Spring 上下文, | |
| 38 | + * 避免 @MapperScan / Security 全量装配的切片摩擦),挂上真实 {@link GlobalExceptionHandler} | |
| 39 | + * 以验证 @Valid 失败转 40001、BusinessException 转 40301;UsrUserService 用 Mockito mock, | |
| 40 | + * 管理员判定通过 mock SecurityUtil 静态方法注入。</p> | |
| 41 | + */ | |
| 42 | +class UsrUserControllerTest { | |
| 43 | + | |
| 44 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 45 | + private final UsrUserService usrUserService = Mockito.mock(UsrUserService.class); | |
| 46 | + private MockMvc mockMvc; | |
| 47 | + private MockedStatic<SecurityUtil> securityUtilMock; | |
| 48 | + | |
| 49 | + @BeforeEach | |
| 50 | + void setUp() { | |
| 51 | + UsrUserController controller = new UsrUserController(usrUserService); | |
| 52 | + mockMvc = MockMvcBuilders.standaloneSetup(controller) | |
| 53 | + .setControllerAdvice(new GlobalExceptionHandler()) | |
| 54 | + .build(); | |
| 55 | + securityUtilMock = Mockito.mockStatic(SecurityUtil.class); | |
| 56 | + } | |
| 57 | + | |
| 58 | + @AfterEach | |
| 59 | + void tearDown() { | |
| 60 | + securityUtilMock.close(); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private String validBody() throws Exception { | |
| 64 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 65 | + dto.setSUserName("good_user"); | |
| 66 | + dto.setSLanguage("中文"); | |
| 67 | + return objectMapper.writeValueAsString(dto); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void adminCreateReturnsCodeZeroWithId() throws Exception { | |
| 72 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 73 | + when(usrUserService.createUser(any(CreateUserDTO.class))).thenReturn(101); | |
| 74 | + | |
| 75 | + mockMvc.perform(post("/api/usr/users") | |
| 76 | + .contentType("application/json") | |
| 77 | + .content(validBody())) | |
| 78 | + .andExpect(status().isOk()) | |
| 79 | + .andExpect(jsonPath("$.code").value(0)) | |
| 80 | + .andExpect(jsonPath("$.data.id").value(101)) | |
| 81 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 82 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void nonAdminReturns40301() throws Exception { | |
| 87 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 88 | + | |
| 89 | + mockMvc.perform(post("/api/usr/users") | |
| 90 | + .contentType("application/json") | |
| 91 | + .content(validBody())) | |
| 92 | + .andExpect(status().isOk()) | |
| 93 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 94 | + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void invalidBodyReturns40001() throws Exception { | |
| 99 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 100 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 101 | + dto.setSUserName("ab"); | |
| 102 | + dto.setSLanguage("中文"); | |
| 103 | + String body = objectMapper.writeValueAsString(dto); | |
| 104 | + | |
| 105 | + mockMvc.perform(post("/api/usr/users") | |
| 106 | + .contentType("application/json") | |
| 107 | + .content(body)) | |
| 108 | + .andExpect(status().isOk()) | |
| 109 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 110 | + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + // ---------------- REQ-USR-002 T4:修改用户 Controller + 管理员前置 ---------------- | |
| 114 | + | |
| 115 | + private String validUpdateBody() throws Exception { | |
| 116 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 117 | + dto.setSUserType("普通用户"); | |
| 118 | + dto.setSLanguage("中文"); | |
| 119 | + return objectMapper.writeValueAsString(dto); | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test | |
| 123 | + void adminUpdateReturnsCodeZeroWithId() throws Exception { | |
| 124 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 125 | + when(usrUserService.updateUser(eq(55), any(UpdateUserDTO.class))).thenReturn(55); | |
| 126 | + | |
| 127 | + mockMvc.perform(put("/api/usr/users/55") | |
| 128 | + .contentType("application/json") | |
| 129 | + .content(validUpdateBody())) | |
| 130 | + .andExpect(status().isOk()) | |
| 131 | + .andExpect(jsonPath("$.code").value(0)) | |
| 132 | + .andExpect(jsonPath("$.data.id").value(55)) | |
| 133 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 134 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + void nonAdminUpdateReturns40301() throws Exception { | |
| 139 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 140 | + | |
| 141 | + mockMvc.perform(put("/api/usr/users/55") | |
| 142 | + .contentType("application/json") | |
| 143 | + .content(validUpdateBody())) | |
| 144 | + .andExpect(status().isOk()) | |
| 145 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 146 | + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); | |
| 147 | + } | |
| 148 | + | |
| 149 | + @Test | |
| 150 | + void invalidBodyUpdateReturns40001() throws Exception { | |
| 151 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 152 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 153 | + dto.setSLanguage("中文"); // 缺 sUserType → @NotBlank 失败 | |
| 154 | + String body = objectMapper.writeValueAsString(dto); | |
| 155 | + | |
| 156 | + mockMvc.perform(put("/api/usr/users/55") | |
| 157 | + .contentType("application/json") | |
| 158 | + .content(body)) | |
| 159 | + .andExpect(status().isOk()) | |
| 160 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 161 | + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); | |
| 162 | + } | |
| 163 | + | |
| 164 | + @Test | |
| 165 | + void userNotFoundReturns40401() throws Exception { | |
| 166 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 167 | + when(usrUserService.updateUser(eq(404), any(UpdateUserDTO.class))) | |
| 168 | + .thenThrow(new BusinessException(ResultCode.NOT_FOUND)); | |
| 169 | + | |
| 170 | + mockMvc.perform(put("/api/usr/users/404") | |
| 171 | + .contentType("application/json") | |
| 172 | + .content(validUpdateBody())) | |
| 173 | + .andExpect(status().isOk()) | |
| 174 | + .andExpect(jsonPath("$.code").value(40401)); | |
| 175 | + } | |
| 176 | + | |
| 177 | + // ---------------- REQ-USR-003 T5:查询用户 Controller GET /api/usr/users(无管理员前置)---------------- | |
| 178 | + | |
| 179 | + private PageResult<UserVO> onePageResult() { | |
| 180 | + UserVO vo = new UserVO(); | |
| 181 | + vo.setId(1); | |
| 182 | + vo.setSUserName("alice"); | |
| 183 | + vo.setSUserType("普通用户"); | |
| 184 | + vo.setSLanguage("中文"); | |
| 185 | + vo.setIIsVoid(0); | |
| 186 | + return PageResult.of(List.of(vo), 1L, 1L, 10L); | |
| 187 | + } | |
| 188 | + | |
| 189 | + @Test | |
| 190 | + void queryReturnsCodeZeroWithPageResult() throws Exception { | |
| 191 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); | |
| 192 | + | |
| 193 | + mockMvc.perform(get("/api/usr/users").param("pageNum", "1").param("pageSize", "10")) | |
| 194 | + .andExpect(status().isOk()) | |
| 195 | + .andExpect(jsonPath("$.code").value(0)) | |
| 196 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 197 | + .andExpect(jsonPath("$.data.pageNum").value(1)) | |
| 198 | + .andExpect(jsonPath("$.data.pageSize").value(10)) | |
| 199 | + .andExpect(jsonPath("$.data.records[0].sUserName").value("alice")) | |
| 200 | + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) | |
| 201 | + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); | |
| 202 | + } | |
| 203 | + | |
| 204 | + @Test | |
| 205 | + void queryAllowsNonAdmin() throws Exception { | |
| 206 | + // 无管理员前置(spec § 8 D5):普通用户即可调用,Service 被调用。 | |
| 207 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 208 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); | |
| 209 | + | |
| 210 | + mockMvc.perform(get("/api/usr/users")) | |
| 211 | + .andExpect(status().isOk()) | |
| 212 | + .andExpect(jsonPath("$.code").value(0)); | |
| 213 | + verify(usrUserService).queryUsers(any(UserQueryDTO.class)); | |
| 214 | + } | |
| 215 | + | |
| 216 | + @Test | |
| 217 | + void queryIllegalEnumReturns40001() throws Exception { | |
| 218 | + mockMvc.perform(get("/api/usr/users").param("queryField", "身份证")) | |
| 219 | + .andExpect(status().isOk()) | |
| 220 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 221 | + verify(usrUserService, never()).queryUsers(any(UserQueryDTO.class)); | |
| 222 | + } | |
| 223 | + | |
| 224 | + @Test | |
| 225 | + void queryPageParamInvalidReturns42201() throws Exception { | |
| 226 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))) | |
| 227 | + .thenThrow(new BusinessException(ResultCode.PAGE_PARAM_INVALID)); | |
| 228 | + | |
| 229 | + mockMvc.perform(get("/api/usr/users").param("pageNum", "0")) | |
| 230 | + .andExpect(status().isOk()) | |
| 231 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 232 | + } | |
| 233 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import jakarta.validation.Validation; | |
| 6 | +import jakarta.validation.Validator; | |
| 7 | +import jakarta.validation.ValidatorFactory; | |
| 8 | +import java.util.Set; | |
| 9 | +import jakarta.validation.ConstraintViolation; | |
| 10 | +import org.junit.jupiter.api.AfterAll; | |
| 11 | +import org.junit.jupiter.api.BeforeAll; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * REQ-USR-001 T4:CreateUserDTO Bean Validation 校验。 | |
| 16 | + */ | |
| 17 | +class CreateUserDTOValidationTest { | |
| 18 | + | |
| 19 | + private static ValidatorFactory factory; | |
| 20 | + private static Validator validator; | |
| 21 | + | |
| 22 | + @BeforeAll | |
| 23 | + static void setUp() { | |
| 24 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 25 | + validator = factory.getValidator(); | |
| 26 | + } | |
| 27 | + | |
| 28 | + @AfterAll | |
| 29 | + static void tearDown() { | |
| 30 | + if (factory != null) { | |
| 31 | + factory.close(); | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + void rejectsBadUserName() { | |
| 37 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 38 | + dto.setSUserName("ab"); | |
| 39 | + dto.setSLanguage("中文"); | |
| 40 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 41 | + assertThat(violations) | |
| 42 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sUserName")); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + void rejectsIllegalLanguage() { | |
| 47 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 48 | + dto.setSUserName("good_user"); | |
| 49 | + dto.setSLanguage("日文"); | |
| 50 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 51 | + assertThat(violations) | |
| 52 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sLanguage")); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void acceptsMinimalValidBody() { | |
| 57 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 58 | + dto.setSUserName("good_user"); | |
| 59 | + dto.setSLanguage("中文"); | |
| 60 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 61 | + assertThat(violations).isEmpty(); | |
| 62 | + } | |
| 63 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 6 | +import jakarta.validation.ConstraintViolation; | |
| 7 | +import jakarta.validation.Validation; | |
| 8 | +import jakarta.validation.Validator; | |
| 9 | +import jakarta.validation.ValidatorFactory; | |
| 10 | +import java.util.Set; | |
| 11 | +import org.junit.jupiter.api.AfterAll; | |
| 12 | +import org.junit.jupiter.api.BeforeAll; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * REQ-USR-004 T2:LoginDTO Bean Validation 与 JSON 反序列化键名。 | |
| 17 | + * | |
| 18 | + * <p>校验 {@code sUserName}/{@code password} 必填、长度上限,{@code companyId} 必填; | |
| 19 | + * 并验证 {@code @JsonProperty("sUserName")} 锁定反序列化键名(与 CreateUserDTO/UserVO 同做法)。</p> | |
| 20 | + */ | |
| 21 | +class LoginDTOValidationTest { | |
| 22 | + | |
| 23 | + private static ValidatorFactory factory; | |
| 24 | + private static Validator validator; | |
| 25 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 26 | + | |
| 27 | + @BeforeAll | |
| 28 | + static void initValidator() { | |
| 29 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 30 | + validator = factory.getValidator(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @AfterAll | |
| 34 | + static void closeValidator() { | |
| 35 | + factory.close(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + private LoginDTO valid() { | |
| 39 | + LoginDTO dto = new LoginDTO(); | |
| 40 | + dto.setSUserName("admin"); | |
| 41 | + dto.setPassword("666666"); | |
| 42 | + dto.setCompanyId(1); | |
| 43 | + return dto; | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void acceptsValidLogin() { | |
| 48 | + Set<ConstraintViolation<LoginDTO>> violations = validator.validate(valid()); | |
| 49 | + assertThat(violations).isEmpty(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + void rejectsBlankUserName() { | |
| 54 | + LoginDTO dto = valid(); | |
| 55 | + dto.setSUserName(""); | |
| 56 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 57 | + dto.setSUserName(null); | |
| 58 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void rejectsBlankPassword() { | |
| 63 | + LoginDTO dto = valid(); | |
| 64 | + dto.setPassword(""); | |
| 65 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 66 | + dto.setPassword(null); | |
| 67 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void rejectsNullCompanyId() { | |
| 72 | + LoginDTO dto = valid(); | |
| 73 | + dto.setCompanyId(null); | |
| 74 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + void rejectsTooLongUserName() { | |
| 79 | + LoginDTO dto = valid(); | |
| 80 | + dto.setSUserName("a".repeat(51)); | |
| 81 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + void deserializesSUserNameJsonKey() throws Exception { | |
| 86 | + LoginDTO dto = objectMapper.readValue( | |
| 87 | + "{\"sUserName\":\"admin\",\"password\":\"x\",\"companyId\":1}", LoginDTO.class); | |
| 88 | + assertThat(dto.getSUserName()).isEqualTo("admin"); | |
| 89 | + assertThat(dto.getPassword()).isEqualTo("x"); | |
| 90 | + assertThat(dto.getCompanyId()).isEqualTo(1); | |
| 91 | + } | |
| 92 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import jakarta.validation.ConstraintViolation; | |
| 6 | +import jakarta.validation.Validation; | |
| 7 | +import jakarta.validation.Validator; | |
| 8 | +import jakarta.validation.ValidatorFactory; | |
| 9 | +import java.util.Set; | |
| 10 | +import org.junit.jupiter.api.AfterAll; | |
| 11 | +import org.junit.jupiter.api.BeforeAll; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * REQ-USR-002 T1:UpdateUserDTO Bean Validation 校验。 | |
| 16 | + * | |
| 17 | + * <p>用 {@link Validator} 直接 validate DTO,断言 violations。</p> | |
| 18 | + */ | |
| 19 | +class UpdateUserDTOValidationTest { | |
| 20 | + | |
| 21 | + private static ValidatorFactory factory; | |
| 22 | + private static Validator validator; | |
| 23 | + | |
| 24 | + @BeforeAll | |
| 25 | + static void setUp() { | |
| 26 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 27 | + validator = factory.getValidator(); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @AfterAll | |
| 31 | + static void tearDown() { | |
| 32 | + if (factory != null) { | |
| 33 | + factory.close(); | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + private UpdateUserDTO minimalValid() { | |
| 38 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 39 | + dto.setSUserType("普通用户"); | |
| 40 | + dto.setSLanguage("中文"); | |
| 41 | + return dto; | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void acceptsMinimalValidBody() { | |
| 46 | + Set<ConstraintViolation<UpdateUserDTO>> violations = validator.validate(minimalValid()); | |
| 47 | + assertThat(violations).isEmpty(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + void rejectsBlankUserType() { | |
| 52 | + UpdateUserDTO dto = minimalValid(); | |
| 53 | + dto.setSUserType(" "); | |
| 54 | + Set<ConstraintViolation<UpdateUserDTO>> violations = validator.validate(dto); | |
| 55 | + assertThat(violations) | |
| 56 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sUserType")); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + void rejectsIllegalLanguage() { | |
| 61 | + UpdateUserDTO dto = minimalValid(); | |
| 62 | + dto.setSLanguage("日文"); | |
| 63 | + Set<ConstraintViolation<UpdateUserDTO>> violations = validator.validate(dto); | |
| 64 | + assertThat(violations) | |
| 65 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sLanguage")); | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + void rejectsOutOfRangeIsVoid() { | |
| 70 | + UpdateUserDTO dto = minimalValid(); | |
| 71 | + dto.setIIsVoid(2); | |
| 72 | + Set<ConstraintViolation<UpdateUserDTO>> violations = validator.validate(dto); | |
| 73 | + assertThat(violations) | |
| 74 | + .anyMatch(v -> v.getPropertyPath().toString().equals("iIsVoid")); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + void rejectsOutOfRangeCanModifyBill() { | |
| 79 | + UpdateUserDTO dto = minimalValid(); | |
| 80 | + dto.setICanModifyBill(2); | |
| 81 | + Set<ConstraintViolation<UpdateUserDTO>> violations = validator.validate(dto); | |
| 82 | + assertThat(violations) | |
| 83 | + .anyMatch(v -> v.getPropertyPath().toString().equals("iCanModifyBill")); | |
| 84 | + } | |
| 85 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import jakarta.validation.ConstraintViolation; | |
| 6 | +import jakarta.validation.Validation; | |
| 7 | +import jakarta.validation.Validator; | |
| 8 | +import jakarta.validation.ValidatorFactory; | |
| 9 | +import java.util.Set; | |
| 10 | +import org.junit.jupiter.api.AfterAll; | |
| 11 | +import org.junit.jupiter.api.BeforeAll; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * REQ-USR-003 T2:UserQueryDTO Bean Validation 校验。 | |
| 16 | + * | |
| 17 | + * <p>queryField/matchType 走 @Pattern(null 跳过,默认值 Service 兜底);queryValue @Size(max=100); | |
| 18 | + * pageNum/pageSize 不加 @Min/@Max(范围在 Service 入口判定 42201,spec § 8 D8)。</p> | |
| 19 | + */ | |
| 20 | +class UserQueryDTOValidationTest { | |
| 21 | + | |
| 22 | + private static ValidatorFactory factory; | |
| 23 | + private static Validator validator; | |
| 24 | + | |
| 25 | + @BeforeAll | |
| 26 | + static void setUp() { | |
| 27 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 28 | + validator = factory.getValidator(); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @AfterAll | |
| 32 | + static void tearDown() { | |
| 33 | + if (factory != null) { | |
| 34 | + factory.close(); | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test | |
| 39 | + void acceptsAllNullAsValid() { | |
| 40 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 41 | + Set<ConstraintViolation<UserQueryDTO>> violations = validator.validate(dto); | |
| 42 | + assertThat(violations).isEmpty(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + void acceptsLegalEnums() { | |
| 47 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 48 | + dto.setQueryField("登录日期"); | |
| 49 | + dto.setMatchType("不包含"); | |
| 50 | + dto.setQueryValue("2026-06-01"); | |
| 51 | + Set<ConstraintViolation<UserQueryDTO>> violations = validator.validate(dto); | |
| 52 | + assertThat(violations).isEmpty(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void rejectsIllegalQueryField() { | |
| 57 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 58 | + dto.setQueryField("身份证"); | |
| 59 | + Set<ConstraintViolation<UserQueryDTO>> violations = validator.validate(dto); | |
| 60 | + assertThat(violations) | |
| 61 | + .anyMatch(v -> v.getPropertyPath().toString().equals("queryField")); | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + void rejectsIllegalMatchType() { | |
| 66 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 67 | + dto.setMatchType("大于"); | |
| 68 | + Set<ConstraintViolation<UserQueryDTO>> violations = validator.validate(dto); | |
| 69 | + assertThat(violations) | |
| 70 | + .anyMatch(v -> v.getPropertyPath().toString().equals("matchType")); | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test | |
| 74 | + void rejectsTooLongQueryValue() { | |
| 75 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 76 | + dto.setQueryValue("x".repeat(101)); | |
| 77 | + Set<ConstraintViolation<UserQueryDTO>> violations = validator.validate(dto); | |
| 78 | + assertThat(violations) | |
| 79 | + .anyMatch(v -> v.getPropertyPath().toString().equals("queryValue")); | |
| 80 | + } | |
| 81 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | +import org.springframework.transaction.annotation.Transactional; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * REQ-USR-004 T3:UsrCompanyMapper(BaseMapper)连库验证实体 ↔ usr_company 列映射。 | |
| 14 | + * | |
| 15 | + * <p>@SpringBootTest 连测试库(Flyway 已 apply V1),@Transactional 测试回滚避免污染库。 | |
| 16 | + * 插 1 条公司 fixture(名前缀 IT4_CO_),验证 selectById / selectList 取回字段正确。</p> | |
| 17 | + */ | |
| 18 | +@SpringBootTest | |
| 19 | +@ActiveProfiles("test") | |
| 20 | +@Transactional | |
| 21 | +class UsrCompanyMapperTest { | |
| 22 | + | |
| 23 | + @Autowired | |
| 24 | + private UsrCompanyMapper usrCompanyMapper; | |
| 25 | + | |
| 26 | + private UsrCompany insertFixture() { | |
| 27 | + UsrCompany c = new UsrCompany(); | |
| 28 | + c.setSCompanyName("IT4_CO_总部"); | |
| 29 | + c.setSVersion("企业版"); | |
| 30 | + usrCompanyMapper.insert(c); | |
| 31 | + return c; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + void selectByIdReturnsCompany() { | |
| 36 | + UsrCompany inserted = insertFixture(); | |
| 37 | + assertThat(inserted.getIIncrement()).isNotNull(); | |
| 38 | + | |
| 39 | + UsrCompany found = usrCompanyMapper.selectById(inserted.getIIncrement()); | |
| 40 | + assertThat(found).isNotNull(); | |
| 41 | + assertThat(found.getSCompanyName()).isEqualTo("IT4_CO_总部"); | |
| 42 | + assertThat(found.getSVersion()).isEqualTo("企业版"); | |
| 43 | + assertThat(found.getIIncrement()).isEqualTo(inserted.getIIncrement()); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void selectListReturnsAll() { | |
| 48 | + UsrCompany inserted = insertFixture(); | |
| 49 | + assertThat(usrCompanyMapper.selectList(null)) | |
| 50 | + .anyMatch(c -> inserted.getIIncrement().equals(c.getIIncrement())); | |
| 51 | + } | |
| 52 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.mapper; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.baomidou.mybatisplus.core.metadata.IPage; | |
| 6 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | |
| 7 | +import com.xly.erp.modules.usr.dto.UserQueryCondition; | |
| 8 | +import com.xly.erp.modules.usr.entity.UsrEmployee; | |
| 9 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 10 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 11 | +import org.junit.jupiter.api.AfterEach; | |
| 12 | +import org.junit.jupiter.api.BeforeEach; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 15 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 16 | +import org.springframework.test.context.ActiveProfiles; | |
| 17 | +import org.springframework.transaction.annotation.Transactional; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * REQ-USR-003 T3:UsrUserMapper.selectUserPage 跨表分页查询(LEFT JOIN + 动态条件 XML)。 | |
| 21 | + * | |
| 22 | + * <p>@SpringBootTest 连测试库(Flyway 已 apply V1),@Transactional 测试回滚避免污染库。 | |
| 23 | + * 插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 usr_employee; | |
| 24 | + * 验证 LEFT JOIN 列映射、文本 LIKE 过滤、不返回密码。</p> | |
| 25 | + */ | |
| 26 | +@SpringBootTest | |
| 27 | +@ActiveProfiles("test") | |
| 28 | +@Transactional | |
| 29 | +class UsrUserMapperPageTest { | |
| 30 | + | |
| 31 | + private static final String EMP_NAME = "T3职员_财务"; | |
| 32 | + private static final String EMP_DEPT = "财务部T3"; | |
| 33 | + private static final String USER_LINKED = "t3_user_linked"; | |
| 34 | + private static final String USER_UNLINKED = "t3_user_unlinked"; | |
| 35 | + | |
| 36 | + @Autowired | |
| 37 | + private UsrUserMapper usrUserMapper; | |
| 38 | + | |
| 39 | + @Autowired | |
| 40 | + private UsrEmployeeMapper usrEmployeeMapper; | |
| 41 | + | |
| 42 | + private Integer empId; | |
| 43 | + | |
| 44 | + @BeforeEach | |
| 45 | + void seed() { | |
| 46 | + UsrEmployee emp = new UsrEmployee(); | |
| 47 | + emp.setSEmployeeName(EMP_NAME); | |
| 48 | + emp.setSEmployeeNo("T3_EMP_001"); | |
| 49 | + emp.setSDepartment(EMP_DEPT); | |
| 50 | + usrEmployeeMapper.insert(emp); | |
| 51 | + empId = emp.getIIncrement(); | |
| 52 | + | |
| 53 | + UsrUser linked = newUser(USER_LINKED); | |
| 54 | + linked.setIEmployeeId(empId); | |
| 55 | + usrUserMapper.insert(linked); | |
| 56 | + | |
| 57 | + UsrUser unlinked = newUser(USER_UNLINKED); | |
| 58 | + unlinked.setIEmployeeId(null); | |
| 59 | + usrUserMapper.insert(unlinked); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @AfterEach | |
| 63 | + void cleanup() { | |
| 64 | + // @Transactional 已回滚;保留显式说明,无需额外清理。 | |
| 65 | + } | |
| 66 | + | |
| 67 | + private UsrUser newUser(String name) { | |
| 68 | + UsrUser u = new UsrUser(); | |
| 69 | + u.setSUserName(name); | |
| 70 | + u.setSPassword("$2a$dummyhash"); | |
| 71 | + u.setSUserType("普通用户"); | |
| 72 | + u.setSLanguage("中文"); | |
| 73 | + u.setICanModifyBill(0); | |
| 74 | + u.setIIsVoid(0); | |
| 75 | + u.setSCreator("t3_seed"); | |
| 76 | + return u; | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test | |
| 80 | + void pageReturnsLeftJoinedEmployeeColumns() { | |
| 81 | + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none()); | |
| 82 | + | |
| 83 | + assertThat(page.getTotal()).isGreaterThanOrEqualTo(2L); | |
| 84 | + UserVO linked = page.getRecords().stream() | |
| 85 | + .filter(v -> USER_LINKED.equals(v.getSUserName())).findFirst().orElseThrow(); | |
| 86 | + assertThat(linked.getEmployeeName()).isEqualTo(EMP_NAME); | |
| 87 | + assertThat(linked.getDepartment()).isEqualTo(EMP_DEPT); | |
| 88 | + | |
| 89 | + UserVO unlinked = page.getRecords().stream() | |
| 90 | + .filter(v -> USER_UNLINKED.equals(v.getSUserName())).findFirst().orElseThrow(); | |
| 91 | + assertThat(unlinked.getEmployeeName()).isNull(); | |
| 92 | + assertThat(unlinked.getDepartment()).isNull(); | |
| 93 | + } | |
| 94 | + | |
| 95 | + @Test | |
| 96 | + void pageAppliesTextLikeOnUserName() { | |
| 97 | + UserQueryCondition cond = UserQueryCondition.text("u.sUserName", "包含", "t3_user_linked"); | |
| 98 | + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 10), cond); | |
| 99 | + | |
| 100 | + assertThat(page.getRecords()).extracting(UserVO::getSUserName).contains(USER_LINKED); | |
| 101 | + assertThat(page.getRecords()).noneMatch(v -> USER_UNLINKED.equals(v.getSUserName())); | |
| 102 | + assertThat(page.getTotal()).isEqualTo(1L); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Test | |
| 106 | + void pageNeverSelectsPassword() { | |
| 107 | + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none()); | |
| 108 | + assertThat(page.getRecords()).isNotEmpty(); | |
| 109 | + // UserVO 无密码属性 → 天然不含;额外确认元素类型与查询不抛错。 | |
| 110 | + assertThat(page.getRecords().get(0)).isInstanceOf(UserVO.class); | |
| 111 | + } | |
| 112 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 5 | +import static org.mockito.ArgumentMatchers.any; | |
| 6 | +import static org.mockito.ArgumentMatchers.anyString; | |
| 7 | +import static org.mockito.ArgumentMatchers.eq; | |
| 8 | +import static org.mockito.Mockito.atLeastOnce; | |
| 9 | +import static org.mockito.Mockito.never; | |
| 10 | +import static org.mockito.Mockito.times; | |
| 11 | +import static org.mockito.Mockito.verify; | |
| 12 | +import static org.mockito.Mockito.when; | |
| 13 | + | |
| 14 | +import com.xly.erp.common.exception.BusinessException; | |
| 15 | +import com.xly.erp.common.response.ResultCode; | |
| 16 | +import com.xly.erp.common.security.JwtUtil; | |
| 17 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 18 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 19 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 20 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | |
| 21 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 22 | +import com.xly.erp.modules.usr.service.impl.UsrAuthServiceImpl; | |
| 23 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 24 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 25 | +import java.util.List; | |
| 26 | +import org.junit.jupiter.api.BeforeEach; | |
| 27 | +import org.junit.jupiter.api.Test; | |
| 28 | +import org.mockito.ArgumentCaptor; | |
| 29 | +import org.mockito.Mockito; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * REQ-USR-004 T4:UsrAuthServiceImpl 认证核心(纯单元,Mockito)。 | |
| 33 | + * | |
| 34 | + * <p>桩 UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;限流阈值经测试构造器注入 | |
| 35 | + * maxFail=3、lockSeconds=300,便于断言锁定行为。覆盖认证判定顺序、防枚举同码同文案、 | |
| 36 | + * 限流锁定与成功清零、公司列表映射。</p> | |
| 37 | + */ | |
| 38 | +class UsrAuthServiceImplTest { | |
| 39 | + | |
| 40 | + private UsrUserMapper usrUserMapper; | |
| 41 | + private UsrCompanyMapper usrCompanyMapper; | |
| 42 | + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; | |
| 43 | + private JwtUtil jwtUtil; | |
| 44 | + private UsrAuthServiceImpl service; | |
| 45 | + | |
| 46 | + private static final int MAX_FAIL = 3; | |
| 47 | + | |
| 48 | + @BeforeEach | |
| 49 | + void setUp() { | |
| 50 | + usrUserMapper = Mockito.mock(UsrUserMapper.class); | |
| 51 | + usrCompanyMapper = Mockito.mock(UsrCompanyMapper.class); | |
| 52 | + passwordEncoder = Mockito.mock( | |
| 53 | + org.springframework.security.crypto.password.PasswordEncoder.class); | |
| 54 | + jwtUtil = Mockito.mock(JwtUtil.class); | |
| 55 | + service = new UsrAuthServiceImpl(usrUserMapper, usrCompanyMapper, passwordEncoder, jwtUtil, | |
| 56 | + MAX_FAIL, 300); | |
| 57 | + } | |
| 58 | + | |
| 59 | + private LoginDTO dto(String userName) { | |
| 60 | + LoginDTO d = new LoginDTO(); | |
| 61 | + d.setSUserName(userName); | |
| 62 | + d.setPassword("666666"); | |
| 63 | + d.setCompanyId(1); | |
| 64 | + return d; | |
| 65 | + } | |
| 66 | + | |
| 67 | + private UsrUser activeUser(String userName) { | |
| 68 | + UsrUser u = new UsrUser(); | |
| 69 | + u.setIIncrement(42); | |
| 70 | + u.setSUserName(userName); | |
| 71 | + u.setSPassword("$2a$hash"); | |
| 72 | + u.setSUserType("超级管理员"); | |
| 73 | + u.setSLanguage("中文"); | |
| 74 | + u.setIIsVoid(0); | |
| 75 | + return u; | |
| 76 | + } | |
| 77 | + | |
| 78 | + private void stubUserFound(String userName) { | |
| 79 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(userName)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + void successReturnsTokenAndUpdatesLoginTime() { | |
| 84 | + stubUserFound("admin"); | |
| 85 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | |
| 86 | + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); | |
| 87 | + when(jwtUtil.generateToken("admin", "超级管理员")).thenReturn("jwt.token"); | |
| 88 | + | |
| 89 | + LoginVO vo = service.login(dto("admin")); | |
| 90 | + | |
| 91 | + assertThat(vo.getToken()).isEqualTo("jwt.token"); | |
| 92 | + assertThat(vo.getUser().getId()).isEqualTo(42); | |
| 93 | + assertThat(vo.getUser().getSUserName()).isEqualTo("admin"); | |
| 94 | + assertThat(vo.getUser().getSUserType()).isEqualTo("超级管理员"); | |
| 95 | + assertThat(vo.getUser().getSLanguage()).isEqualTo("中文"); | |
| 96 | + | |
| 97 | + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class); | |
| 98 | + verify(usrUserMapper, times(1)).updateById(captor.capture()); | |
| 99 | + UsrUser updated = captor.getValue(); | |
| 100 | + assertThat(updated.getTLastLoginDate()).isNotNull(); | |
| 101 | + assertThat(updated.getIIncrement()).isEqualTo(42); | |
| 102 | + // 仅更新主键 + tLastLoginDate,未覆写其它列(用户名/类型/语言/密码留 null)。 | |
| 103 | + assertThat(updated.getSUserName()).isNull(); | |
| 104 | + assertThat(updated.getSPassword()).isNull(); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + void userNotFoundThrows40101() { | |
| 109 | + when(usrUserMapper.selectOne(any())).thenReturn(null); | |
| 110 | + | |
| 111 | + assertThatThrownBy(() -> service.login(dto("ghost"))) | |
| 112 | + .isInstanceOf(BusinessException.class) | |
| 113 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 114 | + .isEqualTo(ResultCode.UNAUTHORIZED); | |
| 115 | + | |
| 116 | + verify(passwordEncoder, never()).matches(anyString(), anyString()); | |
| 117 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 118 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + void wrongPasswordThrows40101() { | |
| 123 | + stubUserFound("admin"); | |
| 124 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | |
| 125 | + | |
| 126 | + assertThatThrownBy(() -> service.login(dto("admin"))) | |
| 127 | + .isInstanceOf(BusinessException.class) | |
| 128 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 129 | + .isEqualTo(ResultCode.UNAUTHORIZED); | |
| 130 | + | |
| 131 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 132 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | |
| 133 | + } | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + void notFoundAndWrongPasswordSameCodeAndMessage() { | |
| 137 | + // 用户不存在路径 | |
| 138 | + when(usrUserMapper.selectOne(any())).thenReturn(null); | |
| 139 | + BusinessException notFound = | |
| 140 | + (BusinessException) catchEx(() -> service.login(dto("ghost"))); | |
| 141 | + | |
| 142 | + // 密码错误路径 | |
| 143 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser("admin")); | |
| 144 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | |
| 145 | + BusinessException wrongPwd = | |
| 146 | + (BusinessException) catchEx(() -> service.login(dto("admin"))); | |
| 147 | + | |
| 148 | + assertThat(notFound.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | |
| 149 | + assertThat(wrongPwd.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | |
| 150 | + assertThat(notFound.getMessage()).isEqualTo(wrongPwd.getMessage()); | |
| 151 | + } | |
| 152 | + | |
| 153 | + @Test | |
| 154 | + void disabledUserThrows40302AfterPasswordOk() { | |
| 155 | + UsrUser disabled = activeUser("admin"); | |
| 156 | + disabled.setIIsVoid(1); | |
| 157 | + when(usrUserMapper.selectOne(any())).thenReturn(disabled); | |
| 158 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | |
| 159 | + | |
| 160 | + assertThatThrownBy(() -> service.login(dto("admin"))) | |
| 161 | + .isInstanceOf(BusinessException.class) | |
| 162 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 163 | + .isEqualTo(ResultCode.ACCOUNT_DISABLED); | |
| 164 | + | |
| 165 | + // 先验密码再判禁用:matches 被调用过。 | |
| 166 | + verify(passwordEncoder, atLeastOnce()).matches(eq("666666"), anyString()); | |
| 167 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | |
| 168 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 169 | + } | |
| 170 | + | |
| 171 | + @Test | |
| 172 | + void illegalCompanyIdThrows40001() { | |
| 173 | + stubUserFound("admin"); | |
| 174 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | |
| 175 | + when(usrCompanyMapper.selectById(1)).thenReturn(null); | |
| 176 | + | |
| 177 | + assertThatThrownBy(() -> service.login(dto("admin"))) | |
| 178 | + .isInstanceOf(BusinessException.class) | |
| 179 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 180 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 181 | + | |
| 182 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | |
| 183 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 184 | + } | |
| 185 | + | |
| 186 | + @Test | |
| 187 | + void rateLimitAfterMaxFailThrows42901() { | |
| 188 | + String user = "ratelimited"; | |
| 189 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); | |
| 190 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | |
| 191 | + | |
| 192 | + for (int i = 0; i < MAX_FAIL; i++) { | |
| 193 | + catchEx(() -> service.login(dto(user))); | |
| 194 | + } | |
| 195 | + Mockito.clearInvocations(usrUserMapper, jwtUtil); | |
| 196 | + // 锁定后即使密码正确也 42901,且不进入查用户之后的认证逻辑、不签发 token。 | |
| 197 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | |
| 198 | + | |
| 199 | + assertThatThrownBy(() -> service.login(dto(user))) | |
| 200 | + .isInstanceOf(BusinessException.class) | |
| 201 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 202 | + .isEqualTo(ResultCode.LOGIN_RATE_LIMITED); | |
| 203 | + | |
| 204 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | |
| 205 | + } | |
| 206 | + | |
| 207 | + @Test | |
| 208 | + void successResetsFailCounter() { | |
| 209 | + String user = "resetme"; | |
| 210 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); | |
| 211 | + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); | |
| 212 | + when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("jwt.token"); | |
| 213 | + | |
| 214 | + // 先失败 2 次(< maxFail=3) | |
| 215 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | |
| 216 | + catchEx(() -> service.login(dto(user))); | |
| 217 | + catchEx(() -> service.login(dto(user))); | |
| 218 | + | |
| 219 | + // 一次成功登录清零 | |
| 220 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | |
| 221 | + service.login(dto(user)); | |
| 222 | + | |
| 223 | + // 再连续失败到「成功前次数 + 1」=3 次,不应触发 42901(仍为 40101) | |
| 224 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | |
| 225 | + for (int i = 0; i < MAX_FAIL; i++) { | |
| 226 | + BusinessException ex = (BusinessException) catchEx(() -> service.login(dto(user))); | |
| 227 | + assertThat(ex.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + @Test | |
| 232 | + void listCompaniesMapsAllRows() { | |
| 233 | + UsrCompany a = new UsrCompany(); | |
| 234 | + a.setIIncrement(1); | |
| 235 | + a.setSCompanyName("公司A"); | |
| 236 | + a.setSVersion("v1"); | |
| 237 | + UsrCompany b = new UsrCompany(); | |
| 238 | + b.setIIncrement(2); | |
| 239 | + b.setSCompanyName("公司B"); | |
| 240 | + b.setSVersion(null); | |
| 241 | + when(usrCompanyMapper.selectList(any())).thenReturn(List.of(a, b)); | |
| 242 | + | |
| 243 | + List<CompanyOptionVO> result = service.listCompanies(); | |
| 244 | + | |
| 245 | + assertThat(result).hasSize(2); | |
| 246 | + assertThat(result.get(0).getId()).isEqualTo(1); | |
| 247 | + assertThat(result.get(0).getSCompanyName()).isEqualTo("公司A"); | |
| 248 | + assertThat(result.get(0).getSVersion()).isEqualTo("v1"); | |
| 249 | + assertThat(result.get(1).getId()).isEqualTo(2); | |
| 250 | + assertThat(result.get(1).getSVersion()).isNull(); | |
| 251 | + } | |
| 252 | + | |
| 253 | + private static Throwable catchEx(Runnable r) { | |
| 254 | + try { | |
| 255 | + r.run(); | |
| 256 | + } catch (Throwable t) { | |
| 257 | + return t; | |
| 258 | + } | |
| 259 | + throw new AssertionError("expected exception but none thrown"); | |
| 260 | + } | |
| 261 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 5 | +import static org.mockito.ArgumentMatchers.any; | |
| 6 | +import static org.mockito.ArgumentMatchers.eq; | |
| 7 | +import static org.mockito.Mockito.never; | |
| 8 | +import static org.mockito.Mockito.times; | |
| 9 | +import static org.mockito.Mockito.verify; | |
| 10 | +import static org.mockito.Mockito.when; | |
| 11 | + | |
| 12 | +import com.baomidou.mybatisplus.core.conditions.Wrapper; | |
| 13 | +import com.baomidou.mybatisplus.core.metadata.IPage; | |
| 14 | +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; | |
| 15 | +import com.xly.erp.common.exception.BusinessException; | |
| 16 | +import com.xly.erp.common.response.PageResult; | |
| 17 | +import com.xly.erp.common.response.ResultCode; | |
| 18 | +import com.xly.erp.common.security.SecurityUtil; | |
| 19 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 20 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 21 | +import com.xly.erp.modules.usr.dto.UserQueryCondition; | |
| 22 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 23 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 24 | +import java.time.LocalDateTime; | |
| 25 | +import com.xly.erp.modules.usr.entity.UsrEmployee; | |
| 26 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 27 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 28 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 29 | +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; | |
| 30 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 31 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 32 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 33 | +import com.xly.erp.modules.usr.service.impl.UsrUserServiceImpl; | |
| 34 | +import java.util.List; | |
| 35 | +import org.junit.jupiter.api.AfterEach; | |
| 36 | +import org.junit.jupiter.api.BeforeEach; | |
| 37 | +import org.junit.jupiter.api.Test; | |
| 38 | +import org.mockito.ArgumentCaptor; | |
| 39 | +import org.mockito.MockedStatic; | |
| 40 | +import org.mockito.Mockito; | |
| 41 | +import org.springframework.dao.DuplicateKeyException; | |
| 42 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * REQ-USR-001 T5 / T6:新增用户 Service 单元测试(Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态)。 | |
| 46 | + */ | |
| 47 | +class UsrUserServiceImplTest { | |
| 48 | + | |
| 49 | + private UsrUserMapper usrUserMapper; | |
| 50 | + private UsrUserPermissionMapper usrUserPermissionMapper; | |
| 51 | + private UsrEmployeeMapper usrEmployeeMapper; | |
| 52 | + private UsrPermissionMapper usrPermissionMapper; | |
| 53 | + private PasswordEncoder passwordEncoder; | |
| 54 | + private UsrUserServiceImpl service; | |
| 55 | + private MockedStatic<SecurityUtil> securityUtilMock; | |
| 56 | + | |
| 57 | + @BeforeEach | |
| 58 | + void setUp() { | |
| 59 | + usrUserMapper = Mockito.mock(UsrUserMapper.class); | |
| 60 | + usrUserPermissionMapper = Mockito.mock(UsrUserPermissionMapper.class); | |
| 61 | + usrEmployeeMapper = Mockito.mock(UsrEmployeeMapper.class); | |
| 62 | + usrPermissionMapper = Mockito.mock(UsrPermissionMapper.class); | |
| 63 | + passwordEncoder = Mockito.mock(PasswordEncoder.class); | |
| 64 | + service = new UsrUserServiceImpl(usrUserMapper, usrUserPermissionMapper, | |
| 65 | + usrEmployeeMapper, usrPermissionMapper, passwordEncoder); | |
| 66 | + securityUtilMock = Mockito.mockStatic(SecurityUtil.class); | |
| 67 | + securityUtilMock.when(SecurityUtil::currentUserName).thenReturn("admin"); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @AfterEach | |
| 71 | + void tearDown() { | |
| 72 | + securityUtilMock.close(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + private CreateUserDTO minimalDto() { | |
| 76 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 77 | + dto.setSUserName("good_user"); | |
| 78 | + dto.setSLanguage("中文"); | |
| 79 | + return dto; | |
| 80 | + } | |
| 81 | + | |
| 82 | + @SuppressWarnings("unchecked") | |
| 83 | + private void stubNoExistingUser() { | |
| 84 | + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(0L); | |
| 85 | + } | |
| 86 | + | |
| 87 | + // ---------------- T5 ---------------- | |
| 88 | + | |
| 89 | + @Test | |
| 90 | + void createUserHashesPasswordAndSetsAuditFields() { | |
| 91 | + stubNoExistingUser(); | |
| 92 | + when(passwordEncoder.encode("666666")).thenReturn("$2a$hashed"); | |
| 93 | + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { | |
| 94 | + UsrUser u = inv.getArgument(0); | |
| 95 | + u.setIIncrement(101); | |
| 96 | + return 1; | |
| 97 | + }); | |
| 98 | + | |
| 99 | + Integer id = service.createUser(minimalDto()); | |
| 100 | + | |
| 101 | + assertThat(id).isEqualTo(101); | |
| 102 | + verify(passwordEncoder).encode("666666"); | |
| 103 | + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class); | |
| 104 | + verify(usrUserMapper).insert(captor.capture()); | |
| 105 | + UsrUser saved = captor.getValue(); | |
| 106 | + assertThat(saved.getSPassword()).isEqualTo("$2a$hashed"); | |
| 107 | + assertThat(saved.getSPassword()).isNotEqualTo("666666"); | |
| 108 | + assertThat(saved.getIIsVoid()).isZero(); | |
| 109 | + assertThat(saved.getSUserType()).isEqualTo("普通用户"); | |
| 110 | + assertThat(saved.getSCreator()).isEqualTo("admin"); | |
| 111 | + assertThat(saved.getTLastLoginDate()).isNull(); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Test | |
| 115 | + void duplicateUserNameThrows40901() { | |
| 116 | + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(1L); | |
| 117 | + | |
| 118 | + assertThatThrownBy(() -> service.createUser(minimalDto())) | |
| 119 | + .isInstanceOf(BusinessException.class) | |
| 120 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 121 | + .isEqualTo(ResultCode.USERNAME_EXISTS); | |
| 122 | + verify(usrUserMapper, never()).insert(any(UsrUser.class)); | |
| 123 | + } | |
| 124 | + | |
| 125 | + @Test | |
| 126 | + void duplicateKeyExceptionTranslatesTo40901() { | |
| 127 | + stubNoExistingUser(); | |
| 128 | + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); | |
| 129 | + when(usrUserMapper.insert(any(UsrUser.class))).thenThrow(new DuplicateKeyException("dup")); | |
| 130 | + | |
| 131 | + assertThatThrownBy(() -> service.createUser(minimalDto())) | |
| 132 | + .isInstanceOf(BusinessException.class) | |
| 133 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 134 | + .isEqualTo(ResultCode.USERNAME_EXISTS); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ---------------- T6 ---------------- | |
| 138 | + | |
| 139 | + @Test | |
| 140 | + void nonExistentEmployeeThrows40001() { | |
| 141 | + stubNoExistingUser(); | |
| 142 | + when(usrEmployeeMapper.selectById(999)).thenReturn(null); | |
| 143 | + CreateUserDTO dto = minimalDto(); | |
| 144 | + dto.setIEmployeeId(999); | |
| 145 | + | |
| 146 | + assertThatThrownBy(() -> service.createUser(dto)) | |
| 147 | + .isInstanceOf(BusinessException.class) | |
| 148 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 149 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 150 | + verify(usrUserMapper, never()).insert(any(UsrUser.class)); | |
| 151 | + } | |
| 152 | + | |
| 153 | + @Test | |
| 154 | + void nonExistentPermissionThrows40001() { | |
| 155 | + stubNoExistingUser(); | |
| 156 | + when(usrPermissionMapper.selectById(5)).thenReturn(null); | |
| 157 | + CreateUserDTO dto = minimalDto(); | |
| 158 | + dto.setPermissionIds(List.of(5)); | |
| 159 | + | |
| 160 | + assertThatThrownBy(() -> service.createUser(dto)) | |
| 161 | + .isInstanceOf(BusinessException.class) | |
| 162 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 163 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 164 | + verify(usrUserMapper, never()).insert(any(UsrUser.class)); | |
| 165 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Test | |
| 169 | + void grantsDedupedPermissions() { | |
| 170 | + stubNoExistingUser(); | |
| 171 | + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); | |
| 172 | + UsrPermission permA = new UsrPermission(); | |
| 173 | + UsrPermission permB = new UsrPermission(); | |
| 174 | + when(usrPermissionMapper.selectById(10)).thenReturn(permA); | |
| 175 | + when(usrPermissionMapper.selectById(20)).thenReturn(permB); | |
| 176 | + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { | |
| 177 | + UsrUser u = inv.getArgument(0); | |
| 178 | + u.setIIncrement(202); | |
| 179 | + return 1; | |
| 180 | + }); | |
| 181 | + CreateUserDTO dto = minimalDto(); | |
| 182 | + dto.setPermissionIds(List.of(10, 10, 20)); | |
| 183 | + | |
| 184 | + Integer id = service.createUser(dto); | |
| 185 | + | |
| 186 | + assertThat(id).isEqualTo(202); | |
| 187 | + ArgumentCaptor<UsrUserPermission> captor = ArgumentCaptor.forClass(UsrUserPermission.class); | |
| 188 | + verify(usrUserPermissionMapper, times(2)).insert(captor.capture()); | |
| 189 | + List<UsrUserPermission> grants = captor.getAllValues(); | |
| 190 | + assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(202); | |
| 191 | + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) | |
| 192 | + .containsExactlyInAnyOrder(10, 20); | |
| 193 | + } | |
| 194 | + | |
| 195 | + // 防御:未使用的 employee mock 引用,确保导入有效(占位避免 checkstyle 未用 import)。 | |
| 196 | + @Test | |
| 197 | + void employeeMapperWiredForExistenceCheck() { | |
| 198 | + stubNoExistingUser(); | |
| 199 | + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); | |
| 200 | + UsrEmployee emp = new UsrEmployee(); | |
| 201 | + when(usrEmployeeMapper.selectById(eq(7))).thenReturn(emp); | |
| 202 | + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { | |
| 203 | + ((UsrUser) inv.getArgument(0)).setIIncrement(303); | |
| 204 | + return 1; | |
| 205 | + }); | |
| 206 | + CreateUserDTO dto = minimalDto(); | |
| 207 | + dto.setIEmployeeId(7); | |
| 208 | + | |
| 209 | + assertThat(service.createUser(dto)).isEqualTo(303); | |
| 210 | + verify(usrEmployeeMapper).selectById(7); | |
| 211 | + } | |
| 212 | + | |
| 213 | + // ---------------- REQ-USR-002 T2:目标用户存在性 + 主记录部分更新 ---------------- | |
| 214 | + | |
| 215 | + private UpdateUserDTO minimalUpdateDto() { | |
| 216 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 217 | + dto.setSUserType("普通用户"); | |
| 218 | + dto.setSLanguage("中文"); | |
| 219 | + return dto; | |
| 220 | + } | |
| 221 | + | |
| 222 | + @Test | |
| 223 | + void updateNonExistentUserThrows40401() { | |
| 224 | + when(usrUserMapper.selectById(404)).thenReturn(null); | |
| 225 | + | |
| 226 | + assertThatThrownBy(() -> service.updateUser(404, minimalUpdateDto())) | |
| 227 | + .isInstanceOf(BusinessException.class) | |
| 228 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 229 | + .isEqualTo(ResultCode.NOT_FOUND); | |
| 230 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 231 | + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); | |
| 232 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 233 | + } | |
| 234 | + | |
| 235 | + @Test | |
| 236 | + void updateAppliesNonNullColumnsAndKeepsIdentityImmutable() { | |
| 237 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 238 | + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); | |
| 239 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 240 | + dto.setSUserType("超级管理员"); | |
| 241 | + dto.setSLanguage("英文"); | |
| 242 | + dto.setICanModifyBill(1); | |
| 243 | + dto.setIIsVoid(1); | |
| 244 | + dto.setSUserNo("N9"); | |
| 245 | + | |
| 246 | + Integer returned = service.updateUser(55, dto); | |
| 247 | + | |
| 248 | + assertThat(returned).isEqualTo(55); | |
| 249 | + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class); | |
| 250 | + verify(usrUserMapper).updateById(captor.capture()); | |
| 251 | + UsrUser saved = captor.getValue(); | |
| 252 | + assertThat(saved.getIIncrement()).isEqualTo(55); | |
| 253 | + assertThat(saved.getSUserType()).isEqualTo("超级管理员"); | |
| 254 | + assertThat(saved.getSLanguage()).isEqualTo("英文"); | |
| 255 | + assertThat(saved.getICanModifyBill()).isEqualTo(1); | |
| 256 | + assertThat(saved.getIIsVoid()).isEqualTo(1); | |
| 257 | + assertThat(saved.getSUserNo()).isEqualTo("N9"); | |
| 258 | + // 身份 / 密码 / 审计列不参与 SET(保持 null,依赖 MP null 不更新语义)。 | |
| 259 | + assertThat(saved.getSUserName()).isNull(); | |
| 260 | + assertThat(saved.getSPassword()).isNull(); | |
| 261 | + assertThat(saved.getSCreator()).isNull(); | |
| 262 | + assertThat(saved.getTCreateDate()).isNull(); | |
| 263 | + } | |
| 264 | + | |
| 265 | + @Test | |
| 266 | + void nullOptionalColumnsAreNotOverwritten() { | |
| 267 | + when(usrUserMapper.selectById(77)).thenReturn(new UsrUser()); | |
| 268 | + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); | |
| 269 | + | |
| 270 | + service.updateUser(77, minimalUpdateDto()); | |
| 271 | + | |
| 272 | + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class); | |
| 273 | + verify(usrUserMapper).updateById(captor.capture()); | |
| 274 | + UsrUser saved = captor.getValue(); | |
| 275 | + assertThat(saved.getIIncrement()).isEqualTo(77); | |
| 276 | + assertThat(saved.getSUserType()).isEqualTo("普通用户"); | |
| 277 | + assertThat(saved.getSLanguage()).isEqualTo("中文"); | |
| 278 | + // 可选列 DTO 为 null → 实体保持 null,MP 不 SET。 | |
| 279 | + assertThat(saved.getSUserNo()).isNull(); | |
| 280 | + assertThat(saved.getIEmployeeId()).isNull(); | |
| 281 | + assertThat(saved.getICanModifyBill()).isNull(); | |
| 282 | + assertThat(saved.getIIsVoid()).isNull(); | |
| 283 | + } | |
| 284 | + | |
| 285 | + // ---------------- REQ-USR-002 T3:关联职员存在性 + 权限组全量覆盖 ---------------- | |
| 286 | + | |
| 287 | + @Test | |
| 288 | + @SuppressWarnings("unchecked") | |
| 289 | + void nonExistentEmployeeOnUpdateThrows40001() { | |
| 290 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 291 | + when(usrEmployeeMapper.selectById(999)).thenReturn(null); | |
| 292 | + UpdateUserDTO dto = minimalUpdateDto(); | |
| 293 | + dto.setIEmployeeId(999); | |
| 294 | + | |
| 295 | + assertThatThrownBy(() -> service.updateUser(55, dto)) | |
| 296 | + .isInstanceOf(BusinessException.class) | |
| 297 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 298 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 299 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 300 | + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); | |
| 301 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 302 | + } | |
| 303 | + | |
| 304 | + @Test | |
| 305 | + @SuppressWarnings("unchecked") | |
| 306 | + void nonExistentPermissionOnUpdateThrows40001() { | |
| 307 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 308 | + when(usrPermissionMapper.selectById(7)).thenReturn(null); | |
| 309 | + UpdateUserDTO dto = minimalUpdateDto(); | |
| 310 | + dto.setPermissionIds(List.of(7)); | |
| 311 | + | |
| 312 | + assertThatThrownBy(() -> service.updateUser(55, dto)) | |
| 313 | + .isInstanceOf(BusinessException.class) | |
| 314 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 315 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 316 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | |
| 317 | + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); | |
| 318 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 319 | + } | |
| 320 | + | |
| 321 | + @Test | |
| 322 | + @SuppressWarnings("unchecked") | |
| 323 | + void permissionIdsOverwriteDeletesThenInserts() { | |
| 324 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 325 | + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); | |
| 326 | + when(usrPermissionMapper.selectById(10)).thenReturn(new UsrPermission()); | |
| 327 | + when(usrPermissionMapper.selectById(20)).thenReturn(new UsrPermission()); | |
| 328 | + UpdateUserDTO dto = minimalUpdateDto(); | |
| 329 | + dto.setPermissionIds(List.of(10, 10, 20)); | |
| 330 | + | |
| 331 | + service.updateUser(55, dto); | |
| 332 | + | |
| 333 | + verify(usrUserPermissionMapper, times(1)).delete(any(Wrapper.class)); | |
| 334 | + ArgumentCaptor<UsrUserPermission> captor = ArgumentCaptor.forClass(UsrUserPermission.class); | |
| 335 | + verify(usrUserPermissionMapper, times(2)).insert(captor.capture()); | |
| 336 | + List<UsrUserPermission> grants = captor.getAllValues(); | |
| 337 | + assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(55); | |
| 338 | + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) | |
| 339 | + .containsExactlyInAnyOrder(10, 20); | |
| 340 | + } | |
| 341 | + | |
| 342 | + @Test | |
| 343 | + @SuppressWarnings("unchecked") | |
| 344 | + void emptyPermissionIdsClearsAll() { | |
| 345 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 346 | + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); | |
| 347 | + UpdateUserDTO dto = minimalUpdateDto(); | |
| 348 | + dto.setPermissionIds(List.of()); | |
| 349 | + | |
| 350 | + service.updateUser(55, dto); | |
| 351 | + | |
| 352 | + verify(usrUserPermissionMapper, times(1)).delete(any(Wrapper.class)); | |
| 353 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 354 | + } | |
| 355 | + | |
| 356 | + @Test | |
| 357 | + @SuppressWarnings("unchecked") | |
| 358 | + void nullPermissionIdsLeavesGrantsUntouched() { | |
| 359 | + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); | |
| 360 | + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); | |
| 361 | + | |
| 362 | + service.updateUser(55, minimalUpdateDto()); | |
| 363 | + | |
| 364 | + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); | |
| 365 | + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); | |
| 366 | + } | |
| 367 | + | |
| 368 | + // ---------------- REQ-USR-003 T4:查询用户 参数判定 + 条件解析 + 分页装配 ---------------- | |
| 369 | + | |
| 370 | + /** 桩:selectUserPage 返回携带指定 records/total/size/current 的 Page。 */ | |
| 371 | + @SuppressWarnings("unchecked") | |
| 372 | + private void stubSelectUserPage(java.util.List<UserVO> records, long total, long size, long current) { | |
| 373 | + when(usrUserMapper.selectUserPage(any(IPage.class), any(UserQueryCondition.class))) | |
| 374 | + .thenAnswer(inv -> { | |
| 375 | + Page<UserVO> page = new Page<>(current, size); | |
| 376 | + page.setRecords(records); | |
| 377 | + page.setTotal(total); | |
| 378 | + return page; | |
| 379 | + }); | |
| 380 | + } | |
| 381 | + | |
| 382 | + private UserQueryDTO queryDto(String field, String matchType, String value) { | |
| 383 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 384 | + dto.setQueryField(field); | |
| 385 | + dto.setMatchType(matchType); | |
| 386 | + dto.setQueryValue(value); | |
| 387 | + return dto; | |
| 388 | + } | |
| 389 | + | |
| 390 | + @Test | |
| 391 | + @SuppressWarnings("unchecked") | |
| 392 | + void pageParamTooSmallThrows42201() { | |
| 393 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 394 | + dto.setPageNum(0); | |
| 395 | + assertThatThrownBy(() -> service.queryUsers(dto)) | |
| 396 | + .isInstanceOf(BusinessException.class) | |
| 397 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 398 | + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); | |
| 399 | + | |
| 400 | + UserQueryDTO dto2 = new UserQueryDTO(); | |
| 401 | + dto2.setPageSize(0); | |
| 402 | + assertThatThrownBy(() -> service.queryUsers(dto2)) | |
| 403 | + .isInstanceOf(BusinessException.class) | |
| 404 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 405 | + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); | |
| 406 | + | |
| 407 | + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); | |
| 408 | + } | |
| 409 | + | |
| 410 | + @Test | |
| 411 | + @SuppressWarnings("unchecked") | |
| 412 | + void pageSizeOverMaxThrows42201() { | |
| 413 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 414 | + dto.setPageSize(500); | |
| 415 | + assertThatThrownBy(() -> service.queryUsers(dto)) | |
| 416 | + .isInstanceOf(BusinessException.class) | |
| 417 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 418 | + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); | |
| 419 | + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); | |
| 420 | + } | |
| 421 | + | |
| 422 | + @Test | |
| 423 | + @SuppressWarnings("unchecked") | |
| 424 | + void blankQueryValueAppliesNoFilter() { | |
| 425 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 426 | + UserQueryDTO dto = queryDto("用户名", "包含", null); | |
| 427 | + | |
| 428 | + service.queryUsers(dto); | |
| 429 | + | |
| 430 | + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class); | |
| 431 | + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); | |
| 432 | + UserQueryCondition cond = captor.getValue(); | |
| 433 | + assertThat(cond.getKind()).isEqualTo(UserQueryCondition.Kind.NONE); | |
| 434 | + assertThat(cond.getTextValue()).isNull(); | |
| 435 | + } | |
| 436 | + | |
| 437 | + @Test | |
| 438 | + @SuppressWarnings("unchecked") | |
| 439 | + void boolFieldUnparsableThrows40001() { | |
| 440 | + UserQueryDTO bad = queryDto("作废", "等于", "abc"); | |
| 441 | + assertThatThrownBy(() -> service.queryUsers(bad)) | |
| 442 | + .isInstanceOf(BusinessException.class) | |
| 443 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 444 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 445 | + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); | |
| 446 | + | |
| 447 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 448 | + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class); | |
| 449 | + | |
| 450 | + service.queryUsers(queryDto("作废", "等于", "1")); | |
| 451 | + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); | |
| 452 | + assertThat(captor.getValue().getBoolValue()).isEqualTo(1); | |
| 453 | + | |
| 454 | + service.queryUsers(queryDto("作废", "等于", "是")); | |
| 455 | + verify(usrUserMapper, times(2)).selectUserPage(any(IPage.class), captor.capture()); | |
| 456 | + assertThat(captor.getValue().getBoolValue()).isEqualTo(1); | |
| 457 | + } | |
| 458 | + | |
| 459 | + @Test | |
| 460 | + @SuppressWarnings("unchecked") | |
| 461 | + void dateFieldIllegalThrows40001() { | |
| 462 | + UserQueryDTO bad = queryDto("登录日期", "等于", "2026-13-99"); | |
| 463 | + assertThatThrownBy(() -> service.queryUsers(bad)) | |
| 464 | + .isInstanceOf(BusinessException.class) | |
| 465 | + .extracting(e -> ((BusinessException) e).getResultCode()) | |
| 466 | + .isEqualTo(ResultCode.PARAM_INVALID); | |
| 467 | + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); | |
| 468 | + | |
| 469 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 470 | + service.queryUsers(queryDto("登录日期", "等于", "2026-06-01")); | |
| 471 | + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class); | |
| 472 | + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); | |
| 473 | + UserQueryCondition cond = captor.getValue(); | |
| 474 | + assertThat(cond.getDateStart()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0, 0)); | |
| 475 | + assertThat(cond.getDateEnd()).isEqualTo(LocalDateTime.of(2026, 6, 2, 0, 0, 0)); | |
| 476 | + } | |
| 477 | + | |
| 478 | + @Test | |
| 479 | + @SuppressWarnings("unchecked") | |
| 480 | + void textLikeEscapesWildcards() { | |
| 481 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 482 | + service.queryUsers(queryDto("用户名", "包含", "a%_b")); | |
| 483 | + | |
| 484 | + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class); | |
| 485 | + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); | |
| 486 | + UserQueryCondition cond = captor.getValue(); | |
| 487 | + assertThat(cond.isText()).isTrue(); | |
| 488 | + assertThat(cond.isTextContains()).isTrue(); | |
| 489 | + assertThat(cond.getTextValue()).contains("\\%").contains("\\_"); | |
| 490 | + } | |
| 491 | + | |
| 492 | + @Test | |
| 493 | + @SuppressWarnings("unchecked") | |
| 494 | + void dataPageOutOfRangeClampsToLastPage() { | |
| 495 | + UserVO vo = new UserVO(); | |
| 496 | + vo.setSUserName("last_page_user"); | |
| 497 | + stubSelectUserPage(java.util.List.of(vo), 23L, 10L, 99L); | |
| 498 | + UserQueryDTO dto = new UserQueryDTO(); | |
| 499 | + dto.setPageNum(99); | |
| 500 | + dto.setPageSize(10); | |
| 501 | + | |
| 502 | + PageResult<UserVO> result = service.queryUsers(dto); | |
| 503 | + | |
| 504 | + assertThat(result.getTotal()).isEqualTo(23L); | |
| 505 | + assertThat(result.getPageNum()).isEqualTo(3L); | |
| 506 | + assertThat(result.getRecords()).extracting(UserVO::getSUserName).contains("last_page_user"); | |
| 507 | + } | |
| 508 | + | |
| 509 | + @Test | |
| 510 | + @SuppressWarnings("unchecked") | |
| 511 | + void emptyResultReturnsZeroTotal() { | |
| 512 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 513 | + PageResult<UserVO> result = service.queryUsers(new UserQueryDTO()); | |
| 514 | + | |
| 515 | + assertThat(result.getRecords()).isEmpty(); | |
| 516 | + assertThat(result.getTotal()).isZero(); | |
| 517 | + assertThat(result.getPageNum()).isEqualTo(1L); | |
| 518 | + } | |
| 519 | + | |
| 520 | + @Test | |
| 521 | + @SuppressWarnings("unchecked") | |
| 522 | + void defaultsApplied() { | |
| 523 | + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); | |
| 524 | + service.queryUsers(new UserQueryDTO()); | |
| 525 | + | |
| 526 | + ArgumentCaptor<IPage> pageCaptor = ArgumentCaptor.forClass(IPage.class); | |
| 527 | + verify(usrUserMapper).selectUserPage(pageCaptor.capture(), any(UserQueryCondition.class)); | |
| 528 | + IPage<?> page = pageCaptor.getValue(); | |
| 529 | + assertThat(page.getCurrent()).isEqualTo(1L); | |
| 530 | + assertThat(page.getSize()).isEqualTo(10L); | |
| 531 | + } | |
| 532 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * REQ-USR-004 T2:CompanyOptionVO 序列化键名锁定。 | |
| 10 | + * | |
| 11 | + * <p>验证匈牙利前缀字段({@code sCompanyName}/{@code sVersion})经 {@code @JsonProperty} | |
| 12 | + * 序列化为契约小驼峰键,而非 Jackson 推断的大驼峰 {@code SCompanyName}。</p> | |
| 13 | + */ | |
| 14 | +class CompanyOptionVOJsonTest { | |
| 15 | + | |
| 16 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + void serializesContractKeys() throws Exception { | |
| 20 | + CompanyOptionVO vo = new CompanyOptionVO(); | |
| 21 | + vo.setId(7); | |
| 22 | + vo.setSCompanyName("小羚羊总部"); | |
| 23 | + vo.setSVersion("企业版"); | |
| 24 | + | |
| 25 | + String json = objectMapper.writeValueAsString(vo); | |
| 26 | + | |
| 27 | + assertThat(json).contains("\"id\":7"); | |
| 28 | + assertThat(json).contains("\"sCompanyName\":\"小羚羊总部\""); | |
| 29 | + assertThat(json).contains("\"sVersion\":\"企业版\""); | |
| 30 | + assertThat(json).doesNotContain("SCompanyName"); | |
| 31 | + assertThat(json).doesNotContain("SVersion"); | |
| 32 | + } | |
| 33 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * REQ-USR-004 T3:LoginVO 序列化结构与键名(含嵌套 user,绝不含密码)。 | |
| 10 | + * | |
| 11 | + * <p>验证顶层 {@code token} + 嵌套对象 {@code user{id,sUserName,sUserType,sLanguage}}, | |
| 12 | + * 匈牙利前缀字段经 {@code @JsonProperty} 锁小驼峰键;响应体不含 {@code sPassword}/{@code password} | |
| 13 | + * /大驼峰键 {@code SUserName}(spec § 3 规则 9 / 验收 11)。</p> | |
| 14 | + */ | |
| 15 | +class LoginVOJsonTest { | |
| 16 | + | |
| 17 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + void serializesTokenAndNestedUserNoPassword() throws Exception { | |
| 21 | + LoginVO.UserInfo user = new LoginVO.UserInfo(); | |
| 22 | + user.setId(1); | |
| 23 | + user.setSUserName("admin"); | |
| 24 | + user.setSUserType("超级管理员"); | |
| 25 | + user.setSLanguage("中文"); | |
| 26 | + | |
| 27 | + LoginVO vo = new LoginVO(); | |
| 28 | + vo.setToken("t.t.t"); | |
| 29 | + vo.setUser(user); | |
| 30 | + | |
| 31 | + String json = objectMapper.writeValueAsString(vo); | |
| 32 | + | |
| 33 | + assertThat(json).contains("\"token\":\"t.t.t\""); | |
| 34 | + assertThat(json).contains("\"user\":"); | |
| 35 | + assertThat(json).contains("\"id\":1"); | |
| 36 | + assertThat(json).contains("\"sUserName\":\"admin\""); | |
| 37 | + assertThat(json).contains("\"sUserType\":\"超级管理员\""); | |
| 38 | + assertThat(json).contains("\"sLanguage\":\"中文\""); | |
| 39 | + assertThat(json).doesNotContain("sPassword"); | |
| 40 | + assertThat(json).doesNotContain("password"); | |
| 41 | + assertThat(json).doesNotContain("SUserName"); | |
| 42 | + } | |
| 43 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.vo; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 6 | +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; | |
| 7 | +import java.time.LocalDateTime; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * REQ-USR-003 T2:UserVO 序列化契约键名 + 不含密码 / 租户列。 | |
| 12 | + * | |
| 13 | + * <p>带匈牙利前缀字段加 @JsonProperty 锁键为小驼峰(sUserName 等); | |
| 14 | + * employeeName/department/id/日期为普通驼峰;严格无 sPassword/password/租户列。</p> | |
| 15 | + */ | |
| 16 | +class UserVOJsonTest { | |
| 17 | + | |
| 18 | + // 注册 JavaTimeModule 模拟 Spring Boot 自动配置的 ObjectMapper(支持 LocalDateTime 序列化)。 | |
| 19 | + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); | |
| 20 | + | |
| 21 | + @Test | |
| 22 | + void serializesContractKeysNoPassword() throws Exception { | |
| 23 | + UserVO vo = new UserVO(); | |
| 24 | + vo.setId(7); | |
| 25 | + vo.setSUserName("alice"); | |
| 26 | + vo.setEmployeeName("爱丽丝"); | |
| 27 | + vo.setSUserNo("U007"); | |
| 28 | + vo.setDepartment("财务部"); | |
| 29 | + vo.setSUserType("超级管理员"); | |
| 30 | + vo.setSLanguage("中文"); | |
| 31 | + vo.setIIsVoid(0); | |
| 32 | + vo.setTLastLoginDate(LocalDateTime.of(2026, 6, 1, 12, 0, 0)); | |
| 33 | + vo.setSCreator("admin"); | |
| 34 | + vo.setTCreateDate(LocalDateTime.of(2026, 5, 1, 9, 0, 0)); | |
| 35 | + | |
| 36 | + String json = objectMapper.writeValueAsString(vo); | |
| 37 | + | |
| 38 | + assertThat(json) | |
| 39 | + .contains("\"id\"") | |
| 40 | + .contains("\"sUserName\"") | |
| 41 | + .contains("\"employeeName\"") | |
| 42 | + .contains("\"sUserNo\"") | |
| 43 | + .contains("\"department\"") | |
| 44 | + .contains("\"sUserType\"") | |
| 45 | + .contains("\"sLanguage\"") | |
| 46 | + .contains("\"iIsVoid\"") | |
| 47 | + .contains("\"tLastLoginDate\"") | |
| 48 | + .contains("\"sCreator\"") | |
| 49 | + .contains("\"tCreateDate\""); | |
| 50 | + // 匈牙利前缀字段 @JsonProperty 锁键生效:不得出现 Jackson 默认推断的大写首字母键名。 | |
| 51 | + assertThat(json).doesNotContain("\"SUserName\""); | |
| 52 | + // 严格不含密码 / 租户列。 | |
| 53 | + assertThat(json).doesNotContain("sPassword").doesNotContain("password"); | |
| 54 | + assertThat(json).doesNotContain("sBrandsId").doesNotContain("sSubsidiaryId"); | |
| 55 | + } | |
| 56 | +} | ... | ... |
docs/08-模块任务管理.md
| ... | ... | @@ -47,10 +47,10 @@ |
| 47 | 47 | - 路径: `backend/src/main/java/com/xly/erp/modules/usr/**` |
| 48 | 48 | - 里程碑: — |
| 49 | 49 | - 功能: |
| 50 | - - [ ] REQ-USR-001 增加用户 | |
| 51 | - - [ ] REQ-USR-002 修改用户 | |
| 52 | - - [ ] REQ-USR-003 查询用户 | |
| 53 | - - [ ] REQ-USR-004 登录用户 | |
| 50 | + - [x] REQ-USR-001 增加用户 | |
| 51 | + - [x] REQ-USR-002 修改用户 | |
| 52 | + - [x] REQ-USR-003 查询用户 | |
| 53 | + - [x] REQ-USR-004 登录用户 | |
| 54 | 54 | |
| 55 | 55 | ## 三、Coding 阶段(前端整体) |
| 56 | 56 | ... | ... |
docs/superpowers/module-reports/2026-06-01-usr.md
0 → 100644
| 1 | +# 模块完成报告 — USR 用户管理(backend) | |
| 2 | + | |
| 3 | +> 生成时间:2026-06-01 CST | |
| 4 | +> 模块 id:usr|阶段:后端(backend)|功能分支:`module-usr`(自默认分支 `master` 分叉) | |
| 5 | +> 本报告为标准化 12 节模块完成报告,供 milestone 标记前置。摘要级(不引入 diff 正文)。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## ① 模块概述 | |
| 10 | + | |
| 11 | +- **模块名称**:USR 用户管理(`com.xly.erp.modules.usr`)。 | |
| 12 | +- **业务范围**:企业内部管理系统的用户账号全生命周期与登录认证,覆盖用户新增、修改、分页查询、登录认证 4 个后端 REQ。 | |
| 13 | +- **依赖**:无(docs/08 § 二 标注「依赖: 无」),为整个 ERP 的基础模块。 | |
| 14 | +- **路径作用域**:`backend/src/main/java/com/xly/erp/modules/usr/**` + 公共基础设施 `com/xly/erp/common/**`(统一响应 / 异常 / 安全 / JWT / BaseEntity,本模块首次落地,供后续模块复用)。 | |
| 15 | +- **本阶段产出范围**:controller / service / repository(mapper) / DTO / VO / 校验 / REST 契约实现;不触 `frontend/`(UI 推迟到前端阶段);本模块未新增 SQL migration(复用 A4 生成的 `V1__initial_schema.sql`)。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## ② 需求覆盖(REQ 清单 + spec 摘要) | |
| 20 | + | |
| 21 | +本模块 4 个 REQ 全部完成并在 `docs/08-模块任务管理.md` § 二 勾选为 `[x]`: | |
| 22 | + | |
| 23 | +| REQ | 标题 | 端点 | spec 摘要 | | |
| 24 | +|---|---|---|---| | |
| 25 | +| REQ-USR-001 | 增加用户 | `POST /api/usr/users` | 管理员新建用户:用户名 3-20 位唯一、密码 BCrypt(默认 `666666`)、用户类型 / 语言枚举约束、可选关联职员 + 权限组授权(多对多);多表写入事务化;新建即生效(`iIsVoid=0`)。错误码 0/40001/40301/40901。 | | |
| 26 | +| REQ-USR-002 | 修改用户 | `PUT /api/usr/users/{id}` | 管理员改用户基础信息(用户号 / 关联职员 / 用户类型 / 语言 / 单据修改权限 / 作废标志 / 权限组);`sUserName` 与 `sPassword` 不可改;权限组**全量覆盖**语义;可选字段 null = 不更新(保守不清空)。错误码 0/40001/40301/40401。 | | |
| 27 | +| REQ-USR-003 | 查询用户 | `GET /api/usr/users` | 任意已认证用户的只读单条件分页查询(8 个查询字段 × 3 种匹配方式);跨表 LEFT JOIN `usr_employee` 取员工名 / 部门;文本模糊(LIKE 转义)/ 枚举 / 布尔 / 日期精确匹配;分页参数非法 → `42201`,数据越界返回最后一页;密码绝不返回。错误码 0/40001/42201(+401 未认证)。 | | |
| 28 | +| REQ-USR-004 | 登录用户 | `POST /api/usr/login`(+ 配套 `GET /api/usr/companies`) | 用户名 + 密码 + 版本(companyId) 认证:BCrypt 比对 → 签发带 exp 的无状态 JWT + 更新 `tLastLoginDate`;防账号枚举(不存在/密码错误同码同提示);禁用账号 `40302`;进程内登录限流 `42901`(默认 5 次/300 秒)。配套补齐只读公司列表端点支撑登录页「版本」下拉。错误码 0/40001/40101/40302/42901(+401)。 | | |
| 29 | + | |
| 30 | +> spec 全文:`docs/superpowers/specs/2026-06-01-REQ-USR-00{1,2,3,4}.md`。各 spec § 8 已逐条登记自主决策(见本报告 § ⑩)。 | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## ③ 文件变更摘要(git diff --stat,master...HEAD) | |
| 35 | + | |
| 36 | +- **提交数**:53 个 commit(`git log master..HEAD --oneline`)。 | |
| 37 | +- **变更规模**:85 个文件,+8280 / -5 行。 | |
| 38 | +- **新增生产代码**(`backend/src/main/java/com/xly/erp/`,A=新增): | |
| 39 | + - 公共基础设施 `common/`:`base/BaseEntity`、`config/{MybatisPlusConfig,SecurityConfig}`、`exception/{BusinessException,GlobalExceptionHandler}`、`response/{Result,ResultCode,PageResult}`、`security/{JwtUtil,JwtAuthenticationFilter,SecurityUtil}`、`ErpApplication`。 | |
| 40 | + - USR 模块 `modules/usr/`:controller(`UsrUserController`、`UsrAuthController`)、service(`UsrUserService(+Impl)`、`UsrAuthService(+Impl)`)、mapper(`UsrUser/UsrCompany/UsrEmployee/UsrPermission/UsrUserPermission` Mapper)、entity(`UsrUser/UsrCompany/UsrEmployee/UsrPermission/UsrUserPermission`)、dto(`CreateUserDTO/UpdateUserDTO/UserQueryDTO/UserQueryCondition/LoginDTO`)、vo(`UserVO/LoginVO/CompanyOptionVO`)。 | |
| 41 | + - 资源:`application.yml`、`application-test.yml`、`mapper/usr/UsrUserMapper.xml`(跨表分页查询)。 | |
| 42 | + - 构建:`backend/pom.xml`(Spring Boot + MyBatis-Plus + Flyway 10.10.0 + JWT,`java.version=17`)、`backend/checkstyle.xml`。 | |
| 43 | +- **新增测试代码**(`backend/src/test/java/`):25 个测试类(详见 § ⑤)。 | |
| 44 | +- **流程 / 文档**:`scripts/test.mjs`(M,新增 `ensureJava17()`)、`docs/08-模块任务管理.md`(M,4 个 REQ 勾选)、`docs/superpowers/{specs,plans,reviews}/2026-06-01-REQ-USR-00{1..4}*.md`(A)、`docs/superpowers/module-reports/usr-test-gate-r1.md`(A)。 | |
| 45 | +- **migration**:无新增(见 § ⑥)。 | |
| 46 | +- **跨模块**:无(见 § ⑦)。 | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +## ④ 读写的数据表 | |
| 51 | + | |
| 52 | +本模块涉及 5 张表,全部由 `sql/migrations/V1__initial_schema.sql` 建好(A4 阶段从 docs/03 翻译),本模块**未做 schema 变更**: | |
| 53 | + | |
| 54 | +| 表 | 操作 | 涉及 REQ | 说明 | | |
| 55 | +|---|---|---|---| | |
| 56 | +| `usr_user` | 读 + 写 | 001(写) / 002(写) / 003(读) / 004(读+写 `tLastLoginDate`) | 用户主表,主键 `iIncrement`,唯一索引 `uk_usr_user_username`。 | | |
| 57 | +| `usr_user_permission` | 写 | 001(批量插入) / 002(全量覆盖) | 用户-权限多对多关联表,唯一索引 `uk_usr_user_permission(iUserId,iPermissionId)`。 | | |
| 58 | +| `usr_employee` | 读 | 001/002(存在性校验) / 003(LEFT JOIN 取员工名/部门) | 职员表;与 `usr_user` 外键 `fk_usr_user_employee` ON DELETE SET NULL。 | | |
| 59 | +| `usr_permission` | 读 | 001/002(权限 id 存在性校验) | 权限组表。 | | |
| 60 | +| `usr_company` | 读 | 004(版本下拉 + companyId 存在性校验) | 公司/版本支撑表,与 `usr_user` 无外键(仅登录时选择)。 | | |
| 61 | + | |
| 62 | +> 严格遵循 docs/04 § 3.4:仅经 Mapper 访问;查询不 SELECT `sPassword`;VO 不含密码列;SQL 参数化防注入,LIKE 通配符转义。 | |
| 63 | + | |
| 64 | +--- | |
| 65 | + | |
| 66 | +## ⑤ 测试与质量闸(test-gate) | |
| 67 | + | |
| 68 | +**flake 判定:无 flake。** test-gate attempt 序列仅 `r1`,且 r1 = **GREEN**(首跑即绿)。 | |
| 69 | + | |
| 70 | +> 说明:历史上曾在 JDK25 默认工具链下有 5 次 RED 记录(旧 r1–r5),其根因为 Mockito/Byte Buddy 不支持 JDK25 的 class file 版本(69),导致 `UsrAuthServiceImplTest` 对 `JwtUtil` 整片 `Mockito cannot mock` 报 error(终态 88 跑 / 9 error)。该批旧证据因属过期工具链问题已在 commit `282a524` 移除,并非当前 attempt 序列的一部分;工具链修复在 `dbc3454`(`scripts/test.mjs` 新增 `ensureJava17()`,把后端测试 JVM 的 `JAVA_HOME` 固定到 Java 17)。本模块当前 attempt 序列从修复后重新起算,r1 首跑即绿,故不构成「red→green flake」。 | |
| 71 | + | |
| 72 | +**本次 r1 证据**(`docs/superpowers/module-reports/usr-test-gate-r1.md`): | |
| 73 | + | |
| 74 | +- 执行命令:`node scripts/test.mjs`(内部 `ensureJava17()` → `/opt/homebrew/Cellar/openjdk@17/17.0.19/.../Home`)。 | |
| 75 | +- 工具链确认:Spring Boot 测试日志 `Starting ErpApplicationTests using Java 17.0.19`;Maven 3.9.15 / Java 17.0.19。 | |
| 76 | +- 5 级闸门:1/5 setup-db ✅、2/5 build(`mvn -q -B -DskipTests package`) ✅、3/5 lint(`mvn -q -B checkstyle:check`) ✅、4/5 unit+integration(`mvn -q -B test`) ✅、5/5 e2e(后端无 e2e,skip)✅。 | |
| 77 | +- 退出码 0,终态 `[test.mjs] GREEN`。 | |
| 78 | +- **测试统计(surefire-reports 汇总)**:`Tests run: 125, Failures: 0, Errors: 0, Skipped: 0`(25 个测试类)。曾整片报错的 `UsrAuthServiceImplTest`(含 `JwtUtil` mock)本次 9/9 全绿,证明根因消除。 | |
| 79 | + | |
| 80 | +**测试类清单(25)**: | |
| 81 | + | |
| 82 | +- 公共:`ErpApplicationTests`、`SecurityConfigTest`、`GlobalExceptionHandlerTest`、`ResultTest`、`ResultCodeLoginTest`、`PageResultTest`、`JwtUtilTest`。 | |
| 83 | +- DTO 校验:`CreateUserDTOValidationTest`、`UpdateUserDTOValidationTest`、`UserQueryDTOValidationTest`、`LoginDTOValidationTest`。 | |
| 84 | +- VO 序列化:`UserVOJsonTest`、`LoginVOJsonTest`、`CompanyOptionVOJsonTest`。 | |
| 85 | +- Mapper:`UsrUserMapperPageTest`、`UsrCompanyMapperTest`。 | |
| 86 | +- Service 单测:`UsrUserServiceImplTest`(24)、`UsrAuthServiceImplTest`(9)。 | |
| 87 | +- Controller 单测:`UsrUserControllerTest`(11)、`UsrAuthControllerTest`(6)。 | |
| 88 | +- 端到端 IT:`UsrUserCreateIT`(5)、`UsrUserUpdateIT`(6)、`UsrUserQueryIT`(13)、`UsrLoginIT`(12)、`AuthLoginConfigIT`(1)。 | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +## ⑥ 数据库 migration | |
| 93 | + | |
| 94 | +**本模块未新增 migration。** | |
| 95 | + | |
| 96 | +- `git diff --name-only --diff-filter=A master...HEAD -- 'sql/migrations/V*.sql'` 结果为空。 | |
| 97 | +- 现有唯一 migration `sql/migrations/V1__initial_schema.sql` 由 A4 阶段 `db-init` 从 `docs/03-数据库设计文档.md`(schema SSoT)翻译生成,第一行:`-- Flyway migration V1 — initial schema for 小羚羊`。 | |
| 98 | +- 4 个 REQ 的 spec § 8 均明确决策「复用 V1,无 schema 变更」(4 张依赖表 `usr_user/usr_employee/usr_permission/usr_user_permission/usr_company` 结构与 docs/03 一致)。REQ-USR-004 仅对 `usr_user.tLastLoginDate` 做 UPDATE(DML 非 DDL)。 | |
| 99 | +- Flyway 依赖已在 `pom.xml` 声明(`flyway-core` + `flyway-mysql` 10.10.0),schema 由 Spring Boot / 测试启动时自动 apply(符合 CLAUDE.md Schema 演化规约)。 | |
| 100 | + | |
| 101 | +--- | |
| 102 | + | |
| 103 | +## ⑦ 跨模块改动 | |
| 104 | + | |
| 105 | +**无跨模块改动。** | |
| 106 | + | |
| 107 | +- `docs/superpowers/module-reports/usr-cross-module.md` 不存在(cross-module-log 未生成 = 本模块未触碰非当前模块的业务代码)。 | |
| 108 | +- 本模块新增的 `common/**` 公共基础设施(统一响应 / 异常 / 安全 / JWT / BaseEntity)属全局基础层首次落地,非「改动其他业务模块」,不计入跨模块改动。 | |
| 109 | + | |
| 110 | +--- | |
| 111 | + | |
| 112 | +## ⑧ 与规范 / 契约的偏离 | |
| 113 | + | |
| 114 | +**实现层无偏离**——4 个 REQ 的实现端点、请求/响应字段、错误码、分层、命名、统一响应、异常处理、事务、安全均与 docs/04 技术规范及 docs/05 API 契约一致(详见各 review 报告四维核对结论)。 | |
| 115 | + | |
| 116 | +仅记录 2 项**对 docs/05 契约的反向同步建议**(实现期已落地为代码,建议择机补入契约 SSoT,非阻塞、非违规): | |
| 117 | + | |
| 118 | +1. **新增端点 `GET /api/usr/companies`**(REQ-USR-004 spec § 8 D1):docs/05 仅定义 `POST /api/usr/login`,未单列公司列表端点。为使登录链路自洽(前端需先取 companyId),后端补齐该只读放行端点。建议补入 docs/05 § REQ-USR-004。 | |
| 119 | +2. **新增限流错误码 `42901`**(REQ-USR-004 spec § 8 D7):docs/05 未列该码,系为落地卡片「连续失败锁定/限流」要求引入。建议补入 docs/05 错误码段。 | |
| 120 | + | |
| 121 | +> 以上两项均已在 spec § 8 显式登记为「建议反向同步(非本 stage 强制)」,review 亦标为非阻塞建议。其余无任何偏离。 | |
| 122 | + | |
| 123 | +--- | |
| 124 | + | |
| 125 | +## ⑨ 评审与验证记录 | |
| 126 | + | |
| 127 | +每个 REQ 均有 round-1 review(裁决 approve)+ verify(证据验证)报告,路径 `docs/superpowers/reviews/2026-06-01-REQ-USR-00{1..4}{,-verify}.md`: | |
| 128 | + | |
| 129 | +| REQ | review 裁决 | 关键核对结论 | | |
| 130 | +|---|---|---| | |
| 131 | +| REQ-USR-001 | **approve**(issues=[]) | 四维(plan-alignment / quality / architecture / docs)无 must-fix;用户名查重 + DuplicateKey 兜底并发安全;密码 BCrypt + `@JsonIgnore` 不外泄;多表写事务回滚验证;24/24 测试绿。 | | |
| 132 | +| REQ-USR-002 | **approve** | 全量覆盖授权、null=不更新语义、`sUserName/sPassword` 不可改、审计字段只读均落地;权限差量覆盖与禁用实时生效端到端验证。 | | |
| 133 | +| REQ-USR-003 | **approve** | 单条件 8 字段 × 3 匹配、跨表 LEFT JOIN、LIKE 转义防注入、参数非法 `42201` / 数据越界返回最后一页、密码不返回均验证。 | | |
| 134 | +| REQ-USR-004 | **approve** | 认证判定顺序(限流→查用户→验密码→判禁用→校 companyId→签发+更新时间)与 spec § 8 D3 一致;防账号枚举(同码同提示);JWT 含 exp;进程内限流 `42901`;安全放行 `/api/usr/companies`。 | | |
| 135 | + | |
| 136 | +- 各 review 均附非阻塞口头建议(如限流计数与事务非原子、契约反向同步、冗余去重代码简化),无任何 must-fix。 | |
| 137 | +- verify 报告确认各 REQ 在通过工具链下测试全绿、验收标准(AC)逐条覆盖。 | |
| 138 | +- 模块级 test-gate(本报告 § ⑤)在锁定 JDK17 下 125/125 全绿,作为合并默认分支前的硬闸。 | |
| 139 | + | |
| 140 | +--- | |
| 141 | + | |
| 142 | +## ⑩ 自主决策汇总(decisions) | |
| 143 | + | |
| 144 | +本报告渲染过程中的自主决策见文末 `decisions[]`(test-gate 重跑判定、flake 归类等)。各 REQ 实现期的自主决策已在对应 spec § 8 逐条登记,要点汇总: | |
| 145 | + | |
| 146 | +- **语言取值锁定**:`sLanguage` ∈ {中文,英文,繁体},不取 docs/03 带审阅占位的默认值(REQ-001 D1)。 | |
| 147 | +- **管理员判定口径**:以 `sUserType=超级管理员` 视为有写权限(无独立角色表)(REQ-001 D2 / 002 D6)。 | |
| 148 | +- **修改语义**:可选字段 null = 不更新(保守不清空);权限组全量覆盖(REQ-002 D3/D4)。 | |
| 149 | +- **查询边界**:单条件查询;参数非法 `42201` vs 数据越界返回最后一页二分;LIKE 通配符转义(REQ-003 D1/D2/D3)。 | |
| 150 | +- **登录链路自洽**:补 `GET /api/usr/companies` 端点;进程内限流 `42901`(5 次/300 秒);先验密码再判禁用防枚举;JWT 默认 2 小时(REQ-004 D1/D3/D7/D10)。 | |
| 151 | +- **migration**:4 个 REQ 全部复用 V1,无新增(REQ-001~004 各 Dn)。 | |
| 152 | + | |
| 153 | +--- | |
| 154 | + | |
| 155 | +## ⑪ 已知限制 / 后续事项 | |
| 156 | + | |
| 157 | +1. **登录限流为进程内 MVP**:当前按 `sUserName` 在进程内存计数(默认 5 次失败 / 锁定 300 秒),分布式部署下不共享、不随 `@Transactional` 回滚(极小窗口)。技术栈虽列 Redis 但当前无连接配置,留待后续平滑替换为 Redis 原子计数(REQ-004 spec § 8 D7、review 非阻塞建议)。 | |
| 158 | +2. **契约反向同步待补**:`GET /api/usr/companies` 端点与 `42901` 错误码建议补入 `docs/05-API接口契约.md`(见 § ⑧)。 | |
| 159 | +3. **多租户列兜底**:`sBrandsId/sSubsidiaryId` 由 DB 默认值 `1111111111` 兜底,未引入运行时多租户上下文解析(按 spec 设计,当前阶段足够)。 | |
| 160 | +4. **冗余防御代码**:`UsrUserServiceImpl` 权限去重存在一处可简化的二次去重(review 口头建议,无功能影响)。 | |
| 161 | +5. **前端未实现**:FE-01~FE-04(登录页 / 主页框架 / 用户列表 / 用户单据)属前端阶段,docs/08 § 三 待办,不在本后端模块范围。 | |
| 162 | + | |
| 163 | +--- | |
| 164 | + | |
| 165 | +## ⑫ 结论 | |
| 166 | + | |
| 167 | +USR 用户管理后端模块 4 个 REQ(增 / 改 / 查 / 登录)全部实现并 approve,模块级硬测试闸在锁定 JDK17 工具链下 **125/125 全绿(r1,无 flake)**,工作树干净,无新增 migration、无跨模块改动、实现层无偏离(仅 2 项非阻塞契约反向同步建议)。满足里程碑 `milestone/usr` 标记的全部前置条件。 | |
| 168 | + | |
| 169 | +> 证据:`docs/superpowers/module-reports/usr-test-gate-r1.md`(GREEN)。 | ... | ... |
docs/superpowers/module-reports/usr-test-gate-r1.md
0 → 100644
| 1 | +# usr 模块硬测试闸 —— attempt r1(GREEN) | |
| 2 | + | |
| 3 | +- **模块**: usr(用户/认证) | |
| 4 | +- **阶段**: backend test-gate(合并默认分支前的硬测试闸) | |
| 5 | +- **attempt**: r1 | |
| 6 | +- **判定**: ✅ **GREEN** | |
| 7 | +- **执行时间**: 2026-06-01 15:56–15:57 CST | |
| 8 | +- **执行命令**: `node scripts/test.mjs`(内部 `ensureJava17()` 固定 JDK17 后逐级执行 setup-db → build → lint → unit+integration → e2e) | |
| 9 | +- **功能分支 HEAD**: `282a524`(`chore(usr): 移除 JDK25 工具链导致的过期红色 test-gate 证据 r1-r5`) | |
| 10 | + | |
| 11 | +## 背景:根因与修复 | |
| 12 | + | |
| 13 | +历史上 usr 模块在 JDK25 默认工具链下连续 5 次 RED(旧 r1–r5 证据),根因为: | |
| 14 | + | |
| 15 | +- Maven Surefire fork 出的测试 JVM 沿用开发机默认 JDK(**Java 25**,class file 版本 69)。 | |
| 16 | +- Mockito 自带的 Byte Buddy 不支持过新的 class file 版本(仅到 Java 22 = 66),导致 `UsrAuthServiceImplTest.setUp` 阶段对 `com.xly.erp.common.security.JwtUtil` 整片 `Mockito cannot mock this class` 报 error。 | |
| 17 | +- 旧 r5 终态:`Tests run: 88, Failures: 0, Errors: 9, Skipped: 0`(9 个 error 全部来自 Mockito mock 失败)。 | |
| 18 | + | |
| 19 | +修复(已在功能分支落地): | |
| 20 | + | |
| 21 | +- `dbc3454 chore(infra): test.mjs 固定后端测试 JDK 为 Java 17` —— `scripts/test.mjs` 新增 `ensureJava17()`,在跑后端 Maven 前把进程树 `JAVA_HOME`/`PATH` 固定到 Java 17 运行时(不改全局 profile)。 | |
| 22 | +- `282a524 chore(usr): 移除 JDK25 工具链导致的过期红色 test-gate 证据 r1-r5` —— 移除过期红色证据,避免污染报告前置判定。 | |
| 23 | + | |
| 24 | +本次 r1 是修复落地后在 **JDK17 工具链**下的首次重跑。 | |
| 25 | + | |
| 26 | +## 工具链确认 | |
| 27 | + | |
| 28 | +- `/usr/libexec/java_home -v 17` → `/opt/homebrew/Cellar/openjdk@17/17.0.19/libexec/openjdk.jdk/Contents/Home` | |
| 29 | +- `ensureJava17()` 日志:`[test.mjs] 固定 JAVA_HOME=Java 17 → /opt/homebrew/Cellar/openjdk@17/17.0.19/libexec/openjdk.jdk/Contents/Home` | |
| 30 | +- Spring Boot 测试启动日志确认运行时:`Starting ErpApplicationTests using Java 17.0.19` | |
| 31 | +- Maven:Apache Maven 3.9.15 / Java version 17.0.19 | |
| 32 | + | |
| 33 | +## 5 级闸门执行结果 | |
| 34 | + | |
| 35 | +| 阶段 | 命令 | 结果 | | |
| 36 | +|-----|------|------| | |
| 37 | +| 1/5 setup test db | `node scripts/setup-test-db.mjs`(DROP+CREATE `xlyweberp_vibe_erp_test`,schema 由 Flyway 启动时 apply) | ✅ done | | |
| 38 | +| 2/5 build | `mvn -q -B -DskipTests package` | ✅ pass | | |
| 39 | +| 3/5 lint | `mvn -q -B checkstyle:check` | ✅ pass | | |
| 40 | +| 4/5 unit + integration | `mvn -q -B test` | ✅ pass | | |
| 41 | +| 5/5 e2e | 后端无 e2e(前端阶段单独执行) | ✅ skip(按 docs/04 §零约定) | | |
| 42 | + | |
| 43 | +退出码:`0`;test.mjs 终态输出:`[test.mjs] GREEN`。 | |
| 44 | + | |
| 45 | +## 测试统计(surefire-reports 汇总) | |
| 46 | + | |
| 47 | +- **总计**:`Tests run: 125, Failures: 0, Errors: 0, Skipped: 0`(25 个测试类)。 | |
| 48 | +- 关键回归:曾整片报错的 `UsrAuthServiceImplTest`(含 `JwtUtil` mock)本次 **9/9 全绿**,证明 Byte Buddy×JDK25 根因已消除。 | |
| 49 | +- 各 IT(`UsrLoginIT` 12、`UsrUserQueryIT` 13、`UsrUserUpdateIT` 6、`UsrUserCreateIT` 5、`AuthLoginConfigIT` 1)全部通过。 | |
| 50 | + | |
| 51 | +## 结论 | |
| 52 | + | |
| 53 | +usr 模块硬测试闸 r1 = **GREEN**。无 flake(本次为修复后首跑即绿;历史 RED 证据已随旧工具链一并移除,非本轮 attempt 序列)。报告阶段前置(末次 attempt green)已满足。 | ... | ... |
docs/superpowers/plans/2026-06-01-REQ-USR-001.md
0 → 100644
| 1 | +# REQ-USR-001 增加用户 — 任务级 TDD 计划(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / repository(mapper) / DTO / VO / entity / 校验 / 配置 / REST 契约实现)。**禁止**写 `frontend/**`。 | |
| 4 | +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-001.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-001.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 | |
| 5 | +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Goal(目标) | |
| 10 | + | |
| 11 | +实现后台管理员新建用户账号的唯一端点 `POST /api/usr/users`:接收 `CreateUserDTO`,校验参数与权限,对用户名查重,BCrypt 哈希初始密码,写入 `usr_user` 并按需为新用户批量授权 `usr_user_permission`,返回 `Result<{ id }>`。同时因这是项目首个后端 REQ,需先一次性搭好 `backend/` Maven 骨架与 `common/**` 公共基础设施(统一响应 / 异常 / 安全 / BaseEntity / 配置),供本 REQ 及后续 REQ 复用。 | |
| 12 | + | |
| 13 | +## Architecture(架构 / 分层) | |
| 14 | + | |
| 15 | +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`(来自 `config-vars.yaml backend.base_package`): | |
| 16 | + | |
| 17 | +``` | |
| 18 | +backend/ | |
| 19 | +├── pom.xml # spring-boot 3 / mybatis-plus / mysql / flyway-core+flyway-mysql / spring-security / jjwt / mapstruct / hutool / bcrypt(随 spring-security) / spring-boot-starter-test | |
| 20 | +├── src/main/java/com/xly/erp/ | |
| 21 | +│ ├── ErpApplication.java # 启动类 @SpringBootApplication + @MapperScan("com.xly.erp.**.mapper") | |
| 22 | +│ ├── common/ | |
| 23 | +│ │ ├── response/Result.java # 统一响应体 Result<T> | |
| 24 | +│ │ ├── response/ResultCode.java # 错误码枚举(0/40001/40301/40901/40101/40302/40401/42201) | |
| 25 | +│ │ ├── exception/BusinessException.java # 业务异常 | |
| 26 | +│ │ ├── exception/GlobalExceptionHandler.java # @RestControllerAdvice:BusinessException / MethodArgumentNotValidException / DuplicateKeyException / 兜底 | |
| 27 | +│ │ ├── base/BaseEntity.java # 标准列公共字段(iIncrement/sId/sBrandsId/sSubsidiaryId/tCreateDate)+ MP 自动填充元信息 | |
| 28 | +│ │ ├── config/MybatisPlusConfig.java # MP 配置(自动填充 MetaObjectHandler、可选分页插件) | |
| 29 | +│ │ ├── config/SecurityConfig.java # SecurityFilterChain:放行 /api/usr/login + swagger,其余需认证;注册 BCryptPasswordEncoder Bean;挂 JwtAuthenticationFilter | |
| 30 | +│ │ └── security/JwtAuthenticationFilter.java + JwtUtil.java + SecurityUtil.java # JWT 解析过滤器、JWT 工具、取当前登录用户名工具 | |
| 31 | +│ └── modules/usr/ | |
| 32 | +│ ├── controller/UsrUserController.java # 仅 @Valid + 委派 | |
| 33 | +│ ├── service/UsrUserService.java # 接口 | |
| 34 | +│ ├── service/impl/UsrUserServiceImpl.java # 业务实现 @Transactional | |
| 35 | +│ ├── mapper/UsrUserMapper.java # BaseMapper<UsrUser> | |
| 36 | +│ ├── mapper/UsrUserPermissionMapper.java # BaseMapper<UsrUserPermission> | |
| 37 | +│ ├── mapper/UsrEmployeeMapper.java # BaseMapper<UsrEmployee>(读校验) | |
| 38 | +│ ├── mapper/UsrPermissionMapper.java # BaseMapper<UsrPermission>(读校验) | |
| 39 | +│ ├── entity/UsrUser.java / UsrUserPermission.java / UsrEmployee.java / UsrPermission.java | |
| 40 | +│ └── dto/CreateUserDTO.java | |
| 41 | +├── src/main/resources/ | |
| 42 | +│ ├── application.yml # 端口/数据源/MP/Flyway locations/JWT(值引用 config-vars,不硬编码密钥) | |
| 43 | +│ └── application-test.yml # 测试 profile(连同一测试库;Flyway 自动 apply V1) | |
| 44 | +└── src/test/java/com/xly/erp/ # 测试,包结构镜像主代码 | |
| 45 | +``` | |
| 46 | + | |
| 47 | +- **跨模块**:本 REQ 仅触及 `modules/usr/**` 与 `common/**`。`common/**` 为首次创建的公共基础设施(非业务模块改动),属项目骨架初始化。 | |
| 48 | +- **数据访问**:只走 Mapper(MyBatis-Plus),查重 / 存在性校验用 `LambdaQueryWrapper` 或 MP 内置;Controller 禁止直接调 Mapper。 | |
| 49 | + | |
| 50 | +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) | |
| 51 | + | |
| 52 | +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus;MySQL 8.x;Flyway 10.x(`flyway-core` + `flyway-mysql`,启动时自动 apply `sql/migrations/`)。 | |
| 53 | +- Spring Security + JWT(jjwt);`BCryptPasswordEncoder` 哈希密码;MapStruct(可选,本 REQ 转换简单可手写);Hutool。 | |
| 54 | +- 根包 `com.xly.erp`;端口 `5172`(`config-vars.yaml backend.http_port`);DB schema `xlyweberp_vibe_erp_test`;JWT 密钥取 `config-vars.yaml secrets.jwt_secret`。 | |
| 55 | +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 | |
| 56 | +- migration:**不新增**,复用 `V1__initial_schema.sql`(spec § 4 / D4)。 | |
| 57 | + | |
| 58 | +## 合同级常量(跨 task 必须一致) | |
| 59 | + | |
| 60 | +- REST:`POST /api/usr/users`。 | |
| 61 | +- 错误码(`ResultCode` 枚举,spec § 6 / docs/05):`SUCCESS=0`、`PARAM_INVALID=40001`、`FORBIDDEN=40301`、`USERNAME_EXISTS=40901`。(本枚举同时预留后续 REQ 用:`UNAUTHORIZED=40101`、`ACCOUNT_DISABLED=40302`、`NOT_FOUND=40401`、`PAGE_PARAM_INVALID=42201`,由本 REQ 一次性建好枚举,避免后续重复改公共文件。) | |
| 62 | +- 默认值:`initialPassword` 缺省 `666666`;`sUserType` 缺省 `普通用户`;`iCanModifyBill` 缺省 `0`;`sBrandsId`/`sSubsidiaryId` 由 DB 列默认 `1111111111` 兜底(实体不强制赋值)。 | |
| 63 | +- 枚举取值:`sUserType ∈ {普通用户, 超级管理员}`;`sLanguage ∈ {中文, 英文, 繁体}`(必填,spec D1:后端不强制业务默认)。 | |
| 64 | +- 系统字段:`tCreateDate`=当前时间;`sCreator`=当前登录用户名(SecurityContext 取 `sUserName`);`sPassword`=BCrypt(initialPassword);`iIsVoid=0`;`tLastLoginDate=null`。 | |
| 65 | + | |
| 66 | +## 关键签名(首次出现处给出,跨 task 保持一致) | |
| 67 | + | |
| 68 | +- `Result<T>`:静态工厂 `Result.success(T data)`、`Result.success()`、`Result.fail(ResultCode code, String message)`;字段 `int code` / `String message` / `T data`。 | |
| 69 | +- `ResultCode`:枚举常量 `(int code, String message)`,含 `getCode()` / `getMessage()`。 | |
| 70 | +- `BusinessException extends RuntimeException`:构造 `BusinessException(ResultCode code)` 与 `BusinessException(ResultCode code, String message)`;暴露 `getResultCode()`。 | |
| 71 | +- `BaseEntity`:受保护公共字段 `Integer iIncrement`(`@TableId(type = IdType.AUTO)`)/ `String sId` / `String sBrandsId` / `String sSubsidiaryId` / `LocalDateTime tCreateDate`(`@TableField(fill = INSERT)`)。 | |
| 72 | +- `CreateUserDTO`:字段与校验注解见下「DTO 形状」。 | |
| 73 | +- `UsrUserService#createUser(CreateUserDTO dto)` 返回 `Integer`(新建用户主键 `iIncrement`)。 | |
| 74 | +- `UsrUserController#createUser(@Valid @RequestBody CreateUserDTO dto)` 返回 `Result<Map<String,Object>>`(`data.id` = 新主键,键名 `id`)。 | |
| 75 | +- `SecurityUtil.currentUserName()` 返回 `String`(当前 JWT 主体的 `sUserName`)。 | |
| 76 | +- `SecurityUtil.currentUserType()` 返回 `String`(当前主体 `sUserType`,用于管理员判定)。 | |
| 77 | + | |
| 78 | +### DTO 形状(`CreateUserDTO`) | |
| 79 | + | |
| 80 | +| 字段 | 类型 | 校验注解 | | |
| 81 | +|---|---|---| | |
| 82 | +| `sUserName` | String | `@NotBlank` + `@Pattern(regexp="^[A-Za-z0-9_]{3,20}$")` | | |
| 83 | +| `sUserNo` | String | `@Size(max=50)` | | |
| 84 | +| `iEmployeeId` | Integer | —(存在性在 Service 校验) | | |
| 85 | +| `sUserType` | String | `@Pattern(regexp="^(普通用户|超级管理员)$")`(为空时 Service 兜底 `普通用户`,故不加 `@NotBlank`) | | |
| 86 | +| `sLanguage` | String | `@NotBlank` + `@Pattern(regexp="^(中文|英文|繁体)$")` | | |
| 87 | +| `iCanModifyBill` | Integer | `@Min(0)` + `@Max(1)`(为空时 Service 兜底 0) | | |
| 88 | +| `permissionIds` | `List<Integer>` | —(元素存在性在 Service 校验) | | |
| 89 | +| `initialPassword` | String | `@Size(max=100)`(为空时 Service 兜底 `666666`) | | |
| 90 | + | |
| 91 | +> 注:`@Valid` 失败由 `GlobalExceptionHandler` 统一转 `40001`。枚举/格式越界既可由注解触发(如已传非法值),也由 Service 兜底前的显式校验保证;Service 对「关联 id 不存在」「枚举越界(兜底后再判)」抛 `BusinessException(PARAM_INVALID)`。 | |
| 92 | + | |
| 93 | +--- | |
| 94 | + | |
| 95 | +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) | |
| 96 | + | |
| 97 | +> 说明:T1-T3 为项目骨架初始化(首个 REQ 必需,否则无可编译/可测的工程)。骨架就绪后 T4 起进入本 REQ 业务红绿循环。每个 task 完成后单独 commit(业务类 commit subject 带 `REQ-USR-001`)。 | |
| 98 | + | |
| 99 | +### T1 — Maven 骨架 + 启动 + 健康自检(绿) | |
| 100 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/ErpApplicationTests.java::contextLoads` —— Spring 上下文能加载(`@SpringBootTest`,激活 `test` profile)。意图:证明 pom 依赖 / 启动类 / `application.yml` 自洽、Flyway 能对测试库 apply V1、MyBatis-Plus 装配成功。 | |
| 101 | +- [ ] **实现**:`backend/pom.xml`(按 Tech Stack 声明依赖与构建命令对应插件,含 `checkstyle` 插件以支撑 lint 命令)、`backend/src/main/java/com/xly/erp/ErpApplication.java`、`backend/src/main/resources/application.yml`(端口 5172、数据源指向 `config-vars` 的 DB、`spring.flyway.locations=filesystem:../sql/migrations`、JWT 配置占位读 env/配置)、`application-test.yml`(test profile 连同一库)。 | |
| 102 | +- [ ] **验证**:子会话跑 `mvn -q -B test -Dtest=ErpApplicationTests`(或全量 `mvn -q -B test`)PASS。 | |
| 103 | +- [ ] **commit**:`chore(usr): 初始化 backend Maven 骨架与启动类` | |
| 104 | + | |
| 105 | +### T2 — 公共响应与异常基础设施 | |
| 106 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/ResultTest.java::successCarriesCodeZeroAndData` + `failCarriesBusinessCodeAndMessage`;`backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java::businessExceptionMapsToResult` —— 校验 `Result.success(data).code==0`,`Result.fail(USERNAME_EXISTS,msg)` 带 `40901` 与 message;`GlobalExceptionHandler` 把 `BusinessException` 转为对应 `Result`(可用单元方式直接调 handler 方法断言返回 `Result`)。 | |
| 107 | +- [ ] **实现**:`common/response/Result.java`、`common/response/ResultCode.java`(含「合同级常量」全部错误码)、`common/exception/BusinessException.java`、`common/exception/GlobalExceptionHandler.java`(处理 `BusinessException`→对应码、`MethodArgumentNotValidException`→`40001`、`DuplicateKeyException`→`40901`、其余→通用系统错误且记 ERROR 日志不泄栈)。 | |
| 108 | +- [ ] **验证**:子会话跑相关测试类 PASS。 | |
| 109 | +- [ ] **commit**:`feat(usr): 统一响应体/错误码枚举/全局异常处理 REQ-USR-001` | |
| 110 | + | |
| 111 | +### T3 — BaseEntity + MP 自动填充 + Security/JWT 基础设施 | |
| 112 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java::generateThenParseRoundTrip` —— 用 `config-vars` 的 `jwt_secret` 签发含 `sUserName` + `sUserType` claim 的 token,再解析能取回相同值且校验通过;`SecurityConfigTest`(轻量:断言 `BCryptPasswordEncoder` Bean 存在且 `encode/matches("666666")` 成立,可作 `@SpringBootTest` 注入或单元 new)。 | |
| 113 | +- [ ] **实现**:`common/base/BaseEntity.java`(标准列字段 + MP 注解)、`common/config/MybatisPlusConfig.java`(`MetaObjectHandler` 填充 `tCreateDate`;可注册分页插件供后续 REQ)、`common/config/SecurityConfig.java`(`SecurityFilterChain`:放行 `/api/usr/login`、swagger、actuator/health;其余 `authenticated()`;注册 `BCryptPasswordEncoder` Bean;将 `JwtAuthenticationFilter` 加在 `UsernamePasswordAuthenticationFilter` 之前;无状态 session)、`common/security/JwtUtil.java`、`common/security/JwtAuthenticationFilter.java`(解析 `Authorization: Bearer`,把 `sUserName`/`sUserType` 放入 `Authentication` 的 principal/authorities)、`common/security/SecurityUtil.java`(`currentUserName()` / `currentUserType()` 从 `SecurityContextHolder` 取)。 | |
| 114 | +- [ ] **验证**:子会话跑 `JwtUtilTest` + Security 相关测试 PASS。 | |
| 115 | +- [ ] **commit**:`feat(usr): BaseEntity 与 MP 自动填充、Security/JWT 基础设施 REQ-USR-001` | |
| 116 | + | |
| 117 | +### T4 — USR 实体 + Mapper + CreateUserDTO(含 Bean Validation) | |
| 118 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java`: | |
| 119 | + - `::rejectsBadUserName` —— `sUserName="ab"`(<3 位)或含非法字符触发约束违反。 | |
| 120 | + - `::rejectsIllegalLanguage` —— `sLanguage="日文"` 违反 `@Pattern`。 | |
| 121 | + - `::acceptsMinimalValidBody` —— `{sUserName:"good_user", sLanguage:"中文"}` 无违反(`sUserType`/`iCanModifyBill`/`initialPassword` 允许空,Service 兜底)。 | |
| 122 | + (用 `jakarta.validation.Validator` 直接 validate DTO 断言 violations 数量。) | |
| 123 | +- [ ] **实现**:`modules/usr/entity/{UsrUser,UsrUserPermission,UsrEmployee,UsrPermission}.java`(`@TableName` 映射对应表,字段名与匈牙利前缀列名一致;`UsrUser`/关联表可继承 `BaseEntity` 复用标准列,业务列按 docs/03 列出,`sPassword` 标 `@TableField` 不参与序列化输出可用 VO 层控制——本 REQ 不返回实体)、`modules/usr/mapper/{UsrUserMapper,UsrUserPermissionMapper,UsrEmployeeMapper,UsrPermissionMapper}.java`(继承 `BaseMapper<T>`)、`modules/usr/dto/CreateUserDTO.java`(按「DTO 形状」加校验注解)。 | |
| 124 | +- [ ] **验证**:子会话跑 `CreateUserDTOValidationTest` PASS。 | |
| 125 | +- [ ] **commit**:`feat(usr): 用户相关实体/Mapper/CreateUserDTO 校验 REQ-USR-001` | |
| 126 | + | |
| 127 | +### T5 — Service:用户名查重 + 密码哈希 + 落库(核心写) | |
| 128 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(Mockito 单元,mock 4 个 Mapper + `BCryptPasswordEncoder` + `SecurityUtil`): | |
| 129 | + - `::createUserHashesPasswordAndSetsAuditFields` —— 给最小合法 DTO(不传 `initialPassword`),断言传给 `UsrUserMapper.insert` 的 `UsrUser`:`sPassword` 非明文 `666666` 且 `encoder.encode` 被以 `666666` 调用、`iIsVoid==0`、`sUserType=="普通用户"`、`sCreator==当前用户名`、`tLastLoginDate==null`,返回新主键。 | |
| 130 | + - `::duplicateUserNameThrows40901` —— 查重命中(mock 查到已存在)→ 抛 `BusinessException(USERNAME_EXISTS)`。 | |
| 131 | + - `::duplicateKeyExceptionTranslatesTo40901` —— `insert` 抛 `DuplicateKeyException` → Service 转 `BusinessException(USERNAME_EXISTS)`(并发兜底)。 | |
| 132 | +- [ ] **实现**:`modules/usr/service/UsrUserService.java`(接口 `Integer createUser(CreateUserDTO)`)+ `modules/usr/service/impl/UsrUserServiceImpl.java`:先按 `sUserName` 查重命中抛 `40901`;兜底默认值(`sUserType`/`iCanModifyBill`/`initialPassword`/`sLanguage` 非法再校验抛 `40001`);`BCryptPasswordEncoder.encode` 哈希;填审计字段;`insert` 捕获 `DuplicateKeyException` 转 `40901`;方法标 `@Transactional(rollbackFor = Exception.class)`。 | |
| 133 | +- [ ] **验证**:子会话跑 `UsrUserServiceImplTest`(上述三个用例)PASS。 | |
| 134 | +- [ ] **commit**:`feat(usr): 新增用户 Service 查重/哈希/落库 REQ-USR-001` | |
| 135 | + | |
| 136 | +### T6 — Service:关联职员/权限存在性校验 + 权限批量授权 | |
| 137 | +- [ ] **测试**:续 `UsrUserServiceImplTest`: | |
| 138 | + - `::nonExistentEmployeeThrows40001` —— 传 `iEmployeeId` 但 `UsrEmployeeMapper` 查不到 → `BusinessException(PARAM_INVALID)`,不 insert。 | |
| 139 | + - `::nonExistentPermissionThrows40001` —— `permissionIds` 含库中不存在 id → `40001`,回滚(不写 user_permission)。 | |
| 140 | + - `::grantsDedupedPermissions` —— `permissionIds=[a,a,b]`(均存在)→ 去重后对 `UsrUserPermissionMapper` 批量插入 2 行 `(newUserId,a)`/`(newUserId,b)`(断言插入次数/内容)。 | |
| 141 | +- [ ] **实现**:在 `UsrUserServiceImpl.createUser` 内:`iEmployeeId` 非空时校验 `usr_employee` 存在(不存在抛 `40001`);`permissionIds` 非空时去重并逐个/批量校验 `usr_permission` 存在(缺失抛 `40001`),用户落库后用 `iIncrement` 批量写 `usr_user_permission`;全程同一 `@Transactional`。 | |
| 142 | +- [ ] **验证**:子会话跑上述三用例 PASS。 | |
| 143 | +- [ ] **commit**:`feat(usr): 新增用户关联职员/权限校验与授权写入 REQ-USR-001` | |
| 144 | + | |
| 145 | +### T7 — Controller + 权限前置(管理员判定) | |
| 146 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(`@WebMvcTest(UsrUserController.class)` + MockMvc,mock `UsrUserService` 与安全上下文): | |
| 147 | + - `::adminCreateReturnsCodeZeroWithId` —— 模拟管理员(`sUserType=超级管理员`)POST 合法 body → HTTP 200,响应 `code==0`,`data.id` 为返回主键;响应体不含 `sPassword`/明文密码。 | |
| 148 | + - `::nonAdminReturns40301` —— 模拟普通用户(`sUserType=普通用户`)→ `code==40301`,Service.createUser 不被调用。 | |
| 149 | + - `::invalidBodyReturns40001` —— `sUserName` 非法 → `code==40001`(经 `@Valid` + `GlobalExceptionHandler`)。 | |
| 150 | +- [ ] **实现**:`modules/usr/controller/UsrUserController.java`:`@PostMapping("/api/usr/users")`,`@Valid @RequestBody CreateUserDTO`;入口判定 `SecurityUtil.currentUserType()` 非 `超级管理员` 抛 `BusinessException(FORBIDDEN)`(先于业务校验,spec § 3.9);委派 `service.createUser`;返回 `Result.success(Map.of("id", newId))`。Controller 不直接调 Mapper、不写业务逻辑。 | |
| 151 | +- [ ] **验证**:子会话跑 `UsrUserControllerTest` PASS。 | |
| 152 | +- [ ] **commit**:`feat(usr): 新增用户 Controller 与管理员权限前置 REQ-USR-001` | |
| 153 | + | |
| 154 | +### T8 — 端到端验收回归(按 spec § 7 验收标准收口) | |
| 155 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java`(`@SpringBootTest` + MockMvc,test profile 连测试库,Flyway 已 apply V1;测试内预置/清理 fixture 数据)覆盖 spec § 7: | |
| 156 | + - `::ac1_normalCreatePersistsHashedPassword` —— 管理员合法 body → `code=0`,库中新增一行,`sPassword` 可被 `encoder.matches("666666", hash)` 通过,`iIsVoid=0`、`sCreator`=调用者、`tCreateDate` 已填。 | |
| 157 | + - `::ac2_duplicateUserNameRollsBack` —— 重复 `sUserName` → `40901`,无新增行。 | |
| 158 | + - `::ac4_permissionGrantWritesRows` —— `permissionIds=[a,b]`(先插 fixture 权限)→ `usr_user_permission` 新增 2 行。 | |
| 159 | + - `::ac5_nonAdminForbidden` —— 普通用户/无 token → `40301` / 401,不创建记录。 | |
| 160 | + - `::ac7_responseHasNoPassword` —— 成功响应体仅含 `data.id`,无密码字段。 | |
| 161 | + (IT 通过真实 BCrypt + 真实库写入做端到端确认;如环境无法连库,子会话需明确报告而非静默跳过。) | |
| 162 | +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如审计字段填充、权限去重边界),不引入新公共契约。 | |
| 163 | +- [ ] **验证**:子会话跑 `UsrUserCreateIT` PASS(连库);随后跑全量 `mvn -q -B test` 全绿,`mvn -q -B checkstyle:check` 通过。 | |
| 164 | +- [ ] **commit**:`test(usr): 新增用户端到端验收回归 REQ-USR-001` | |
| 165 | + | |
| 166 | +--- | |
| 167 | + | |
| 168 | +## 自审 | |
| 169 | + | |
| 170 | +### 占位符扫描 | |
| 171 | +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位(DB 文档 D1/D5 的「需用户审阅」遗留标记不在本后端 REQ 作用域,按 spec D1 取「`sLanguage` 必填、不强制业务默认」继续)。 | |
| 172 | + | |
| 173 | +### Spec coverage(spec 每节 → task 映射) | |
| 174 | +- § 1 Goal → T7(端点)+ 全部 task。 | |
| 175 | +- § 2.1 输入 / DTO 字段与校验 → T4(DTO 校验)+ T5/T6(Service 兜底与存在性)。 | |
| 176 | +- § 2.2 输出 `Result<{id}>` → T2(Result)+ T7(Controller 组装)。 | |
| 177 | +- § 3.1 用户名唯一(含并发 DuplicateKey 转 40901)→ T5。 | |
| 178 | +- § 3.2 密码默认 + BCrypt + 不落明文/不进响应 → T3(encoder)+ T5(哈希)+ T7/T8(响应不含密码)。 | |
| 179 | +- § 3.3 用户类型默认与约束 → T4(注解)+ T5(兜底/越界 40001)。 | |
| 180 | +- § 3.4 语言取值约束 → T4(`@Pattern`)。 | |
| 181 | +- § 3.5 关联职员存在性 → T6。 | |
| 182 | +- § 3.6 权限组多对多授权 + 去重 + 唯一索引 → T6。 | |
| 183 | +- § 3.7 新建即生效 `iIsVoid=0` → T5(审计/状态字段)+ T8(AC1)。 | |
| 184 | +- § 3.8 制单人/创建时间审计 → T3(MetaObjectHandler)+ T5(显式赋值 sCreator)。 | |
| 185 | +- § 3.9 权限前置(非管理员 40301,先于业务)→ T7。 | |
| 186 | +- § 4 约束(分层/包路径/命名/统一响应/异常/事务/认证/数据访问/配置/schema)→ T1-T7 分层落位,schema 复用 V1(无新 migration)。 | |
| 187 | +- § 5 Schema 引用 → T4(实体/Mapper 映射 4 表)。 | |
| 188 | +- § 6 错误码 → T2(ResultCode 枚举)。 | |
| 189 | +- § 7 验收标准 1-7 → T8(IT 覆盖 AC1/2/4/5/7;AC3 参数非法在 T4+T7,AC6 默认密码在 T5+T8 AC1)。 | |
| 190 | +- § 8 decisions(D1-D5)→ 已体现于「合同级常量」「DTO 形状」与不新增 migration(D4)。 | |
| 191 | + | |
| 192 | +### 类型一致性 | |
| 193 | +- `Result` / `ResultCode` / `BusinessException` 签名在 T2 定义,T5/T6/T7 一致引用。 | |
| 194 | +- `CreateUserDTO` 字段与校验在 T4 锁定,T5/T6/T7/T8 一致使用。 | |
| 195 | +- `UsrUserService#createUser(CreateUserDTO):Integer` 与 `UsrUserController#createUser` 返回 `Result<Map>`(`data.id`)跨 T5/T7 一致。 | |
| 196 | +- 错误码字面量 `0/40001/40301/40901` 与 docs/05、spec § 6 一致。 | |
| 197 | +- REST 路径 `/api/usr/users`、放行路径 `/api/usr/login` 与 docs/05 一致。 | ... | ... |
docs/superpowers/plans/2026-06-01-REQ-USR-002.md
0 → 100644
| 1 | +# REQ-USR-002 修改用户 — 任务级 TDD 计划(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / DTO / 校验 / REST 契约实现)。**禁止**写 `frontend/**`。 | |
| 4 | +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-002.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-002.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 | |
| 5 | +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 | |
| 6 | +> 本 REQ 复用 REQ-USR-001 已建的 backend 骨架与 `modules/usr/**`(controller / service / mapper / entity)、`common/**`(Result / ResultCode / BusinessException / GlobalExceptionHandler / SecurityUtil / JWT);**不**新建公共基础设施,**不**新增 migration。 | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## Goal(目标) | |
| 11 | + | |
| 12 | +实现后台管理员修改已有用户基本信息的唯一端点 `PUT /api/usr/users/{id}`:接收路径 `id` + `UpdateUserDTO`,前置管理员权限校验,校验目标用户存在、枚举/取值合法、关联职员与权限组 id 存在,按"部分更新(null 不改)"语义更新 `usr_user`(不动 `sUserName` / `sPassword` / 审计列 / 租户列),并按"全量覆盖"语义重写该用户在 `usr_user_permission` 的授权,整体单事务,返回 `Result<{ id }>`。变更立即生效(角色 / 禁用状态实时反映到查询与登录流程)。 | |
| 13 | + | |
| 14 | +## Architecture(架构 / 分层) | |
| 15 | + | |
| 16 | +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**`,不跨模块、不动 `common/**`: | |
| 17 | + | |
| 18 | +``` | |
| 19 | +backend/src/main/java/com/xly/erp/modules/usr/ | |
| 20 | +├── controller/UsrUserController.java # 既有类,新增 @PutMapping("/users/{id}") updateUser;仅 @Valid + 管理员前置 + 委派 | |
| 21 | +├── service/UsrUserService.java # 既有接口,新增 updateUser(Integer id, UpdateUserDTO dto) | |
| 22 | +├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 updateUser 实现 @Transactional | |
| 23 | +├── mapper/UsrUserMapper.java # 既有,复用 BaseMapper<UsrUser>(selectById/updateById/update) | |
| 24 | +├── mapper/UsrUserPermissionMapper.java # 既有,复用 BaseMapper(delete by iUserId / insert) | |
| 25 | +├── mapper/UsrEmployeeMapper.java # 既有,复用 selectById 做职员存在性校验 | |
| 26 | +├── mapper/UsrPermissionMapper.java # 既有,复用 selectById 做权限存在性校验 | |
| 27 | +├── entity/{UsrUser,UsrUserPermission,UsrEmployee,UsrPermission}.java # 既有,无需改 | |
| 28 | +└── dto/UpdateUserDTO.java # 【本 REQ 新增】修改用户入参 | |
| 29 | +``` | |
| 30 | + | |
| 31 | +- **跨模块**:无。本 REQ 全部落在 `modules/usr/**`,不动 `common/**`,不新增公共契约(错误码 `40401`/`40301`/`40001` 已在 REQ-USR-001 一次性建好的 `ResultCode` 枚举中预留,直接复用,**不改 `ResultCode`**)。 | |
| 32 | +- **数据访问**:只走 Mapper(MyBatis-Plus);存在性校验 / 删授权用 `LambdaQueryWrapper` 或 MP 内置(`selectById` / `selectCount` / `delete`);Controller 禁止直接调 Mapper。 | |
| 33 | +- **部分更新落库策略**:为实现"null 列不更新",更新主记录用 MP 的 `updateById`(MP 默认 null 字段不参与 SET,恰好匹配"传 null 不改该列"语义),目标实体只 set 非 null 的可更新列 + 主键 `iIncrement`;不 set `sUserName` / `sPassword` / `sCreator` / `tCreateDate` / 租户列,杜绝覆盖原值。 | |
| 34 | + | |
| 35 | +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) | |
| 36 | + | |
| 37 | +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus;MySQL 8.x;Flyway 10.x(启动时自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / D5)。 | |
| 38 | +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证)。 | |
| 39 | +- 根包 `com.xly.erp`;端口与 DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。 | |
| 40 | +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 | |
| 41 | + | |
| 42 | +## 合同级常量(跨 task 必须一致) | |
| 43 | + | |
| 44 | +- REST:`PUT /api/usr/users/{id}`(`id` 为 `@PathVariable Integer`)。 | |
| 45 | +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**): | |
| 46 | + - `SUCCESS=0` — 成功,`data.id` = 被修改用户主键。 | |
| 47 | + - `PARAM_INVALID=40001` — 参数校验失败(字段格式 / 必填 / 枚举越界 / `iEmployeeId` 或 `permissionIds` 元素不存在 / `id` 非正整数)。 | |
| 48 | + - `FORBIDDEN=40301` — 无权限(非管理员调用)。 | |
| 49 | + - `NOT_FOUND=40401` — 用户不存在(路径 `id` 无对应 `usr_user` 记录)。 | |
| 50 | +- 管理员判定口径(与 REQ-USR-001 一致,spec § 8 D6):`SecurityUtil.currentUserType()` 等于常量 `超级管理员` 视为有权;否则抛 `BusinessException(FORBIDDEN)`。控制器内沿用既有 `ADMIN_USER_TYPE = "超级管理员"` 常量,不重复定义新口径。 | |
| 51 | +- 枚举取值:`sUserType ∈ {普通用户, 超级管理员}`;`sLanguage ∈ {中文, 英文, 繁体}`;`iCanModifyBill ∈ {0,1}`;`iIsVoid ∈ {0,1}`(0 正常 / 1 禁用)。 | |
| 52 | +- 部分更新语义(spec § 8 D3):可选字段(`sUserNo` / `iEmployeeId` / `iCanModifyBill` / `iIsVoid`)传 `null` = 本次不改该列(保持原值);非 null 即覆盖。 | |
| 53 | +- 权限覆盖语义(spec § 8 D4):`permissionIds` 非 null = 以该集合(去重)全量覆盖该用户授权(先删该用户全部旧授权再批量插入去重后的目标集合,结果 = 目标集合);`[]` = 清空全部授权;`null` = 不改动现有授权。 | |
| 54 | +- 只读 / 不接收字段(spec § 2.1 / § 3):`sUserName` / `sPassword` / `tCreateDate` / `sCreator` / `tLastLoginDate` / `iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` 一律不在本接口更新;`UpdateUserDTO` 不含这些字段(即便请求体携带也因 DTO 无该字段而被忽略)。 | |
| 55 | + | |
| 56 | +## 关键签名(首次出现处给出,跨 task 保持一致) | |
| 57 | + | |
| 58 | +- `UsrUserService#updateUser(Integer id, UpdateUserDTO dto)` 返回 `Integer`(被修改用户主键 `iIncrement`,等于入参 `id`)。 | |
| 59 | +- `UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)` 返回 `Result<Map<String, Object>>`(`data.id` = 被修改主键,键名 `id`,与 REQ-USR-001 `createUser` 返回形状一致)。 | |
| 60 | +- 复用既有:`SecurityUtil.currentUserType()` → `String`;`Result.success(T)`;`BusinessException(ResultCode)` / `BusinessException(ResultCode, String)` 暴露 `getResultCode()`;Mapper 继承 `BaseMapper`(`selectById` / `updateById` / `selectCount` / `delete` / `insert`);`UsrUserPermission(Integer iUserId, Integer iPermissionId)` 构造器。 | |
| 61 | +- 实体 getter/setter 沿用匈牙利前缀风格(`getIIncrement` / `setSUserType` / `setIIsVoid` 等,已存在于 `UsrUser` / `UsrUserPermission`)。 | |
| 62 | + | |
| 63 | +### DTO 形状(`UpdateUserDTO`,置于 `modules/usr/dto`) | |
| 64 | + | |
| 65 | +> 字段为匈牙利前缀命名(与列名一致),getter 形如 `getSUserType` 会被 Jackson 推断为属性名 `SUserType`,与契约 JSON 键不符;故对带前缀字段显式 `@JsonProperty` 锁定 JSON 键名(与 `CreateUserDTO` 同样做法)。 | |
| 66 | + | |
| 67 | +| 字段 | 类型 | 校验注解 | 语义 | | |
| 68 | +|---|---|---|---| | |
| 69 | +| `sUserNo` | String | `@Size(max=50)` | 可选;非 null 覆盖,null 不改 | | |
| 70 | +| `iEmployeeId` | Integer | —(存在性在 Service 校验) | 可选;非 null 须存在于 `usr_employee` 否则 `40001`;null 不改 | | |
| 71 | +| `sUserType` | String | `@NotBlank` + `@Pattern(regexp="^(普通用户|超级管理员)$")` | 必填;越界 `40001` | | |
| 72 | +| `sLanguage` | String | `@NotBlank` + `@Pattern(regexp="^(中文|英文|繁体)$")` | 必填;越界 `40001` | | |
| 73 | +| `iCanModifyBill` | Integer | `@Min(0)` + `@Max(1)` | 可选;非 0/1 越界 `40001`;null 不改 | | |
| 74 | +| `iIsVoid` | Integer | `@Min(0)` + `@Max(1)` | 可选;0 正常 / 1 禁用;非 0/1 越界 `40001`;null 不改 | | |
| 75 | +| `permissionIds` | `List<Integer>` | —(元素存在性在 Service 校验) | 可选;非 null 全量覆盖授权;`[]` 清空;null 不改 | | |
| 76 | + | |
| 77 | +> 注:`@Valid` 失败由既有 `GlobalExceptionHandler` 统一转 `40001`。`sUserType` / `sLanguage` 既由注解兜底,也在 Service 端不做额外越界放行;Service 对"目标用户不存在"抛 `40401`,对"关联 id 不存在"抛 `40001`。本 DTO 不含 `sUserName` / `sPassword` / 审计 / 租户字段。 | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) | |
| 82 | + | |
| 83 | +> 业务类 commit subject 必须带 `REQ-USR-002` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 | |
| 84 | + | |
| 85 | +### T1 — `UpdateUserDTO` + Bean Validation | |
| 86 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java`(用 `jakarta.validation.Validator` 直接 validate DTO 断言 violations 数量): | |
| 87 | + - `::acceptsMinimalValidBody` —— 仅 `{sUserType:"普通用户", sLanguage:"中文"}`(其余可选字段全 null)无违反。 | |
| 88 | + - `::rejectsBlankUserType` —— `sUserType` 为空白触发 `@NotBlank`。 | |
| 89 | + - `::rejectsIllegalLanguage` —— `sLanguage="日文"` 违反 `@Pattern`。 | |
| 90 | + - `::rejectsOutOfRangeIsVoid` —— `iIsVoid=2` 违反 `@Max(1)`;`::rejectsOutOfRangeCanModifyBill` —— `iCanModifyBill=2` 违反 `@Max(1)`。 | |
| 91 | +- [ ] **实现**:`modules/usr/dto/UpdateUserDTO.java`,按上「DTO 形状」加字段 + `@JsonProperty` + 校验注解 + getter/setter(不含 `sUserName`/`sPassword`/审计/租户字段)。 | |
| 92 | +- [ ] **验证**:子会话跑 `UpdateUserDTOValidationTest` PASS。 | |
| 93 | +- [ ] **commit**:`feat(usr): 修改用户入参 UpdateUserDTO 与校验 REQ-USR-002` | |
| 94 | + | |
| 95 | +### T2 — Service:目标用户存在性 + 主记录部分更新(核心写) | |
| 96 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(续既有类,Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态): | |
| 97 | + - `::updateNonExistentUserThrows40401` —— `usrUserMapper.selectById(id)` 返回 null → 抛 `BusinessException(NOT_FOUND)`,且不调用 `updateById` / 不动权限表。 | |
| 98 | + - `::updateAppliesNonNullColumnsAndKeepsIdentityImmutable` —— 目标用户存在,DTO 设 `sUserType="超级管理员"` / `sLanguage="英文"` / `iCanModifyBill=1` / `iIsVoid=1` / `sUserNo="N9"`,断言传给 `usrUserMapper.updateById` 的 `UsrUser`:`iIncrement==id`、上述列为新值、且 `sUserName`/`sPassword`/`sCreator`/`tCreateDate` 未被赋值(保持 null,证明不参与 SET,依赖 MP null 不更新语义)。 | |
| 99 | + - `::nullOptionalColumnsAreNotOverwritten` —— DTO 仅含必填 `sUserType`/`sLanguage`,`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid` 均 null → 传给 `updateById` 的实体这些字段为 null(MP 不 SET,保持原值)。 | |
| 100 | +- [ ] **实现**:`UsrUserService.java` 新增 `Integer updateUser(Integer id, UpdateUserDTO dto)`;`UsrUserServiceImpl.java` 新增实现并标 `@Transactional(rollbackFor = Exception.class)`:先 `selectById(id)` 校验存在(null → `40401`);组装目标 `UsrUser` 仅 `setIIncrement(id)` + set 非 null 可更新列(`sUserType`/`sLanguage` 必填总 set,`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid` 仅在 DTO 非 null 时 set);`updateById`。 | |
| 101 | +- [ ] **验证**:子会话跑上述 3 个用例 PASS。 | |
| 102 | +- [ ] **commit**:`feat(usr): 修改用户主记录部分更新与存在性校验 REQ-USR-002` | |
| 103 | + | |
| 104 | +### T3 — Service:关联职员存在性 + 权限组全量覆盖 | |
| 105 | +- [ ] **测试**:续 `UsrUserServiceImplTest`: | |
| 106 | + - `::nonExistentEmployeeOnUpdateThrows40001` —— 目标用户存在但 DTO `iEmployeeId` 指向 `usrEmployeeMapper.selectById` 返回 null → `BusinessException(PARAM_INVALID)`,不 `updateById`、不动权限表(事务回滚语义)。 | |
| 107 | + - `::nonExistentPermissionOnUpdateThrows40001` —— `permissionIds` 含 `usrPermissionMapper.selectById` 返回 null 的 id → `40001`,不 `updateById`、不写权限表。 | |
| 108 | + - `::permissionIdsOverwriteDeletesThenInserts` —— `permissionIds=[a,a,b]`(均存在)→ 先对 `usrUserPermissionMapper.delete`(按 `iUserId=id`)调用 1 次清旧授权,再去重批量 `insert` 2 行 `(id,a)`/`(id,b)`(断言 delete 与 insert 次数/内容;用 `ArgumentCaptor` 验证 `iUserId==id`、`iPermissionId` 含 a/b 各一次)。 | |
| 109 | + - `::emptyPermissionIdsClearsAll` —— `permissionIds=[]` → `delete`(按 `iUserId=id`)被调用,`insert` 不被调用(清空)。 | |
| 110 | + - `::nullPermissionIdsLeavesGrantsUntouched` —— `permissionIds=null` → `delete` 与 `insert` 均不被调用(不改动授权)。 | |
| 111 | +- [ ] **实现**:在 `updateUser` 内(主记录更新前先做存在性校验,保证非法即整体回滚不留副作用):`iEmployeeId` 非 null 时校验 `usr_employee` 存在(缺失 `40001`);`permissionIds` 非 null 时去重并逐个校验 `usr_permission` 存在(缺失 `40001`);主记录 `updateById` 成功后,若 `permissionIds` 非 null 则按 `LambdaQueryWrapper.eq(UsrUserPermission::getIUserId, id)` 全量删除旧授权,再对去重集合逐行 `insert(new UsrUserPermission(id, permId))`(空集合则只删不插);全程同一 `@Transactional`。 | |
| 112 | +- [ ] **验证**:子会话跑上述 5 个用例 PASS。 | |
| 113 | +- [ ] **commit**:`feat(usr): 修改用户关联职员校验与权限组全量覆盖 REQ-USR-002` | |
| 114 | + | |
| 115 | +### T4 — Controller + 管理员权限前置 | |
| 116 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(续既有类,MockMvc standaloneSetup + 真实 `GlobalExceptionHandler` + mock `UsrUserService` + mock `SecurityUtil` 静态): | |
| 117 | + - `::adminUpdateReturnsCodeZeroWithId` —— 管理员(`currentUserType="超级管理员"`)`PUT /api/usr/users/55` 带合法 body(`sUserType`/`sLanguage` 合法)→ HTTP 200,`code==0`,`data.id==55`;响应体不含 `sPassword`/`password`。 | |
| 118 | + - `::nonAdminUpdateReturns40301` —— 普通用户 → `code==40301`,`usrUserService.updateUser` 不被调用。 | |
| 119 | + - `::invalidBodyUpdateReturns40001` —— body 缺 `sUserType`(`@NotBlank` 失败)→ `code==40001`,Service 不被调用。 | |
| 120 | + - `::userNotFoundReturns40401` —— 管理员 + 合法 body,但 `usrUserService.updateUser` 抛 `BusinessException(NOT_FOUND)` → `code==40401`(验证 Controller 不吞业务异常、由全局处理器转码)。 | |
| 121 | +- [ ] **实现**:`UsrUserController.java` 新增 `@PutMapping("/users/{id}")` 方法 `updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)`:入口判定 `!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())` 抛 `BusinessException(FORBIDDEN)`(先于业务校验,spec § 3.9);委派 `usrUserService.updateUser(id, dto)`;返回 `Result.success(Map.of("id", id))`。Controller 不直接调 Mapper、不写业务逻辑。 | |
| 122 | +- [ ] **验证**:子会话跑 `UsrUserControllerTest`(既有 + 新增用例)PASS。 | |
| 123 | +- [ ] **commit**:`feat(usr): 修改用户 Controller 与管理员权限前置 REQ-USR-002` | |
| 124 | + | |
| 125 | +### T5 — 端到端验收回归(按 spec § 7 验收标准收口) | |
| 126 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;用真实 `JwtUtil` 签发 token 走安全链;`@AfterEach` 自清理本测试 fixture,命名前缀如 `it2_user_` / `IT2_PERM_` 便于 likeRight 清理;管理员 token 与普通用户 token 仿 `UsrUserCreateIT` 生成)覆盖 spec § 7: | |
| 127 | + - `::ac1UpdateBasicInfoPersists` —— 先经 `POST /api/usr/users` 建一个用户取 `id`(或直接 Mapper 插 fixture),再 `PUT /api/usr/users/{id}` 改 `sUserType`/`sLanguage`/`iCanModifyBill`/`sUserNo` → `code=0`、`data.id==id`;库中该行上述列为新值,且 `sUserName`/`sPassword`/`sCreator`/`tCreateDate` 字节级不变(保存改前后 `selectById` 比对)。 | |
| 128 | + - `::ac2UpdateNonExistentReturns40401` —— `PUT /api/usr/users/{很大且不存在的 id}` → `code=40401`,无任何写入。 | |
| 129 | + - `::ac3InvalidParamRollsBack` —— `iEmployeeId` 指向不存在职员(或 `permissionIds` 含不存在 id)→ `code=40001`,目标用户行各列与调用前一致(无副作用)。 | |
| 130 | + - `::ac6PermissionOverwrite` —— 预置该用户授权为 `{a,c}`(fixture 插 `usr_permission` a/b/c + `usr_user_permission`),`PUT` 传 `permissionIds=[a,b,a]` → 覆盖后该用户授权恰为 `{a,b}`(c 被删、b 新增、a 去重一次);另一用例或同用例追加:传 `permissionIds=[]` → 该用户授权清空;不传 `permissionIds` → 授权不变。 | |
| 131 | + - `::ac7NonAdminAndNoTokenBlocked` —— 普通用户 token → `code=40301`;无 token → HTTP 401;两种情况下目标用户行均未被修改。 | |
| 132 | + - `::ac8PasswordUnchangedAndAbsentFromResponse` —— 修改成功后 `sPassword` 列与改前字节级一致(`selectById` 比对哈希值相等);成功响应体仅含 `data.id`,不含 `sPassword` / 明文 / `password` 字段。 | |
| 133 | + - (AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / REQ-USR-003 查询接口,尚未实现;本 IT 以"`PUT iIsVoid=1` / 改 `sUserType` 后 `selectById` 读回库内 `iIsVoid==1` / `sUserType` 为新值"做后端落库层等价验证,登录/查询联动留待对应 REQ 的 IT 覆盖,在本测试注释中标注此边界。) | |
| 134 | +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如 MP `updateById` 对 null 字段的实际行为与预期不符时调整组装策略、权限删除 wrapper 边界),不引入新公共契约、不新增 migration。 | |
| 135 | +- [ ] **验证**:子会话跑 `UsrUserUpdateIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。 | |
| 136 | +- [ ] **commit**:`test(usr): 修改用户端到端验收回归 REQ-USR-002` | |
| 137 | + | |
| 138 | +--- | |
| 139 | + | |
| 140 | +## 自审 | |
| 141 | + | |
| 142 | +### 占位符扫描 | |
| 143 | +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本后端 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体})继续,不阻塞。 | |
| 144 | + | |
| 145 | +### Spec coverage(spec 每节 → task 映射) | |
| 146 | +- § 1 Goal(唯一端点 `PUT /api/usr/users/{id}`、`sUserName` 不可改、密码不改)→ T4(端点)+ T2(不动 `sUserName`/`sPassword`)+ 全部 task。 | |
| 147 | +- § 2.1 输入 / DTO 字段与校验 / 路径参数 `id` / Auth → T1(DTO 校验)+ T2/T3(Service 存在性与覆盖)+ T4(路径参数 + 管理员前置)。 | |
| 148 | +- § 2.1 忽略只读/不接收字段(`sUserName`/`sPassword`/审计/租户)→ T1(DTO 不含这些字段)+ T2(组装实体不 set 这些列)+ T5 AC8(密码不变)。 | |
| 149 | +- § 2.2 输出 `Result<{id}>`、不含敏感字段 → T4(Controller 组装)+ T5 AC8。 | |
| 150 | +- § 3.1 目标用户必须存在(`40401`,不写入)→ T2 + T5 AC2。 | |
| 151 | +- § 3.2 `sUserName` 不可改 → T1(DTO 无该字段)+ T2 + T5 AC1。 | |
| 152 | +- § 3.3 密码不改/不返回 → T2(不 set `sPassword`)+ T5 AC8。 | |
| 153 | +- § 3.4 用户类型必填 + 枚举约束(越界 `40001`)→ T1(`@NotBlank`+`@Pattern`)+ T4。 | |
| 154 | +- § 3.5 语言必填 + 枚举约束 → T1(`@NotBlank`+`@Pattern`)。 | |
| 155 | +- § 3.6 关联职员可选且需存在(`40001`)/ 部分更新语义 → T3 + T2(null 不改)。 | |
| 156 | +- § 3.7 作废/禁用实时生效(`iIsVoid ∈ {0,1}`,越界 `40001`)→ T1(`@Min/@Max`)+ T2(落库)+ T5(落库层等价验证,登录联动留 REQ-USR-004)。 | |
| 157 | +- § 3.8 权限组全量覆盖(`permissionIds` 非 null 覆盖 / `[]` 清空 / null 不改 / 去重 / 唯一索引)→ T3 + T5 AC6。 | |
| 158 | +- § 3.9 权限校验前置(非管理员 `40301`,先于业务)→ T4。 | |
| 159 | +- § 3.10 审计字段只读、不新增列/migration → T2(不 set 审计列)+ Tech Stack(不新增 migration)。 | |
| 160 | +- § 4 约束(分层/包路径/命名 `updateUser`/统一响应/异常/事务/认证/数据访问/配置/schema)→ T1-T4 分层落位,事务在 T2/T3,schema 复用 V1。 | |
| 161 | +- § 5 Schema 引用(写 `usr_user`/`usr_user_permission`、读 `usr_employee`/`usr_permission`)→ T2/T3(复用既有实体/Mapper)。 | |
| 162 | +- § 6 错误码(`0`/`40001`/`40401`/`40301`)→ 复用既有 `ResultCode`,T2(`40401`)/T3(`40001`)/T4(`40301`)。 | |
| 163 | +- § 7 验收标准 1-8 → T5(AC1/2/3/6/7/8 直接覆盖;AC4/AC5 以落库层等价验证 + 注释边界,联动留对应 REQ)。 | |
| 164 | +- § 8 decisions(D1-D6)→ D1(不改密码)T2、D2(`iIsVoid` 承载状态)T1/T2、D3(null 不改)T2、D4(全量覆盖)T3、D5(不新增 migration)Tech Stack、D6(管理员口径)T4 已体现。 | |
| 165 | + | |
| 166 | +### 类型一致性 | |
| 167 | +- `UsrUserService#updateUser(Integer id, UpdateUserDTO):Integer` 在 T2 定义,T3(实现续写)/T4(Controller 调用)/T5(IT)一致引用。 | |
| 168 | +- `UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO)` 返回 `Result<Map<String,Object>>`(`data.id`),与 REQ-USR-001 `createUser` 返回形状一致。 | |
| 169 | +- `UpdateUserDTO` 字段与校验注解在 T1 锁定,T2/T3/T4/T5 一致使用;字段命名与 `UsrUser` 列名匈牙利前缀一致,`@JsonProperty` 锁 JSON 键(与 `CreateUserDTO` 同风格)。 | |
| 170 | +- 错误码字面量 `0/40001/40301/40401` 复用既有 `ResultCode`,与 docs/05、spec § 6 一致,不新增枚举常量。 | |
| 171 | +- REST 路径 `PUT /api/usr/users/{id}`、管理员口径 `超级管理员` 与 docs/05、spec、既有 `UsrUserController.ADMIN_USER_TYPE` 一致。 | |
| 172 | +- Mapper 复用既有 `BaseMapper`(`selectById`/`updateById`/`delete`/`insert`/`selectCount`)+ `UsrUserPermission(Integer,Integer)` 构造器,无新增 Mapper 方法签名。 | ... | ... |
docs/superpowers/plans/2026-06-01-REQ-USR-003.md
0 → 100644
| 1 | +# REQ-USR-003 查询用户 — 任务级 TDD 计划(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / mapper XML / DTO / VO / 公共响应 / REST 契约实现)。**禁止**写 `frontend/**`。 | |
| 4 | +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-003.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 | |
| 5 | +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 | |
| 6 | +> 本 REQ 复用 REQ-USR-001/002 已建的 `modules/usr/**`(`UsrUserController` / `UsrUserService` / `UsrUserServiceImpl` / `UsrUserMapper` / `UsrEmployee`/`UsrUser` 实体)与 `common/**`(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `SecurityUtil` / JWT / `MybatisPlusConfig` 分页插件);**纯只读查询**,**不新增 migration**(spec § 4 / § 8 D9,仅读 `usr_user` / `usr_employee`,二表已在 `V1__initial_schema.sql` 建好)。 | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## Goal(目标) | |
| 11 | + | |
| 12 | +实现后台用户查询的唯一只读端点 `GET /api/usr/users`:接收 `UserQueryDTO`(`queryField` / `matchType` / `queryValue` / `pageNum` / `pageSize`,全部可选),按"单字段 + 单匹配方式"施加一个过滤条件(空值返回全量分页),`usr_user LEFT JOIN usr_employee` 取员工名 / 部门,分页装配 `PageResult<UserVO>` 返回 `Result<PageResult<UserVO>>`(`code=0`)。查询**无任何写副作用**,响应**绝不返回 `sPassword` 或任何敏感字段**。分页参数非法(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`;查询参数越界(枚举不合法 / 超长 / 布尔值不可解析为 0/1 / 日期值非法)→ `40001`;数据层「请求页超总页数」钳制到最后一页返回 `code=0`。任意已认证用户可调用(无管理员限制,spec § 8 D5),无 / 失效 token → 401。 | |
| 13 | + | |
| 14 | +## Architecture(架构 / 分层) | |
| 15 | + | |
| 16 | +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与新增公共分页响应体 `common/response/PageResult.java`(spec § 2.2 / docs/04 § 1.4 / § 3.2 引用但前序 REQ 未建),不跨业务模块: | |
| 17 | + | |
| 18 | +``` | |
| 19 | +backend/src/main/java/com/xly/erp/ | |
| 20 | +├── common/response/PageResult.java # 【本 REQ 新增】通用分页响应体(records/total/pageNum/pageSize),后续模块复用 | |
| 21 | +├── modules/usr/ | |
| 22 | +│ ├── controller/UsrUserController.java # 既有类,新增 @GetMapping("/users") queryUsers;仅 @Valid + 委派(本端点无管理员前置,spec § 8 D5) | |
| 23 | +│ ├── service/UsrUserService.java # 既有接口,新增 queryUsers(UserQueryDTO dto) | |
| 24 | +│ ├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 queryUsers 实现(参数判定 + 条件解析 + 分页装配) | |
| 25 | +│ ├── mapper/UsrUserMapper.java # 既有,新增自定义方法 selectUserPage(LEFT JOIN + 动态条件,走 XML) | |
| 26 | +│ ├── entity/{UsrUser,UsrEmployee}.java # 既有,只读,无需改 | |
| 27 | +│ ├── dto/UserQueryDTO.java # 【本 REQ 新增】查询入参 | |
| 28 | +│ └── vo/UserVO.java # 【本 REQ 新增】查询输出(不含 sPassword/租户列) | |
| 29 | +└── resources/mapper/usr/UsrUserMapper.xml # 【本 REQ 新增】queryUsers 复杂 SQL(LEFT JOIN + <if> 动态条件 + resultMap → UserVO) | |
| 30 | +``` | |
| 31 | + | |
| 32 | +- **跨模块**:无。本 REQ 落在 `modules/usr/**` + 新增 `common/response/PageResult.java`(公共契约,非跨业务模块)。新增 `PageResult` 属于 docs/04 § 1.4 / § 3.2 已定义但尚未落盘的公共响应体,建在 `common/response`(与 `Result` / `ResultCode` 同包),后续分页 REQ 复用——需在《模块完成报告》留痕「新增公共分页响应体」(CLAUDE.md 跨模块改动留痕,虽非跨业务模块,新增公共契约一并记录)。 | |
| 33 | +- **数据访问**:只走 Mapper(MyBatis-Plus)。LEFT JOIN(`usr_user ⋈ usr_employee`)+ 动态单条件 + 分页属"复杂 SQL",按 docs/04 § 3.4 写 **Mapper XML**(`UsrUserMapper.selectUserPage(IPage<UserVO> page, ...)`),由 MP `PaginationInnerInterceptor`(`MybatisPlusConfig` 已注册)自动补 `LIMIT` 与 `COUNT(*)`。Controller 禁止直接调 Mapper。 | |
| 34 | +- **XML 扫描 / 列映射**:`application.yml` 未显式配 `mybatis-plus.mapper-locations`,MP 默认扫描 `classpath*:/mapper/**/*.xml`,故 XML 落在 `resources/mapper/usr/` 即被自动加载,**无需改 `application.yml`**;若 T4 子会话验证发现 XML 未加载,则最小补 `mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml`(仅此一行,记入 decisions)。因 `map-underscore-to-camel-case: false`,XML 用 `<resultMap>` 显式把列(含 `usr_employee` 别名列)映射到 `UserVO` 字段,不依赖驼峰自动映射。 | |
| 35 | +- **只读 / 不写**:`queryUsers` 标 `@Transactional(readOnly = true)`(spec § 3.1,可选但推荐);SQL 仅 `SELECT`,**不 SELECT `sPassword`**,不写任何表、不更新 `tLastLoginDate`、不落审计。 | |
| 36 | +- **安全过滤**:防注入用 `#{}` 预编译占位;LIKE 模糊查询对 `queryValue` 的 `%` `_` `\` 转义并 `ESCAPE '\\'`(spec § 8 D3),转义在 Service 端对 `queryValue` 预处理后传入 XML。 | |
| 37 | + | |
| 38 | +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) | |
| 39 | + | |
| 40 | +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(分页插件 `PaginationInnerInterceptor` 已在 `MybatisPlusConfig` 注册);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / § 8 D9)。 | |
| 41 | +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证),但**无管理员前置**(任意已认证用户可调用,spec § 8 D5)。 | |
| 42 | +- 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。 | |
| 43 | +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 | |
| 44 | + | |
| 45 | +## 合同级常量(跨 task 必须一致) | |
| 46 | + | |
| 47 | +- REST:`GET /api/usr/users`(query 参数绑定到 `UserQueryDTO`,非 `@RequestBody`)。 | |
| 48 | +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**;`PARAM_INVALID=40001`、`PAGE_PARAM_INVALID=42201`、`SUCCESS=0` 已存在): | |
| 49 | + - `SUCCESS=0` — 成功,`data` = `PageResult<UserVO>`。 | |
| 50 | + - `PARAM_INVALID=40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值不可解析为 0/1 / 日期字段值非法)。 | |
| 51 | + - `PAGE_PARAM_INVALID=42201` — 分页参数非法(`pageNum<1` 或 `pageSize<1` 或 `pageSize>100`)。 | |
| 52 | + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。 | |
| 53 | +- 枚举取值(spec § 2.1 / § 3.4): | |
| 54 | + - `queryField ∈ {用户名, 员工名, 用户号, 部门, 用户类型, 作废, 登录日期, 制单人}`,默认 `用户名`。 | |
| 55 | + - `matchType ∈ {包含, 不包含, 等于}`,默认 `包含`。 | |
| 56 | +- 字段 → 列映射(中文 `queryField` → 查询列,spec § 3.4,写死于 Service 映射常量): | |
| 57 | + | queryField | 列 | 类型语义 | | |
| 58 | + |---|---|---| | |
| 59 | + | `用户名` | `usr_user.sUserName` | 文本 | | |
| 60 | + | `员工名` | `usr_employee.sEmployeeName` | 文本(跨表) | | |
| 61 | + | `用户号` | `usr_user.sUserNo` | 文本 | | |
| 62 | + | `部门` | `usr_employee.sDepartment` | 文本(跨表) | | |
| 63 | + | `用户类型` | `usr_user.sUserType` | 枚举 | | |
| 64 | + | `作废` | `usr_user.iIsVoid` | 布尔 0/1 | | |
| 65 | + | `登录日期` | `usr_user.tLastLoginDate` | 日期时间 | | |
| 66 | + | `制单人` | `usr_user.sCreator` | 文本 | | |
| 67 | +- 匹配语义(spec § 3.5): | |
| 68 | + - 文本字段:`包含` → `LIKE '%v%' ESCAPE '\\'`;`不包含` → `NOT LIKE '%v%' ESCAPE '\\'`;`等于` → `= v`。`v` 中 `%` `_` `\` 须转义。 | |
| 69 | + - 枚举字段(用户类型):`等于` → `= v`;`包含` → `LIKE '%v%'`、`不包含` → `NOT LIKE '%v%'`(容错允许,不报错)。 | |
| 70 | + - 布尔字段(作废):`queryValue` 归一化为 0/1(接受 `0`/`1`、`是`/`否`、`true`/`false`,spec § 8 D6),按 `等于`→`= 0/1`、`包含`→`= 0/1`、`不包含`→`<> 0/1`;不可解析 → `40001`。 | |
| 71 | + - 日期字段(登录日期):`queryValue` 解析为 `yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`(spec § 8 D6);`等于`/`包含` → 命中当日整天区间 `[day 00:00:00, day+1 00:00:00)`;`不包含` → 当日区间取反;非法 → `40001`。 | |
| 72 | +- 分页语义(spec § 3.7 / § 8 D1): | |
| 73 | + - 默认 `pageNum=1`、`pageSize=10`;上限 `pageSize=100`。 | |
| 74 | + - **参数非法**(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`,先于查询判定。 | |
| 75 | + - **数据越界**(`pageNum≥1` 合法但超实际总页数)→ 钳制到最后一页返回 `code=0`,`PageResult.pageNum` 回传钳制后实际页号,`total` 为真实总数;`total=0` 时返回空 `records`、`pageNum` 回传 1。 | |
| 76 | +- 空条件语义(spec § 3.2):`queryValue` 为 null / trim 后空串 → 不施加业务过滤,仅按分页返回全量;此时 `queryField` / `matchType` 不生效。 | |
| 77 | +- 跨表关联(spec § 3.6 / § 8 D7):`usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**;未关联职员的用户仍出现在结果,`employeeName` / `department` 为 null;`不包含` 对该列 null 行依标准 SQL 三值逻辑自然不命中(不写 `OR col IS NULL`)。 | |
| 78 | +- 敏感字段(spec § 3.9 / § 9):SQL 不 SELECT `sPassword`;`UserVO` 不含密码、不含租户列 `sId`/`sBrandsId`/`sSubsidiaryId`;输出列严格按下「VO 形状」。 | |
| 79 | + | |
| 80 | +## 关键签名(首次出现处给出,跨 task 保持一致) | |
| 81 | + | |
| 82 | +- `UsrUserService#queryUsers(UserQueryDTO dto)` 返回 `PageResult<UserVO>`。 | |
| 83 | +- `UsrUserController#queryUsers(@Valid UserQueryDTO dto)` 返回 `Result<PageResult<UserVO>>`(query 参数自动绑定到 DTO,无 `@RequestBody`)。 | |
| 84 | +- `UsrUserMapper#selectUserPage(com.baomidou.mybatisplus.core.metadata.IPage<UserVO> page, @Param("cond") UserQueryCondition cond)` 返回 `IPage<UserVO>`(XML 实现;MP 分页插件补 LIMIT/COUNT)。其中 `UserQueryCondition` 为 Service 内部传给 Mapper 的「已解析」条件载体(携带目标列名 token、SQL 片段类型、转义后文本值 / 布尔值 / 日期区间起止),由 Service 把 `UserQueryDTO` 的中文 `queryField`/`matchType`/原始 `queryValue` 解析归一为可直接拼 XML `<if>` 分支的结构——避免在 XML 内做中文枚举判断与类型解析。 | |
| 85 | + - 备选实现(若执行者认为更简单):用 `@Param` 直接传若干已解析标量(如 `column`、`matchOp`、`textValue`、`intValue`、`dateStart`、`dateEnd`、`isText`/`isBool`/`isDate` 标志)替代 `UserQueryCondition` 对象,二者择一、保持 XML `<if>` 分支与 Service 解析结果一致即可。`column` 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。 | |
| 86 | +- 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` 暴露 `getResultCode()`;`UsrUserMapper extends BaseMapper<UsrUser>`;实体 getter 匈牙利前缀(`getSUserName`/`getIIsVoid`/`getTLastLoginDate` 等)。 | |
| 87 | +- 实体 / VO getter-setter 沿用匈牙利前缀风格;`UserVO` 跨表字段用驼峰 `employeeName`/`department`(spec § 2.2 契约键名)。 | |
| 88 | + | |
| 89 | +### DTO 形状(`UserQueryDTO`,置于 `modules/usr/dto`) | |
| 90 | + | |
| 91 | +> query 参数绑定(非 JSON body)。带匈牙利前缀字段的 getter(如 `getSUserName` 类似情况此处不涉及)与查询参数键名需一致;`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize` 为普通小驼峰,query 参数名直接同名绑定,无需 `@JsonProperty`。 | |
| 92 | + | |
| 93 | +| 字段 | 类型 | 校验注解 | 默认 | 语义 | | |
| 94 | +|---|---|---|---|---| | |
| 95 | +| `queryField` | String | `@Pattern(regexp="^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$")`(null 跳过校验,默认值在 Service 兜底)| `用户名` | 查询字段;越界 `40001` | | |
| 96 | +| `matchType` | String | `@Pattern(regexp="^(包含|不包含|等于)$")`(null 跳过)| `包含` | 匹配方式;越界 `40001` | | |
| 97 | +| `queryValue` | String | `@Size(max=100)` | — | 查询值;null / trim 后空 = 不施加条件 | | |
| 98 | +| `pageNum` | Integer | —(范围在 Service 入口显式判定 `42201`,spec § 8 D8)| `1` | 页码,从 1 起 | | |
| 99 | +| `pageSize` | Integer | —(范围在 Service 入口显式判定 `42201`)| `10` | 每页条数,1..100 | | |
| 100 | + | |
| 101 | +> 注:`pageNum`/`pageSize` **不**用 `@Min/@Max`(避免 `@Valid` 失败被全局处理器统一转 `40001`,与 spec 要求的 `42201` 冲突,spec § 8 D8);改在 Service 入口显式判定范围并抛 `BusinessException(PAGE_PARAM_INVALID)`。`@Valid` 失败(`queryField`/`matchType`/`queryValue` 注解)由既有 `GlobalExceptionHandler` 统一转 `40001`。DTO 提供 getter/setter,默认值可在字段初始化或 Service 兜底(择一,保持 Service 对 null 的兜底逻辑一致)。 | |
| 102 | + | |
| 103 | +### VO 形状(`UserVO`,置于 `modules/usr/vo`,严格按 spec § 2.2,**不含 `sPassword` / 租户列**) | |
| 104 | + | |
| 105 | +| VO 字段 | 类型 | 来源列 | | |
| 106 | +|---|---|---| | |
| 107 | +| `id` | Integer | `usr_user.iIncrement` | | |
| 108 | +| `sUserName` | String | `usr_user.sUserName` | | |
| 109 | +| `employeeName` | String | `usr_employee.sEmployeeName`(LEFT JOIN,可 null) | | |
| 110 | +| `sUserNo` | String | `usr_user.sUserNo` | | |
| 111 | +| `department` | String | `usr_employee.sDepartment`(LEFT JOIN,可 null) | | |
| 112 | +| `sUserType` | String | `usr_user.sUserType` | | |
| 113 | +| `sLanguage` | String | `usr_user.sLanguage` | | |
| 114 | +| `iIsVoid` | Integer | `usr_user.iIsVoid` | | |
| 115 | +| `tLastLoginDate` | LocalDateTime | `usr_user.tLastLoginDate`(可 null) | | |
| 116 | +| `sCreator` | String | `usr_user.sCreator` | | |
| 117 | +| `tCreateDate` | LocalDateTime | `usr_user.tCreateDate` | | |
| 118 | + | |
| 119 | +> `UserVO` 提供全部字段 getter/setter;带匈牙利前缀字段(`sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iIsVoid`/`sCreator`)的 getter 形如 `getSUserName` 会被 Jackson 推断为 `SUserName`,与契约键名不符——对这些字段加 `@JsonProperty`(与 `CreateUserDTO`/`UpdateUserDTO` 同做法)锁定 JSON 键为 `sUserName` 等小驼峰;`employeeName`/`department`/`id`/`tLastLoginDate`/`tCreateDate` 普通驼峰无需 `@JsonProperty`。XML `<resultMap>` 把列映射到 VO 字段名。 | |
| 120 | + | |
| 121 | +### `PageResult<T>` 形状(`common/response/PageResult.java`,docs/04 § 1.4 / § 3.2) | |
| 122 | + | |
| 123 | +| 字段 | 类型 | 语义 | | |
| 124 | +|---|---|---| | |
| 125 | +| `records` | `List<T>` | 当前页数据 | | |
| 126 | +| `total` | `long` | 真实总记录数 | | |
| 127 | +| `pageNum` | `long` | 当前页号(数据越界钳制后的实际页号)| | |
| 128 | +| `pageSize` | `long` | 每页条数 | | |
| 129 | + | |
| 130 | +> 提供全字段 getter/setter + 全参构造器(或静态工厂 `of(records, total, pageNum, pageSize)`),便于 Service 从 MP `IPage` 装配。`implements Serializable`(与 `Result` 一致)。 | |
| 131 | + | |
| 132 | +--- | |
| 133 | + | |
| 134 | +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) | |
| 135 | + | |
| 136 | +> 业务类 commit subject 必须带 `REQ-USR-003` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 | |
| 137 | + | |
| 138 | +### T1 — `PageResult<T>` 公共分页响应体 | |
| 139 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/PageResultTest.java`: | |
| 140 | + - `::ofAssemblesAllFields` —— 用 `records=[..]`、`total=23`、`pageNum=2`、`pageSize=10` 构造(构造器或 `of`),断言四字段读回一致。 | |
| 141 | + - `::emptyRecordsAllowed` —— `records=[]`、`total=0`、`pageNum=1` 构造,断言 `records` 非 null 且为空、`total==0`。 | |
| 142 | +- [ ] **实现**:`common/response/PageResult.java`,按上「`PageResult<T>` 形状」加 `records`/`total`/`pageNum`/`pageSize` 字段 + getter/setter + 全参构造器(或 `of` 静态工厂)+ `implements Serializable`。 | |
| 143 | +- [ ] **验证**:子会话跑 `PageResultTest` PASS。 | |
| 144 | +- [ ] **commit**:`feat(usr): 通用分页响应体 PageResult REQ-USR-003` | |
| 145 | + | |
| 146 | +### T2 — `UserQueryDTO` + `UserVO`(入参校验 + 输出 JSON 键) | |
| 147 | +- [ ] **测试**: | |
| 148 | + - `backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java`(用 `jakarta.validation.Validator` validate): | |
| 149 | + - `::acceptsAllNullAsValid` —— 全 null(含 `queryField`/`matchType`/`queryValue`)无违反(全部可选)。 | |
| 150 | + - `::acceptsLegalEnums` —— `queryField=登录日期`、`matchType=不包含` 无违反。 | |
| 151 | + - `::rejectsIllegalQueryField` —— `queryField=身份证` 违反 `@Pattern`。 | |
| 152 | + - `::rejectsIllegalMatchType` —— `matchType=大于` 违反 `@Pattern`。 | |
| 153 | + - `::rejectsTooLongQueryValue` —— `queryValue` 长度 101 违反 `@Size(max=100)`。 | |
| 154 | + - `backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java`(用 `ObjectMapper` 序列化): | |
| 155 | + - `::serializesContractKeysNoPassword` —— 构造一个填满字段的 `UserVO`,序列化后 JSON 含键 `id`/`sUserName`/`employeeName`/`sUserNo`/`department`/`sUserType`/`sLanguage`/`iIsVoid`/`tLastLoginDate`/`sCreator`/`tCreateDate`,且**不含** `sPassword`/`password`/`SUserName`(验证 `@JsonProperty` 锁键生效、无密码字段)。 | |
| 156 | +- [ ] **实现**:`modules/usr/dto/UserQueryDTO.java`(按「DTO 形状」字段 + `@Pattern`/`@Size` + getter/setter,`pageNum`/`pageSize` 不加 `@Min/@Max`);`modules/usr/vo/UserVO.java`(按「VO 形状」11 字段 + `@JsonProperty` 锁匈牙利前缀字段键名 + getter/setter,不含 `sPassword`/租户列)。 | |
| 157 | +- [ ] **验证**:子会话跑两测试 PASS。 | |
| 158 | +- [ ] **commit**:`feat(usr): 查询用户入参 UserQueryDTO 与输出 UserVO REQ-USR-003` | |
| 159 | + | |
| 160 | +### T3 — Mapper:`selectUserPage` 自定义查询(LEFT JOIN + 动态条件 XML) | |
| 161 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(测试回滚,避免污染库)连测试库;`@Autowired UsrUserMapper`;测试内插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 `usr_employee`): | |
| 162 | + - `::pageReturnsLeftJoinedEmployeeColumns` —— 无过滤条件、`Page<UserVO>(1,10)` 调 `selectUserPage` → 返回 `IPage<UserVO>`,关联职员的用户行 `employeeName`/`department` 非 null 且等于 fixture 职员值;未关联职员的用户行两列为 null;`total≥2`。 | |
| 163 | + - `::pageAppliesTextLikeOnUserName` —— 条件为「`sUserName` 文本 `包含` fixture 用户名片段」→ 仅命中该用户、`total` 正确。 | |
| 164 | + - `::pageNeverSelectsPassword` —— 任意结果 `UserVO` 无密码字段(VO 无该属性即天然满足;额外断言 `selectUserPage` 不抛错且 records 元素为 `UserVO` 类型)。 | |
| 165 | + - (命名前缀如 `t3_user_` / `T3_EMP_` 便于 `@Transactional` 回滚兜底外再清理;若用 `@Transactional` 回滚则无需 `@AfterEach`。) | |
| 166 | +- [ ] **实现**: | |
| 167 | + - `modules/usr/mapper/UsrUserMapper.java` 新增方法签名 `IPage<UserVO> selectUserPage(IPage<UserVO> page, @Param("cond") UserQueryCondition cond)`(或备选标量 `@Param` 版,见「关键签名」)。 | |
| 168 | + - 新增 `resources/mapper/usr/UsrUserMapper.xml`:`namespace=com.xly.erp.modules.usr.mapper.UsrUserMapper`;`<resultMap id="userVOMap" type="com.xly.erp.modules.usr.vo.UserVO">` 把 `u.iIncrement→id`、`u.sUserName→sUserName`、`e.sEmployeeName→employeeName`、`u.sUserNo→sUserNo`、`e.sDepartment→department`、`u.sUserType→sUserType`、`u.sLanguage→sLanguage`、`u.iIsVoid→iIsVoid`、`u.tLastLoginDate→tLastLoginDate`、`u.sCreator→sCreator`、`u.tCreateDate→tCreateDate` 映射;`<select id="selectUserPage" resultMap="userVOMap">` 显式列出上述列别名(**不写 `SELECT *`、不 SELECT `u.sPassword`**),`FROM usr_user u LEFT JOIN usr_employee e ON u.iEmployeeId = e.iIncrement`,`<where>` 内按解析后的条件用 `<if>` 拼单分支(文本 `LIKE/NOT LIKE/=` 带 `ESCAPE '\\'`、枚举 `=`/`LIKE`、布尔 `=`/`<>`、日期区间 `>=`/`<`),全部 `#{}` 占位。 | |
| 169 | + - (`UserQueryCondition` 若采用对象版,置于 `modules/usr/dto` 或 `modules/usr/service` 内部包;属内部解析载体,非对外契约。) | |
| 170 | +- [ ] **验证**:子会话跑 `UsrUserMapperPageTest` PASS(连库,确认 XML 被 MP 默认扫描加载、resultMap 映射正确、分页插件生效)。**若 XML 未被加载**(报 `selectUserPage` not found)→ 在 `application.yml` 补 `mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml` 一行后重跑,并把该决策记入返回 decisions。 | |
| 171 | +- [ ] **commit**:`feat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003` | |
| 172 | + | |
| 173 | +### T4 — Service:参数判定 + 条件解析 + 分页装配(核心读) | |
| 174 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(续既有类,Mockito mock `UsrUserMapper` 等;`selectUserPage` 用 `thenReturn` 桩 `Page<UserVO>`): | |
| 175 | + - `::pageParamTooSmallThrows42201` —— `pageNum=0` → 抛 `BusinessException(PAGE_PARAM_INVALID)`,不调 `selectUserPage`;`pageSize=0` 同理。 | |
| 176 | + - `::pageSizeOverMaxThrows42201` —— `pageSize=500` → `BusinessException(PAGE_PARAM_INVALID)`,不调 Mapper。 | |
| 177 | + - `::blankQueryValueAppliesNoFilter` —— `queryValue=null`(或空串)→ 调 `selectUserPage` 时传入的 cond 表示「无过滤条件」(用 `ArgumentCaptor` 断言 cond 的过滤标志为空 / 文本值为 null)。 | |
| 178 | + - `::boolFieldUnparsableThrows40001` —— `queryField=作废&queryValue=abc` → `BusinessException(PARAM_INVALID)`,不调 Mapper;`queryValue=1` 不抛、cond 布尔值为 1;`queryValue=是` 归一化为 1。 | |
| 179 | + - `::dateFieldIllegalThrows40001` —— `queryField=登录日期&queryValue=2026-13-99` → `BusinessException(PARAM_INVALID)`;`queryValue=2026-06-01` 解析出区间 `[2026-06-01T00:00, 2026-06-02T00:00)`(断言 cond 的 dateStart/dateEnd)。 | |
| 180 | + - `::textLikeEscapesWildcards` —— `queryField=用户名&matchType=包含&queryValue=a%_b` → cond 的文本值对 `%`/`_` 转义(断言转义后含 `\%`/`\_`,且匹配方式为 LIKE 包含)。 | |
| 181 | + - `::dataPageOutOfRangeClampsToLastPage` —— mock `selectUserPage` 返回 `total=23`、`size=10`、请求 `pageNum=99`:Service 装配的 `PageResult.pageNum` 钳制为 3(`ceil(23/10)`),`total=23`、`records` 为桩返回的最后一页 records。(实现可借 MP `Page` 的 `current`/`pages` 或自行 `Math.ceil` 钳制并以钳后页号回查/回传,见实现说明。) | |
| 182 | + - `::emptyResultReturnsZeroTotal` —— mock 返回空 → `PageResult.records=[]`、`total=0`、`pageNum=1`(不抛错)。 | |
| 183 | + - `::defaultsApplied` —— DTO 全 null → cond 用默认 `queryField=用户名`、`matchType=包含`,分页默认 `pageNum=1`/`pageSize=10`(断言传给 Mapper 的 `IPage` current=1/size=10)。 | |
| 184 | +- [ ] **实现**:`UsrUserService.java` 新增 `PageResult<UserVO> queryUsers(UserQueryDTO dto)`;`UsrUserServiceImpl.java` 新增实现并标 `@Transactional(readOnly = true)`: | |
| 185 | + 1. **分页参数判定**(先于一切):`pageNum` 兜底默认 1、`pageSize` 兜底默认 10;若 `pageNum<1` 或 `pageSize<1` 或 `pageSize>100` → 抛 `BusinessException(PAGE_PARAM_INVALID)`。 | |
| 186 | + 2. **条件解析**:`queryField` 兜底 `用户名`、`matchType` 兜底 `包含`;`queryValue` trim 后为空 → cond「无过滤」。非空时按字段类型解析:文本→转义 `%`/`_`/`\` + 选 `LIKE/NOT LIKE/=`;枚举→`=`/`LIKE`;布尔→归一化 `0/1`(不可解析抛 `40001`)+ `=`/`<>`;日期→解析当日区间起止(不可解析抛 `40001`)。把白名单列 token + 解析结果填入 `UserQueryCondition`(或标量 `@Param`)。 | |
| 187 | + 3. **分页查询**:构造 MP `Page<UserVO>(pageNum, pageSize)` 调 `usrUserMapper.selectUserPage(page, cond)`。 | |
| 188 | + 4. **越界钳制**:取真实 `total`,算总页数 `pages = total==0?1:ceil(total/pageSize)`;若 `pageNum>pages` → 用钳后页号回查一次(或对已查 `IPage` 重算回传页号——执行者择稳妥实现,保证 `records` 为最后一页真实数据);装配 `PageResult`(`records`/`total`/钳后 `pageNum`/`pageSize`)返回。`total=0` 时 `pageNum` 回传 1。 | |
| 189 | +- [ ] **验证**:子会话跑上述用例 PASS。 | |
| 190 | +- [ ] **commit**:`feat(usr): 查询用户 Service 参数判定与条件解析分页装配 REQ-USR-003` | |
| 191 | + | |
| 192 | +### T5 — Controller:`GET /api/usr/users` 端点(无管理员前置) | |
| 193 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(续既有类,MockMvc standaloneSetup + 真实 `GlobalExceptionHandler` + mock `UsrUserService`): | |
| 194 | + - `::queryReturnsCodeZeroWithPageResult` —— `GET /api/usr/users?pageNum=1&pageSize=10`,`usrUserService.queryUsers` 桩返回含 1 条 `UserVO` 的 `PageResult` → HTTP 200,`code==0`,`data.total` / `data.pageNum` / `data.pageSize` 正确,`data.records[0].sUserName` 存在;响应体不含 `sPassword`/`password`。 | |
| 195 | + - `::queryAllowsNonAdmin` —— 普通用户调用(无管理员前置,spec § 8 D5)→ `code==0`,`usrUserService.queryUsers` 被调用(区别于 createUser/updateUser 的 `40301`)。 | |
| 196 | + - `::queryIllegalEnumReturns40001` —— `queryField=身份证`(`@Pattern` 失败)→ `code==40001`,Service 不被调用。 | |
| 197 | + - `::queryPageParamInvalidReturns42201` —— Service 桩抛 `BusinessException(PAGE_PARAM_INVALID)`(模拟 `pageNum=0`)→ `code==42201`(验证 Controller 不吞业务异常、全局处理器转码)。 | |
| 198 | +- [ ] **实现**:`UsrUserController.java` 新增 `@GetMapping("/users")` 方法 `queryUsers(@Valid UserQueryDTO dto)`:**不做管理员前置**(spec § 8 D5,与 `createUser`/`updateUser` 区分);直接委派 `usrUserService.queryUsers(dto)`;返回 `Result.success(pageResult)`。Controller 不直接调 Mapper、不写业务逻辑。 | |
| 199 | +- [ ] **验证**:子会话跑 `UsrUserControllerTest`(既有 + 新增用例)PASS。 | |
| 200 | +- [ ] **commit**:`feat(usr): 查询用户 Controller GET /api/usr/users REQ-USR-003` | |
| 201 | + | |
| 202 | +### T6 — 端到端验收回归(按 spec § 7 验收标准收口) | |
| 203 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;真实 `JwtUtil` 签发 token 走安全链;`@AfterEach` 按前缀清理本测试 fixture,命名前缀如 `it3_user_` / `IT3_EMP_`;管理员 / 普通用户 token 仿 `UsrUserCreateIT` 生成)覆盖 spec § 7: | |
| 204 | + - `::ac1EmptyConditionFullPage` —— 预置数条用户 fixture,`GET /api/usr/users?pageNum=1&pageSize=10` → `code=0`,`records.size≤10`,`total≥fixture 数`,`pageNum=1`、`pageSize=10`;每条 VO 含 § 2.2 全列、**响应体不含 `sPassword`/`password`**。 | |
| 205 | + - `::ac2TextContains` —— `queryField=用户名&matchType=包含&queryValue=<fixture 片段>` → 仅返回用户名含该片段的用户。 | |
| 206 | + - `::ac3TextEquals` —— `matchType=等于&queryValue=<完整 fixture 用户名>` → 仅返回严格等于该名的用户(含 1 条;近似名不命中)。 | |
| 207 | + - `::ac4TextNotContains` —— `queryField=制单人&matchType=不包含&queryValue=<某 creator>` → 返回 `sCreator` 不含该值的用户。 | |
| 208 | + - `::ac5EnumEquals` —— `queryField=用户类型&matchType=等于&queryValue=超级管理员` → 仅返回 `sUserType=超级管理员` 的用户。 | |
| 209 | + - `::ac6BoolFilter` —— `queryField=作废&queryValue=1` 仅返回 `iIsVoid=1`;`queryValue=0` 仅 `iIsVoid=0`;`queryValue=abc` → `code=40001`。 | |
| 210 | + - `::ac7DateFilter` —— 预置一条 `tLastLoginDate=2026-06-01 12:00:00` 的用户,`queryField=登录日期&matchType=等于&queryValue=2026-06-01` → 命中该用户;`queryValue=2026-13-99` → `code=40001`。 | |
| 211 | + - `::ac8CrossTableEmployeeDept` —— 预置 1 关联职员(部门含「财务」)的用户 + 1 未关联职员的用户:返回 VO 中关联用户 `employeeName`/`department` 来自其职员、未关联用户两列为 null(均在结果中);`queryField=部门&matchType=包含&queryValue=财务` 仅返回所属部门含「财务」的用户。 | |
| 212 | + - `::ac9NoMatchEmptyList` —— 条件无命中(如 `queryValue=<极不可能的串>`)→ `code=0`、`records=[]`、`total=0`(不报错)。 | |
| 213 | + - `::ac10DataOutOfRangeLastPage` —— 控制 fixture 使总数落在已知页数(如插 fixture 后用 `queryField` 限定到恰 3 页内),`pageNum=99&pageSize=10` → `code=0`、`records` 为最后一页数据、`PageResult.pageNum` 回传实际最后页号、`total` 为真实总数。(fixture 数难精确时,可先 `total` 查询计算期望末页号再断言。) | |
| 214 | + - `::ac11PageParamInvalid` —— `pageNum=0` / `pageSize=0` / `pageSize=500` 各 → `code=42201`。 | |
| 215 | + - `::ac12NoToken` —— 无 token → HTTP 401,不返回任何用户数据;失效 token 同样 401。 | |
| 216 | + - `::ac13PasswordNeverLeaks` —— 任意上述成功响应体均不含 `sPassword` 字段、不含明文密码 / `password` 字段。 | |
| 217 | +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如 XML resultMap 列名 / 日期区间边界 / 越界钳制页号回传与 IT 期望不符时调整),不引入新公共契约、不新增 migration。 | |
| 218 | +- [ ] **验证**:子会话跑 `UsrUserQueryIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。 | |
| 219 | +- [ ] **commit**:`test(usr): 查询用户端到端验收回归 REQ-USR-003` | |
| 220 | + | |
| 221 | +--- | |
| 222 | + | |
| 223 | +## 自审 | |
| 224 | + | |
| 225 | +### 占位符扫描 | |
| 226 | +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本只读查询 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员})继续,不阻塞。 | |
| 227 | + | |
| 228 | +### Spec coverage(spec 每节 → task 映射) | |
| 229 | +- § 1 Goal(唯一只读端点 `GET /api/usr/users`、跨表员工名/部门、不返回密码)→ T5(端点)+ T3(LEFT JOIN)+ T4(装配)+ 全部 task。 | |
| 230 | +- § 2.1 输入 / `UserQueryDTO` 字段与校验 / Auth(无管理员限制)→ T2(DTO 校验)+ T4(默认值兜底 + 范围判定)+ T5(端点 + 无管理员前置)。 | |
| 231 | +- § 2.2 输出 `Result<PageResult<UserVO>>` / `UserVO` 列 / 不含 `sPassword` → T1(`PageResult`)+ T2(`UserVO` + JSON 键 + 无密码)+ T3(resultMap 不取密码)+ T5(Controller 组装)+ T6 AC1/AC13。 | |
| 232 | +- § 3.1 只读无副作用 → T4(`@Transactional(readOnly=true)`)+ T3(仅 SELECT)+ T6(无写入断言隐含)。 | |
| 233 | +- § 3.2 空条件返回全量分页 → T4(空值不施加条件)+ T6 AC1。 | |
| 234 | +- § 3.3 单条件查询 → T4(解析单字段单匹配)+ T3(XML 单分支)。 | |
| 235 | +- § 3.4 字段→列映射 → 合同级常量映射表 + T3(XML 列)+ T4(白名单列 token)。 | |
| 236 | +- § 3.5 匹配方式语义(文本 LIKE/精确、枚举、布尔、日期)→ T4(解析)+ T3(XML 分支 + ESCAPE)+ T6 AC2/3/4/5/6/7。 | |
| 237 | +- § 3.6 跨表 LEFT JOIN(员工名/部门,未关联为 null)→ T3(XML JOIN + resultMap)+ T6 AC8。 | |
| 238 | +- § 3.7 分页规则(`42201` 参数非法 / 数据越界返回末页 / 默认值)→ T4(范围判定 + 钳制)+ T6 AC10/AC11。 | |
| 239 | +- § 3.8 空结果返回空列表不报错 → T4(空装配)+ T6 AC9。 | |
| 240 | +- § 3.9 密码与敏感字段不返回 → T2(VO 无密码/租户列)+ T3(SQL 不取密码)+ T6 AC13。 | |
| 241 | +- § 4 约束(分层 / 包路径 / 命名 `queryUsers` / 统一响应 / 异常 / 分页实现 XML / 数据访问 / 安全 / 配置 / schema 不改)→ T1-T5 分层落位,分页 XML 在 T3,参数化/转义在 T3/T4,schema 复用 V1(Tech Stack)。 | |
| 242 | +- § 5 Schema 引用(读 `usr_user` / `usr_employee`,LEFT JOIN,不 SELECT 密码)→ T3(XML)。 | |
| 243 | +- § 6 错误码(`0`/`40001`/`42201`/401)→ 复用既有 `ResultCode`,T4(`42201`/`40001`)/T5(全局处理器转码)/T6(401 安全链)。 | |
| 244 | +- § 7 验收标准 1-13 → T6 AC1-AC13 逐条覆盖。 | |
| 245 | +- § 8 decisions(D1-D9)→ D1(参数非法 vs 数据越界)T4/合同级常量、D2(单条件)T4/T3、D3(LIKE 转义 ESCAPE)T4/T3、D4(序号→`id`,前端渲染)T2(VO 含 `id` 不含序号)、D5(不限管理员)T5、D6(布尔/日期解析口径)T4、D7(`不包含` 对 null 行 SQL 三值逻辑)T3、D8(`42201` Service 入口显式判定)T4、D9(不新增 migration)Tech Stack 已体现。 | |
| 246 | + | |
| 247 | +### 类型一致性 | |
| 248 | +- `UsrUserService#queryUsers(UserQueryDTO):PageResult<UserVO>` 在 T4 定义,T5(Controller 调用)/T6(IT)一致引用。 | |
| 249 | +- `UsrUserController#queryUsers(@Valid UserQueryDTO)` 返回 `Result<PageResult<UserVO>>`,与 docs/05 契约一致。 | |
| 250 | +- `UsrUserMapper#selectUserPage(IPage<UserVO>, @Param("cond") UserQueryCondition)`(或备选标量 `@Param` 版)在 T3 定义,T4 调用一致;`column` 仅来自固定白名单映射,防注入。 | |
| 251 | +- `PageResult<T>`(`records`/`total`/`pageNum`/`pageSize`)在 T1 锁定,T4/T5/T6 一致使用;与 docs/04 § 1.4 / § 3.2 字段名一致。 | |
| 252 | +- `UserQueryDTO` 字段(`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize`)+ 校验注解在 T2 锁定,T4/T5/T6 一致使用。 | |
| 253 | +- `UserVO` 11 字段 + `@JsonProperty` 锁键在 T2 锁定,T3(resultMap 映射)/T5/T6 一致;严格不含 `sPassword`/租户列。 | |
| 254 | +- 错误码字面量 `0`/`40001`/`42201` 复用既有 `ResultCode`(`SUCCESS`/`PARAM_INVALID`/`PAGE_PARAM_INVALID`),与 docs/05、spec § 6 一致,不新增枚举常量。 | |
| 255 | +- REST 路径 `GET /api/usr/users`、枚举取值 `{包含,不包含,等于}` / `{用户名,...,制单人}` 与 docs/05、spec、合同级常量一致。 | |
| 256 | +- Mapper 既有继承 `BaseMapper<UsrUser>` + 新增一个自定义 XML 方法;实体 / VO getter 沿用匈牙利前缀风格 + `@JsonProperty` 锁键(与 `CreateUserDTO`/`UpdateUserDTO` 同风格)。 | ... | ... |
docs/superpowers/plans/2026-06-01-REQ-USR-004.md
0 → 100644
| 1 | +# REQ-USR-004 登录用户 — 任务级 TDD 计划(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / DTO / VO / entity / config / 公共响应 / 安全 / REST 契约实现)。**禁止**写 `frontend/**`。 | |
| 4 | +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-004.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-004.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 | |
| 5 | +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 | |
| 6 | +> 本 REQ 复用 REQ-USR-001/002/003 已建的 `modules/usr/**`(`UsrUser` 实体、`UsrUserMapper extends BaseMapper<UsrUser>`、`UsrUserController`)与 `common/**`(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `SecurityUtil` / `JwtUtil` / `JwtAuthenticationFilter` / `SecurityConfig` / `PasswordEncoder` Bean);**只读** `usr_user` / `usr_company` + **UPDATE** `usr_user.tLastLoginDate`(DML 非 DDL),**不新增 migration**(spec § 4 / § 8 D11,二表已在 `V1__initial_schema.sql` 建好且结构与 docs/03 一致)。 | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## Goal(目标) | |
| 11 | + | |
| 12 | +实现登录认证主端点 `POST /api/usr/login`(放行,无需 token):接收 `LoginDTO`(`sUserName` / `password` / `companyId`,三者必填),按「基础参数校验 → 查用户 → BCrypt 比对密码 → 判禁用 → companyId 存在性校验 → 签发 JWT + 更新 `tLastLoginDate`」流程认证,成功返回 `Result<LoginVO>`(`code=0`,含 `token` 与 `user{ id, sUserName, sUserType, sLanguage }`,**绝不含 `sPassword`**)。配套补齐只读端点 `GET /api/usr/companies`(放行,无参,返回 `Result<List<CompanyOptionVO>>` 供登录页「版本」下拉)。 | |
| 13 | + | |
| 14 | +防账号枚举:用户不存在与密码错误返回**完全相同**的 `40101`(统一文案);禁用用户 → `40302`;参数缺失或 `companyId` 在 `usr_company` 不存在 → `40001`;同一账号连续失败超阈值在冷却窗内 → `42901`(登录过于频繁)。登录成功在同一事务内更新 `tLastLoginDate` 并清零失败计数;JWT 含过期时间(exp claim),令牌可被既有 `JwtAuthenticationFilter` 接受。 | |
| 15 | + | |
| 16 | +## Architecture(架构 / 分层) | |
| 17 | + | |
| 18 | +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与 `common/**`(错误码枚举 + 安全放行配置),不跨业务模块: | |
| 19 | + | |
| 20 | +``` | |
| 21 | +backend/src/main/java/com/xly/erp/ | |
| 22 | +├── common/response/ResultCode.java # 既有枚举,新增 42901 限流码(40101/40302/40001 已存在) | |
| 23 | +├── common/config/SecurityConfig.java # 既有,放行清单追加 GET /api/usr/companies(POST /api/usr/login 已放行) | |
| 24 | +├── modules/usr/ | |
| 25 | +│ ├── controller/UsrAuthController.java # 【本 REQ 新增】认证 Controller:POST /api/usr/login + GET /api/usr/companies;仅 @Valid + 委派(spec § 8 D8) | |
| 26 | +│ ├── service/UsrAuthService.java # 【本 REQ 新增】认证服务接口:login / listCompanies | |
| 27 | +│ ├── service/impl/UsrAuthServiceImpl.java # 【本 REQ 新增】认证实现(查用户→BCrypt→判禁用→companyId 校验→签发 JWT→更新登录时间;限流计数) | |
| 28 | +│ ├── mapper/UsrCompanyMapper.java # 【本 REQ 新增】UsrCompany Mapper(继承 BaseMapper,spec § 8 D9) | |
| 29 | +│ ├── mapper/UsrUserMapper.java # 既有,复用 BaseMapper 的 selectOne/updateById(无需改) | |
| 30 | +│ ├── entity/UsrCompany.java # 【本 REQ 新增】usr_company 实体(spec § 8 D9) | |
| 31 | +│ ├── entity/UsrUser.java # 既有,只读 + UPDATE tLastLoginDate(无需改) | |
| 32 | +│ ├── dto/LoginDTO.java # 【本 REQ 新增】登录入参 | |
| 33 | +│ └── vo/{LoginVO,CompanyOptionVO}.java # 【本 REQ 新增】登录输出 + 公司下拉项输出(不含 sPassword) | |
| 34 | +``` | |
| 35 | + | |
| 36 | +- **跨模块**:无。本 REQ 落在 `modules/usr/**` + 改 `common/response/ResultCode.java`(新增一枚错误码常量)+ 改 `common/config/SecurityConfig.java`(放行清单加一条)。后两者属公共区改动,需在《模块完成报告》留痕「新增 `42901` 限流错误码 + 放行 `GET /api/usr/companies`」(CLAUDE.md 跨模块改动留痕要求)。 | |
| 37 | +- **数据访问**:只走 Mapper(MyBatis-Plus)。按 `sUserName` 精确查用户用 `UsrUserMapper.selectOne(LambdaQueryWrapper.eq(UsrUser::getSUserName, ...))`;更新登录时间用 `UsrUserMapper.updateById`(仅 set `iIncrement` + `tLastLoginDate`,依赖 MP「null 字段不参与 SET」语义,不触碰其他列);按 `companyId` 校验存在用 `UsrCompanyMapper.selectById`;公司列表用 `UsrCompanyMapper.selectList(null)`。全部参数化(`#{}` / MP 预编译)防注入。**不 SELECT `sPassword` 到响应**(实体查出含 `sPassword` 供比对,但 `UsrUser.sPassword` 已标 `@JsonIgnore` 且 `LoginVO` 不含该字段,绝不外泄)。简单 CRUD,无需自定义 XML。 | |
| 38 | +- **事务**:`login` 含写(UPDATE `tLastLoginDate`),`UsrAuthServiceImpl#login` 标 `@Transactional(rollbackFor = Exception.class)`(spec § 4 / § 8 D6,更新失败整体回滚、登录视为失败);`listCompanies` 标 `@Transactional(readOnly = true)`。 | |
| 39 | +- **安全**:`SecurityConfig` 放行 `POST /api/usr/login`(已放行)+ `GET /api/usr/companies`(本 REQ 追加),登录前可访问;其余受保护接口仍走既有 `JwtAuthenticationFilter`。密码用既有 `BCryptPasswordEncoder` Bean 比对,禁止明文。JWT 用既有 `JwtUtil.generateToken(userName, userType)` 签发(含 exp claim),密钥来自 `application.yml` `jwt.secret`(引 config-vars `secrets.jwt_secret`),不硬编码。 | |
| 40 | +- **限流**:进程内(内存)按 `sUserName` 计数的轻量限流(spec § 3 规则 8 / § 8 D7),不依赖 Redis;阈值 / 窗口读 `application.yml` `auth.login.max-fail`(默认 5)/ `auth.login.lock-seconds`(默认 300)。计数器封装在 `UsrAuthServiceImpl` 内部(`ConcurrentHashMap` 持有 `用户名→{连续失败次数, 锁定到期时间戳}`),成功登录清零;线程安全用并发容器即可(单实例 MVP)。 | |
| 41 | + | |
| 42 | +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) | |
| 43 | + | |
| 44 | +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(BaseMapper + `LambdaQueryWrapper`,简单 CRUD 无需 XML);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / § 8 D11)。 | |
| 45 | +- Spring Security + JWT(既有 `JwtUtil` / `JwtAuthenticationFilter` / `SecurityUtil` / `SecurityConfig` / `PasswordEncoder` Bean);`POST /api/usr/login` 与 `GET /api/usr/companies` 放行。 | |
| 46 | +- 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥 / 限流阈值只读 `config-vars.yaml` / `application.yml`,不硬编码。 | |
| 47 | +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 | |
| 48 | + | |
| 49 | +## 合同级常量(跨 task 必须一致) | |
| 50 | + | |
| 51 | +- REST:`POST /api/usr/login`(JSON body 绑定 `LoginDTO`,`@RequestBody`);`GET /api/usr/companies`(无参)。二者均放行(无需 token)。 | |
| 52 | +- 错误码(`ResultCode` 枚举,spec § 6 / docs/05;`SUCCESS=0` / `PARAM_INVALID=40001` / `UNAUTHORIZED=40101` / `ACCOUNT_DISABLED=40302` 既有已存在,**本 REQ 仅新增 `LOGIN_RATE_LIMITED=42901`**): | |
| 53 | + - `SUCCESS=0` — 成功。`login` → `LoginVO`;`companies` → `List<CompanyOptionVO>`。 | |
| 54 | + - `PARAM_INVALID=40001` — 参数校验失败(缺 `sUserName` / `password` / `companyId`,或 `companyId` 在 `usr_company` 不存在)。 | |
| 55 | + - `UNAUTHORIZED=40101` — 认证失败(用户名或密码错误;**不区分**以防账号枚举,统一文案)。 | |
| 56 | + - `ACCOUNT_DISABLED=40302` — 账号已禁用(`iIsVoid=1`,禁止登录)。 | |
| 57 | + - `LOGIN_RATE_LIMITED=42901` — 登录过于频繁(连续失败超阈值,账号临时锁定,spec § 3 规则 8 / § 8 D7)。**新增常量**,message 建议「请求过于频繁,请稍后重试」。 | |
| 58 | + - 401 — 受保护接口无 / 失效 token(由安全过滤器返回,非本端点产生;本登录端点放行)。 | |
| 59 | +- 统一失败文案(spec § 3 规则 2/3):`40101` 走单一 message「用户名或密码错误」——用户不存在与密码错误**用同一码同一文案**,不暴露「账号不存在」/「密码错误」字样。可直接用 `BusinessException(ResultCode.UNAUTHORIZED)`(默认 message 为枚举的「认证失败」),或显式传统一文案;**两条失败路径必须传同一 message**,由 T4 测试固化「两路径响应体逐字一致」。 | |
| 60 | +- 认证判定顺序(spec § 3 规则 1 / § 8 D3,**固定**):①基础参数校验(`@Valid`,缺失 → `40001`)→ ②按 `sUserName` 查 `usr_user`,未查到 → `40101` → ③**先 BCrypt 比对密码**,不匹配 → `40101` → ④再判 `iIsVoid=1` → `40302`(先验密码再返禁用码,最小化枚举面,spec § 8 D3)→ ⑤`companyId` 存在性校验(不存在 → `40001`)→ ⑥签发 JWT + 更新 `tLastLoginDate` + 清零失败计数 + 返回 `LoginVO`。限流判定(`42901`)置于流程**最前**(查用户前即查锁定窗,命中锁定窗直接 `42901`,spec 验收 9:锁定窗内即使密码正确也 `42901`)。 | |
| 61 | +- JWT:复用既有 `JwtUtil.generateToken(String userName, String userType)`,subject = `sUserName`、claim 含 `sUserName`(`JwtUtil.CLAIM_USER_NAME`)+ `sUserType`(`JwtUtil.CLAIM_USER_TYPE`)+ exp(既有实现已带 `expiration`);返回的 `token` **不**加 `Bearer ` 前缀(前端按契约自行拼 `Authorization: Bearer <token>`,与 `UsrUserCreateIT` 中 `"Bearer " + jwtUtil.generateToken(...)` 的用法一致)。`companyId` 不写入 JWT 认证主体(spec § 3 规则 6)。 | |
| 62 | +- 限流配置键(`application.yml`,spec § 4 / § 8 D7):`auth.login.max-fail`(默认 5,达到该连续失败次数后锁定)、`auth.login.lock-seconds`(默认 300,锁定窗秒数)。`UsrAuthServiceImpl` 用 `@Value("${auth.login.max-fail:5}")` / `@Value("${auth.login.lock-seconds:300}")` 注入。 | |
| 63 | +- 软删除口径(spec § 3 规则 5 / § 8 D4):本库无独立「已删除」列,「已禁用」与「已删除」统一按 `iIsVoid=1` 处理,均 `40302`。 | |
| 64 | +- 敏感字段(spec § 3 规则 9 / 验收 11):`LoginVO` / `CompanyOptionVO` 均不含 `sPassword`;不 `SELECT *` 透传实体到响应;密码明文不进任何日志 / 异常 message。 | |
| 65 | + | |
| 66 | +## 关键签名(首次出现处给出,跨 task 保持一致) | |
| 67 | + | |
| 68 | +- `UsrAuthService#login(LoginDTO dto)` 返回 `LoginVO`。 | |
| 69 | +- `UsrAuthService#listCompanies()` 返回 `List<CompanyOptionVO>`。 | |
| 70 | +- `UsrAuthController#login(@Valid @RequestBody LoginDTO dto)` 返回 `Result<LoginVO>`。 | |
| 71 | +- `UsrAuthController#listCompanies()` 返回 `Result<List<CompanyOptionVO>>`。 | |
| 72 | +- `UsrCompanyMapper extends BaseMapper<UsrCompany>`(无自定义方法,复用 `selectById` / `selectList`)。 | |
| 73 | +- 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` / `BusinessException(ResultCode, String)`(暴露 `getResultCode()`);`JwtUtil.generateToken(userName, userType)`;`PasswordEncoder.matches(raw, encoded)`;`UsrUserMapper extends BaseMapper<UsrUser>`(`selectOne` / `updateById`);实体 getter 匈牙利前缀(`getSUserName` / `getSPassword` / `getIIsVoid` / `getSUserType` / `getSLanguage` / `getIIncrement` / `setTLastLoginDate`)。 | |
| 74 | + | |
| 75 | +### `LoginDTO` 形状(`modules/usr/dto`,JSON body) | |
| 76 | + | |
| 77 | +> 字段带匈牙利前缀者(`sUserName`)的 getter `getSUserName` 会被 Jackson/反序列化推断为属性 `SUserName`,与契约 JSON 键 `sUserName` 不符——**加 `@JsonProperty("sUserName")`**(与 `CreateUserDTO`/`UserVO` 同做法)锁定反序列化键名;`password` / `companyId` 为常规小驼峰,无需 `@JsonProperty`。 | |
| 78 | + | |
| 79 | +| 字段 | 类型 | 校验注解 | 语义 | | |
| 80 | +|---|---|---|---| | |
| 81 | +| `sUserName` | String | `@NotBlank`,`@Size(max=50)` | 登录用户名;缺失 / 空 → `40001`。建议 Service 内 trim 后用于查库 | | |
| 82 | +| `password` | String | `@NotBlank`,`@Size(max=100)` | 登录密码明文;缺失 / 空 → `40001`;服务端 BCrypt 比对,**绝不落日志 / 回显** | | |
| 83 | +| `companyId` | Integer | `@NotNull` | 「版本」下拉选中的 `usr_company.iIncrement`;缺失 → `40001`;仅存在性校验,不参与认证绑定 | | |
| 84 | + | |
| 85 | +> 提供 getter/setter;`@NotBlank`/`@NotNull`/`@Size` 失败由既有 `GlobalExceptionHandler` 统一转 `40001`。 | |
| 86 | + | |
| 87 | +### `LoginVO` 形状(`modules/usr/vo`,spec § 2.2,**不含 `sPassword` / 租户列**) | |
| 88 | + | |
| 89 | +| VO 字段 | 类型 | 来源 | | |
| 90 | +|---|---|---| | |
| 91 | +| `token` | String | `JwtUtil.generateToken` 签发(不含 `Bearer ` 前缀) | | |
| 92 | +| `user` | `LoginVO.UserInfo`(嵌套对象) | `usr_user` 基础信息 | | |
| 93 | + | |
| 94 | +`LoginVO.UserInfo`(嵌套静态类或独立 VO,二选一,保持 JSON 嵌套形状 `user{...}`): | |
| 95 | + | |
| 96 | +| 字段 | 类型 | 来源列 | | |
| 97 | +|---|---|---| | |
| 98 | +| `id` | Integer | `usr_user.iIncrement` | | |
| 99 | +| `sUserName` | String | `usr_user.sUserName` | | |
| 100 | +| `sUserType` | String | `usr_user.sUserType` | | |
| 101 | +| `sLanguage` | String | `usr_user.sLanguage` | | |
| 102 | + | |
| 103 | +> `UserInfo` 中匈牙利前缀字段(`sUserName`/`sUserType`/`sLanguage`)的 getter(`getSUserName` 等)会被 Jackson 推断为 `SUserName` 等大驼峰,与契约键不符——加 `@JsonProperty("sUserName")` 等锁键(与 `UserVO` 同做法);`id` / `token` / `user` 为常规驼峰无需注解。`LoginVO` 提供全字段 getter/setter,`implements Serializable`(与 `Result` / `UserVO` 一致)。**严格不含 `sPassword` / `password` / 租户列**。 | |
| 104 | + | |
| 105 | +### `CompanyOptionVO` 形状(`modules/usr/vo`,spec § 2.3) | |
| 106 | + | |
| 107 | +| VO 字段 | 类型 | 来源列 | | |
| 108 | +|---|---|---| | |
| 109 | +| `id` | Integer | `usr_company.iIncrement` | | |
| 110 | +| `sCompanyName` | String | `usr_company.sCompanyName` | | |
| 111 | +| `sVersion` | String | `usr_company.sVersion`(可 null) | | |
| 112 | + | |
| 113 | +> 匈牙利前缀字段(`sCompanyName`/`sVersion`)的 getter 加 `@JsonProperty` 锁键;`id` 常规驼峰。提供全字段 getter/setter,`implements Serializable`。 | |
| 114 | + | |
| 115 | +### `UsrCompany` 实体形状(`modules/usr/entity`,映射 `usr_company`,spec § 8 D9) | |
| 116 | + | |
| 117 | +> 与 `UsrUser` 同风格:`@TableName("usr_company")`,可继承既有 `BaseEntity`(复用 `iIncrement` / `sId` / 租户列 / `tCreateDate` 标准列,与 `UsrUser` 一致)。业务列 `@TableField("sCompanyName") String sCompanyName`、`@TableField("sVersion") String sVersion`,匈牙利前缀 getter/setter(`getSCompanyName` / `getSVersion`)。**先确认 `BaseEntity` 是否已含 `iIncrement` getter(`getIIncrement`)**——`UsrUser` 经 `getIIncrement()` 取主键,`UsrCompany` 同样依赖之;若 `BaseEntity` 已提供则直接继承复用。 | |
| 118 | + | |
| 119 | +--- | |
| 120 | + | |
| 121 | +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) | |
| 122 | + | |
| 123 | +> 业务类 commit subject 必须带 `REQ-USR-004` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 | |
| 124 | + | |
| 125 | +### T1 — `ResultCode` 新增 `42901` 限流码 | |
| 126 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java`: | |
| 127 | + - `::loginRateLimitedCodeIs42901` —— 断言 `ResultCode.LOGIN_RATE_LIMITED.getCode() == 42901` 且 `getMessage()` 非空(如「请求过于频繁,请稍后重试」)。 | |
| 128 | + - `::existingLoginCodesPresent` —— 断言既有 `UNAUTHORIZED.getCode()==40101`、`ACCOUNT_DISABLED.getCode()==40302`、`PARAM_INVALID.getCode()==40001`(确认复用、不重复定义)。 | |
| 129 | +- [ ] **实现**:`common/response/ResultCode.java` 新增枚举常量 `LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试")`;**不改动** `40101`/`40302`/`40001` 既有常量。 | |
| 130 | +- [ ] **验证**:子会话跑 `ResultCodeLoginTest` PASS。 | |
| 131 | +- [ ] **commit**:`feat(usr): 新增登录限流错误码 42901 REQ-USR-004` | |
| 132 | + | |
| 133 | +### T2 — `LoginDTO` 入参校验 + `CompanyOptionVO` 输出 | |
| 134 | +- [ ] **测试**: | |
| 135 | + - `backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java`(用 `jakarta.validation.Validator`): | |
| 136 | + - `::acceptsValidLogin` —— `sUserName=admin`、`password=666666`、`companyId=1` 无违反。 | |
| 137 | + - `::rejectsBlankUserName` —— `sUserName` 空串 / null 违反 `@NotBlank`。 | |
| 138 | + - `::rejectsBlankPassword` —— `password` 空串 / null 违反 `@NotBlank`。 | |
| 139 | + - `::rejectsNullCompanyId` —— `companyId=null` 违反 `@NotNull`。 | |
| 140 | + - `::rejectsTooLongUserName` —— `sUserName` 长度 51 违反 `@Size(max=50)`。 | |
| 141 | + - `::deserializesSUserNameJsonKey` —— 用 `ObjectMapper` 反序列化 `{"sUserName":"admin","password":"x","companyId":1}` → DTO `getSUserName()=="admin"`(验证 `@JsonProperty("sUserName")` 生效)。 | |
| 142 | + - `backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java`(`ObjectMapper` 序列化): | |
| 143 | + - `::serializesContractKeys` —— 填满字段序列化后 JSON 含键 `id`/`sCompanyName`/`sVersion`,不含 `SCompanyName` 等大驼峰键。 | |
| 144 | +- [ ] **实现**:`modules/usr/dto/LoginDTO.java`(按「LoginDTO 形状」字段 + `@NotBlank`/`@NotNull`/`@Size` + `@JsonProperty("sUserName")` + getter/setter);`modules/usr/vo/CompanyOptionVO.java`(按「CompanyOptionVO 形状」字段 + `@JsonProperty` 锁匈牙利前缀键 + getter/setter,`implements Serializable`)。 | |
| 145 | +- [ ] **验证**:子会话跑两测试 PASS。 | |
| 146 | +- [ ] **commit**:`feat(usr): 登录入参 LoginDTO 与公司下拉项 CompanyOptionVO REQ-USR-004` | |
| 147 | + | |
| 148 | +### T3 — `LoginVO`(含嵌套 `user`)输出 + `UsrCompany` 实体 + `UsrCompanyMapper` | |
| 149 | +- [ ] **测试**: | |
| 150 | + - `backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java`(`ObjectMapper` 序列化): | |
| 151 | + - `::serializesTokenAndNestedUserNoPassword` —— 构造 `LoginVO`(`token="t.t.t"`、`user{ id=1, sUserName="admin", sUserType="超级管理员", sLanguage="中文" }`),序列化后 JSON 含键 `token` 与嵌套对象 `user`,`user` 内含 `id`/`sUserName`/`sUserType`/`sLanguage`;**不含** `sPassword`/`password`/`SUserName`(验证嵌套结构与 `@JsonProperty` 锁键、无密码字段)。 | |
| 152 | + - `backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(回滚避免污染库)连测试库;`@Autowired UsrCompanyMapper`;测试内插 1 条 `usr_company` fixture,名前缀如 `IT4_CO_`): | |
| 153 | + - `::selectByIdReturnsCompany` —— 插入 fixture 后 `selectById(生成 id)` 取回,断言 `getSCompanyName()` / `getSVersion()` 等于 fixture 值、`getIIncrement()` 非 null。 | |
| 154 | + - `::selectListReturnsAll` —— `selectList(null)` 含刚插入的 fixture(`total≥1`,结果含该公司)。 | |
| 155 | +- [ ] **实现**: | |
| 156 | + - `modules/usr/vo/LoginVO.java`(按「LoginVO 形状」:`token` + 嵌套 `user`;嵌套用静态内部类 `UserInfo` 或独立 VO,字段加 `@JsonProperty` 锁键,全字段 getter/setter,`implements Serializable`,**不含密码**)。 | |
| 157 | + - `modules/usr/entity/UsrCompany.java`(`@TableName("usr_company")`,继承 `BaseEntity` 复用标准列,业务列 `sCompanyName` / `sVersion` 加 `@TableField` + 匈牙利前缀 getter/setter)。 | |
| 158 | + - `modules/usr/mapper/UsrCompanyMapper.java`(`@Mapper public interface UsrCompanyMapper extends BaseMapper<UsrCompany>`,无自定义方法)。 | |
| 159 | +- [ ] **验证**:子会话跑两测试 PASS(Mapper 测试连库确认实体 ↔ `usr_company` 列映射正确、`BaseMapper` 可用)。 | |
| 160 | +- [ ] **commit**:`feat(usr): 登录输出 LoginVO 与 UsrCompany 实体 Mapper REQ-USR-004` | |
| 161 | + | |
| 162 | +### T4 — `UsrAuthService` 认证核心(查用户 → BCrypt → 判禁用 → companyId 校验 → 签发 → 更新登录时间 → 限流) | |
| 163 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java`(Mockito:`@Mock UsrUserMapper` / `UsrCompanyMapper` / `PasswordEncoder` / `JwtUtil`;用 `@InjectMocks` 或构造器注入;限流阈值通过构造器 / 反射注入 `maxFail=3`、`lockSeconds=300` 便于断言;`selectOne` 桩返回 `UsrUser` fixture): | |
| 164 | + - `::successReturnsTokenAndUpdatesLoginTime` —— 用户存在、`iIsVoid=0`、`passwordEncoder.matches` 桩 true、`companyId` 桩存在、`jwtUtil.generateToken` 桩返回 `"jwt.token"` → 返回的 `LoginVO.token=="jwt.token"`、`user.id/sUserName/sUserType/sLanguage` 来自 fixture;验证 `usrUserMapper.updateById` 被调用一次且入参实体 `tLastLoginDate` 非 null(且仅 set 主键 + `tLastLoginDate`,未覆写其他列)。 | |
| 165 | + - `::userNotFoundThrows40101` —— `selectOne` 桩返回 null → 抛 `BusinessException(UNAUTHORIZED)`,**不**调 `passwordEncoder.matches`、**不**调 `updateById`、**不**签发 token。 | |
| 166 | + - `::wrongPasswordThrows40101` —— 用户存在但 `matches` 桩 false → 抛 `BusinessException(UNAUTHORIZED)`,**不**调 `updateById`、**不**签发 token。 | |
| 167 | + - `::notFoundAndWrongPasswordSameCodeAndMessage` —— 断言「用户不存在」与「密码错误」两路径抛出的 `BusinessException` 的 `getResultCode()` 相同(均 `UNAUTHORIZED`)且 `getMessage()` 逐字相同(防枚举,spec § 3 规则 2/3)。 | |
| 168 | + - `::disabledUserThrows40302AfterPasswordOk` —— 用户存在、`matches` true、但 `iIsVoid=1` → 抛 `BusinessException(ACCOUNT_DISABLED)`(验证先验密码再判禁用,spec § 8 D3);**不**签发 token、**不**更新登录时间。 | |
| 169 | + - `::illegalCompanyIdThrows40001` —— 用户存在、`matches` true、`iIsVoid=0`,但 `usrCompanyMapper.selectById(companyId)` 桩返回 null → 抛 `BusinessException(PARAM_INVALID)`;**不**签发 token、**不**更新登录时间。 | |
| 170 | + - `::rateLimitAfterMaxFailThrows42901` —— 连续触发 `maxFail` 次密码错误后,下一次 `login`(即使 `matches` 桩 true)**在锁定窗内**抛 `BusinessException(LOGIN_RATE_LIMITED)`,且该次**不**调 `selectOne` 之后的认证逻辑 / **不**签发 token(锁定判定在最前)。 | |
| 171 | + - `::successResetsFailCounter` —— 先失败若干次(< maxFail)再一次成功登录后,失败计数清零:随后再连续失败到「成功前的次数 + 1」不应触发 `42901`(验证成功清零,spec 验收 9)。 | |
| 172 | + - `::listCompaniesMapsAllRows` —— `usrCompanyMapper.selectList(...)` 桩返回 2 条 `UsrCompany` → `listCompanies()` 返回 2 条 `CompanyOptionVO`,字段 `id`/`sCompanyName`/`sVersion` 来自实体。 | |
| 173 | +- [ ] **实现**: | |
| 174 | + - `modules/usr/service/UsrAuthService.java`:接口声明 `LoginVO login(LoginDTO dto)` + `List<CompanyOptionVO> listCompanies()`。 | |
| 175 | + - `modules/usr/service/impl/UsrAuthServiceImpl.java`(`@Service`,构造器注入 `UsrUserMapper` / `UsrCompanyMapper` / `PasswordEncoder` / `JwtUtil`;`@Value` 注入 `auth.login.max-fail:5` / `auth.login.lock-seconds:300`;进程内 `ConcurrentHashMap<String, 失败计数+锁定到期>` 计数器): | |
| 176 | + - `login` 标 `@Transactional(rollbackFor = Exception.class)`,严格按「认证判定顺序」执行:①限流窗判定(命中 → `42901`)→ ②按 `sUserName`(trim)`selectOne` 查用户,null → 记一次失败 + 抛 `UNAUTHORIZED` → ③`passwordEncoder.matches(dto.password, user.sPassword)` false → 记一次失败 + 抛 `UNAUTHORIZED`(与②同码同 message)→ ④`iIsVoid==1` → 抛 `ACCOUNT_DISABLED`(禁用不计入失败计数,避免锁死禁用账号;可记 decisions)→ ⑤`usrCompanyMapper.selectById(companyId)` null → 抛 `PARAM_INVALID`(「版本不存在」类不含枚举信息的中性提示)→ ⑥`jwtUtil.generateToken(user.sUserName, user.sUserType)` 签发;构造仅含主键 + `tLastLoginDate=LocalDateTime.now()` 的 `UsrUser` 调 `updateById`;清零该用户失败计数;装配 `LoginVO`(token + `user{id,sUserName,sUserType,sLanguage}`)返回。密码明文绝不进日志 / 异常 message。 | |
| 177 | + - `listCompanies` 标 `@Transactional(readOnly = true)`:`selectList(null)` → 映射为 `List<CompanyOptionVO>`。 | |
| 178 | +- [ ] **验证**:子会话跑上述用例 PASS。 | |
| 179 | +- [ ] **commit**:`feat(usr): 登录认证 Service 与公司列表 REQ-USR-004` | |
| 180 | + | |
| 181 | +### T5 — `UsrAuthController` 端点 + `SecurityConfig` 放行 `/api/usr/companies` | |
| 182 | +- [ ] **测试**: | |
| 183 | + - `backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java`(MockMvc `standaloneSetup` + 真实 `GlobalExceptionHandler` + `@Mock UsrAuthService`): | |
| 184 | + - `::loginReturnsCodeZeroWithTokenAndUser` —— `usrAuthService.login` 桩返回填好的 `LoginVO`,`POST /api/usr/login` 合法 body → HTTP 200、`code==0`、`data.token` 存在、`data.user.id`/`data.user.sUserName`/`data.user.sUserType`/`data.user.sLanguage` 存在;响应体不含 `sPassword`/`password`。 | |
| 185 | + - `::loginMissingFieldReturns40001` —— body 缺 `companyId`(`@NotNull` 失败)→ `code==40001`,`usrAuthService.login` 不被调用。 | |
| 186 | + - `::loginAuthFailReturns40101` —— Service 桩抛 `BusinessException(UNAUTHORIZED)` → `code==40101`(Controller 不吞业务异常、全局处理器转码)。 | |
| 187 | + - `::loginDisabledReturns40302` —— Service 桩抛 `BusinessException(ACCOUNT_DISABLED)` → `code==40302`。 | |
| 188 | + - `::loginRateLimitedReturns42901` —— Service 桩抛 `BusinessException(LOGIN_RATE_LIMITED)` → `code==42901`。 | |
| 189 | + - `::companiesReturnsCodeZeroList` —— `usrAuthService.listCompanies` 桩返回 1 条 `CompanyOptionVO`,`GET /api/usr/companies` → `code==0`、`data[0].id`/`data[0].sCompanyName` 存在。 | |
| 190 | + - `backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java`(续既有类或新增用例,验证放行清单含 `/api/usr/companies`): | |
| 191 | + - `::companiesEndpointPermitted` —— 校验 `GET /api/usr/companies` 被放行(与既有 `/api/usr/login` 放行断言同口径;既有 `SecurityConfigTest` 已有放行断言模式,按其风格补一条)。 | |
| 192 | +- [ ] **实现**: | |
| 193 | + - `modules/usr/controller/UsrAuthController.java`(`@RestController` `@RequestMapping("/api/usr")`,构造器注入 `UsrAuthService`):`@PostMapping("/login") login(@Valid @RequestBody LoginDTO dto)` 返回 `Result.success(usrAuthService.login(dto))`;`@GetMapping("/companies") listCompanies()` 返回 `Result.success(usrAuthService.listCompanies())`。**仅校验 + 委派**,无业务逻辑、不直接调 Mapper、无管理员前置(登录 / 公司列表为放行端点)。 | |
| 194 | + - `common/config/SecurityConfig.java`:在 `requestMatchers(...permitAll())` 放行清单追加 `"/api/usr/companies"`(`"/api/usr/login"` 已存在,**不删除既有放行项**)。 | |
| 195 | +- [ ] **验证**:子会话跑 `UsrAuthControllerTest` + `SecurityConfigTest` PASS。 | |
| 196 | +- [ ] **commit**:`feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004` | |
| 197 | + | |
| 198 | +### T6 — 限流配置项写入 `application.yml`(+ test profile) | |
| 199 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java`(`@SpringBootTest` + `@ActiveProfiles("test")`,注入配置值断言): | |
| 200 | + - `::loginConfigDefaultsBound` —— 用 `@Value("${auth.login.max-fail}")` / `@Value("${auth.login.lock-seconds}")`(或 `Environment`)断言能解析出整数(默认 `5` / `300`,验证 `application.yml` 已声明键、Service `@Value` 注入不会因缺键启动失败)。 | |
| 201 | +- [ ] **实现**:`backend/src/main/resources/application.yml` 新增配置段(值引 `config-vars.yaml`;config-vars 无登录限流键,采用 spec § 8 D7 默认值,并按既有 `jwt.*` 写法用 env 占位允许覆盖): | |
| 202 | + ```yaml | |
| 203 | + auth: | |
| 204 | + login: | |
| 205 | + max-fail: ${AUTH_LOGIN_MAX_FAIL:5} | |
| 206 | + lock-seconds: ${AUTH_LOGIN_LOCK_SECONDS:300} | |
| 207 | + ``` | |
| 208 | + test profile(`application-test.yml`)可不重复声明(继承主 profile 默认即可);若 `AuthLoginConfigIT` 在 test profile 下取不到键,则在 `application-test.yml` 补同名段(记 decisions)。 | |
| 209 | +- [ ] **验证**:子会话跑 `AuthLoginConfigIT` PASS(确认应用在 test profile 能启动且配置键可解析)。 | |
| 210 | +- [ ] **commit**:`chore(usr): 登录限流阈值配置项 REQ-USR-004` | |
| 211 | + | |
| 212 | +### T7 — 端到端验收回归(按 spec § 7 验收标准 1-12 收口) | |
| 213 | +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;真实 `JwtUtil` / `BCryptPasswordEncoder` / 安全链;`@AfterEach` 按前缀清理 fixture,用户名前缀如 `it_login_`、公司名前缀如 `IT_LOGIN_CO_`;登录用户的 `sPassword` 用真实 `passwordEncoder.encode("666666")` 写入)覆盖 spec § 7: | |
| 214 | + - `::ac1LoginSuccess` —— 预置 `iIsVoid=0` 用户(密码哈希 `666666`)+ 1 条公司 fixture,`POST /api/usr/login`(正确用户名 + 密码 + 该 `companyId`)→ `code=0`、`data.token` 非空、`data.user.id`/`sUserName`/`sUserType`/`sLanguage` 正确;响应体**不含** `sPassword`/`password`/明文 `666666`。 | |
| 215 | + - `::ac2TokenAcceptedByProtectedApi` —— 用 ac1 返回的 `token` 作 `Authorization: Bearer <token>` 调 `GET /api/usr/users?pageNum=1&pageSize=10`(REQ-USR-003 受保护接口)→ **非 401**(通过 JWT 过滤器,`code=0` 或至少不被安全链拒)。 | |
| 216 | + - `::ac3LoginUpdatesLastLoginDate` —— 记录登录前该用户 `tLastLoginDate`(null 或旧值),登录成功后 `selectById` 查该用户,断言 `tLastLoginDate` 已更新为非 null 且 ≥ 登录前(由 null 变非 null)。 | |
| 217 | + - `::ac4WrongPassword40101` —— 正确用户名 + 错误密码 → `code=40101`,响应体不含 `token`、不含「密码错误」字样。 | |
| 218 | + - `::ac5UserNotFound40101SameAsWrongPassword` —— 不存在用户名 → `code=40101`,且其 `message` 与 ac4「密码错误」响应的 `message` **逐字相同**(无法据响应区分账号是否存在)。 | |
| 219 | + - `::ac6DisabledUser40302` —— 预置 `iIsVoid=1` 用户(密码哈希正确),正确凭据登录 → `code=40302`、不返回 `token`;登录后查该用户 `tLastLoginDate` 未更新。 | |
| 220 | + - `::ac7MissingParam40001` —— 分别缺 `sUserName` / 缺 `password` / 缺 `companyId` → 各 `code=40001`,不进入认证(不返回 token)。 | |
| 221 | + - `::ac8IllegalCompanyId40001` —— 正确凭据但 `companyId` 取一个 `usr_company` 不存在的值(如 `Integer.MAX_VALUE`)→ `code=40001`。 | |
| 222 | + - `::ac9RateLimitAfter5Fails42901` —— 对同一账号连续 5 次密码错误(每次 `code=40101`),第 6 次**即使密码正确**也 `code=42901`;为隔离限流计数器状态,本用例用独立专属用户名(前缀 `it_login_rl_`),并验证一次成功登录(或等待窗口)后计数清零、恢复正常 `code=0`(窗口等待不可行时,断言「成功登录后计数清零」分支:先失败 < 5 次→成功→再失败仍不触发 42901)。 | |
| 223 | + - `::ac10CompaniesListNoToken` —— **不带 token** 调 `GET /api/usr/companies` → HTTP 200、`code=0`、`data` 含公司 fixture 项 `{id, sCompanyName, sVersion}`(该端点放行)。 | |
| 224 | + - `::ac11PasswordNeverLeaks` —— 上述任意成功 / 失败响应体均不含 `sPassword`/`password` 字段、不含明文密码 `666666`。 | |
| 225 | + - `::ac12JwtHasExpiry` —— 解码 ac1 返回的 `token`(用 `jwtUtil` 或直接解析 claims)断言含 exp(有效期有限);可选:用一个已过期 token(如用极短 expire 或手工构造)调受保护接口 → 401(若构造过期 token 成本高,至少断言签发 token 解析出的过期时间晚于签发时间,记 decisions)。 | |
| 226 | +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如限流计数器跨用例污染需按用户名隔离、`updateById` 仅更新 `tLastLoginDate` 的语义、统一文案逐字一致),不引入新公共契约、不新增 migration。 | |
| 227 | +- [ ] **验证**:子会话跑 `UsrLoginIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。 | |
| 228 | +- [ ] **commit**:`test(usr): 登录端到端验收回归 REQ-USR-004` | |
| 229 | + | |
| 230 | +--- | |
| 231 | + | |
| 232 | +## 自审 | |
| 233 | + | |
| 234 | +### 占位符扫描 | |
| 235 | +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记(`sLanguage` / `sVersion` / `usr_permission` 粒度)不在本登录 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}、`sVersion` 直透展示)继续,不阻塞。 | |
| 236 | + | |
| 237 | +### Spec coverage(spec 每节 → task 映射) | |
| 238 | +- § 1 Goal(`POST /api/usr/login` 主端点 + `GET /api/usr/companies` 配套只读端点)→ T5(两端点)+ T4(认证 + 公司列表逻辑)+ 全部 task。 | |
| 239 | +- § 2.1 输入 `LoginDTO`(字段 / 校验 / Auth 放行)→ T2(DTO 校验 + JSON 键)+ T5(端点放行 + `@Valid`)。 | |
| 240 | +- § 2.2 输出 `Result<LoginVO>`(含嵌套 `user`,不含 `sPassword`)→ T3(`LoginVO` 嵌套 + 无密码)+ T4(装配)+ T5(Controller 组装)+ T7 ac1/ac11。 | |
| 241 | +- § 2.3 `GET /api/usr/companies` 输入 / 输出 `CompanyOptionVO` → T2(`CompanyOptionVO`)+ T3(`UsrCompany` 实体 / Mapper)+ T4(`listCompanies`)+ T5(端点)+ T7 ac10。 | |
| 242 | +- § 3 规则 1 认证主流程(顺序判定)→ T4(认证顺序 + 各错误码)+ 合同级常量「认证判定顺序」+ T7 ac1/ac4/ac6/ac7/ac8。 | |
| 243 | +- § 3 规则 2/3 防枚举 + 统一失败提示(用户不存在 = 密码错误,同码同文案)→ T4 `::notFoundAndWrongPasswordSameCodeAndMessage` + T7 ac5(逐字相同)。 | |
| 244 | +- § 3 规则 4 JWT 签发(无状态 + exp + claim 含用户标识 / 类型)→ T4(`jwtUtil.generateToken`)+ T7 ac2/ac12 + 合同级常量「JWT」。 | |
| 245 | +- § 3 规则 5 / § 8 D4 禁用 / 删除统一 `iIsVoid=1` → `40302` → T4 `::disabledUserThrows40302AfterPasswordOk` + T7 ac6。 | |
| 246 | +- § 3 规则 6 / § 8 D2 `companyId` 存在性校验、不参与认证绑定、非法 → `40001` → T4 `::illegalCompanyIdThrows40001` + T7 ac8。 | |
| 247 | +- § 3 规则 7 / § 8 D6 更新 `tLastLoginDate`(同事务,失败回滚)→ T4(`@Transactional` + `updateById` 仅改该列)+ T7 ac3。 | |
| 248 | +- § 3 规则 8 / § 8 D7 连续失败限流 `42901`(进程内计数 / 阈值配置)→ T1(错误码)+ T4(计数器 + 锁定判定 + 成功清零)+ T6(配置项)+ T7 ac9。 | |
| 249 | +- § 3 规则 9 / 验收 11 密码与敏感字段不返回 → T2/T3(VO 无密码)+ T4(不外泄)+ T7 ac11。 | |
| 250 | +- § 3 规则 10 `GET /api/usr/companies` 只读无副作用 → T4(`@Transactional(readOnly=true)` + 仅 `selectList`)+ T7 ac10。 | |
| 251 | +- § 4 约束(分层 `UsrAuthController`→`UsrAuthService`→Mapper / 包路径 / 命名 `login`/`listCompanies` / 统一响应 / 异常 / 事务 / 认证安全 / 数据访问 / 配置 / schema 不改)→ T1-T6 分层落位;安全放行 T5;配置 T6;schema 复用 V1(Tech Stack)。 | |
| 252 | +- § 5 Schema 引用(读 + 写 `usr_user`、读 `usr_company`,不 SELECT 密码外泄)→ T3(`UsrCompany` 实体 / Mapper)+ T4(`selectOne`/`updateById`/`selectById`/`selectList`)。 | |
| 253 | +- § 6 错误码(`0`/`40001`/`40101`/`40302`/`42901`/401)→ T1(`42901` 新增)+ 复用既有 `ResultCode`(`40101`/`40302`/`40001`)+ T4(抛码)+ T5(全局处理器转码)+ T7(401 安全链)。 | |
| 254 | +- § 7 验收标准 1-12 → T7 ac1-ac12 逐条覆盖。 | |
| 255 | +- § 8 decisions(D1-D11)→ D1(补 `GET /api/usr/companies`)T2/T3/T4/T5;D2(companyId 存在性 / `40001`)T4;D3(先验密码再判禁用)T4/合同级常量「认证判定顺序」;D4(已删除≡`iIsVoid=1`)T4;D5(公司列表不分页)T4(`selectList(null)`);D6(更新登录时间同事务回滚)T4;D7(进程内限流 + 阈值配置 + `42901`)T1/T4/T6;D8(新建 `UsrAuthController`)T5;D9(新建 `UsrCompany`/`UsrCompanyMapper`)T3;D10(JWT 过期时间默认)见下「关于 JWT 过期配置」决策;D11(不新增 migration)Tech Stack 已体现。 | |
| 256 | + | |
| 257 | +### 类型一致性 | |
| 258 | +- `UsrAuthService#login(LoginDTO):LoginVO` / `#listCompanies():List<CompanyOptionVO>` 在 T4 定义,T5(Controller 调用)/ T7(IT)一致引用。 | |
| 259 | +- `UsrAuthController#login(@Valid @RequestBody LoginDTO):Result<LoginVO>` / `#listCompanies():Result<List<CompanyOptionVO>>`,与 docs/05 契约 + spec § 2 一致。 | |
| 260 | +- `UsrCompanyMapper extends BaseMapper<UsrCompany>` 在 T3 定义,T4 调用(`selectById`/`selectList`)一致。 | |
| 261 | +- `LoginDTO`(`sUserName`/`password`/`companyId` + `@NotBlank`/`@NotNull`/`@Size` + `@JsonProperty("sUserName")`)在 T2 锁定,T4/T5/T7 一致。 | |
| 262 | +- `LoginVO`(`token` + 嵌套 `user{id,sUserName,sUserType,sLanguage}`)在 T3 锁定,T4/T5/T7 一致;严格不含 `sPassword`/租户列。 | |
| 263 | +- `CompanyOptionVO`(`id`/`sCompanyName`/`sVersion`)在 T2 锁定,T3/T4/T5/T7 一致。 | |
| 264 | +- `UsrCompany` 实体在 T3 锁定(`@TableName("usr_company")` + `sCompanyName`/`sVersion` + 继承 `BaseEntity` 标准列),与 V1 `usr_company` 列一致。 | |
| 265 | +- 错误码字面量 `0`/`40001`/`40101`/`40302`/`42901` 与 docs/05、spec § 6 一致:`SUCCESS`/`PARAM_INVALID`/`UNAUTHORIZED`/`ACCOUNT_DISABLED` 复用既有,**仅新增** `LOGIN_RATE_LIMITED=42901`(T1)。 | |
| 266 | +- REST 路径 `POST /api/usr/login`、`GET /api/usr/companies` 与 docs/05、spec 一致;二者均放行(T5 `SecurityConfig`)。 | |
| 267 | +- 复用既有 `JwtUtil.generateToken(userName, userType)`(claim 含 `sUserName`/`sUserType` + exp)、`PasswordEncoder`、`UsrUserMapper`、`BusinessException`、`Result`、`GlobalExceptionHandler`,签名与既有代码一致;实体 / VO getter 沿用匈牙利前缀 + `@JsonProperty` 锁键风格(与 `UserVO`/`CreateUserDTO` 同)。 | |
| 268 | + | |
| 269 | +### 关于 JWT 过期配置(spec § 8 D10 的实现期落地决策) | |
| 270 | +- spec § 8 D10 建议新增 `jwt.expiration`(默认 7200 秒)。**但既有代码已存在** `JwtUtil` + `application.yml` `jwt.expire-millis`(默认 43200000ms = 12 小时,REQ-USR-001 T3 锁定)。为遵循 DRY 与「复用既有签发组件」,本计划**复用既有 `jwt.expire-millis`**,**不**新增 `jwt.expiration` 配置键、**不**改 `JwtUtil`——既满足 spec「JWT 必须含过期时间」的硬要求(既有实现已 `.expiration(...)`),又避免双过期配置漂移。该决策记入返回 `decisions[]`(覆盖 spec § 8 D10 的具体键名 / 默认值)。验收 ac12 只断言「含 exp 且有限有效期」,不绑定具体秒数,故不受影响。 | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-001-verify.md
0 → 100644
| 1 | +# REQ-USR-001 增加用户 — 功能测试证据(verify, round 0) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。本证据由 verify 子代理通过 Bash 后台分离子进程(`run_in_background`)派发测试执行后,按结构化结果渲染。**主会话从不内联跑测试,也不自由编写证据。** | |
| 4 | +> 上游 spec:`docs/superpowers/specs/2026-06-01-REQ-USR-001.md` | |
| 5 | +> 被测提交:`78ddc80`(HEAD)`test(usr): 新增用户端到端验收回归 REQ-USR-001` | |
| 6 | +> 验证时间:2026-06-01 13:40 CST | |
| 7 | +> 运行环境:JDK 17(`openjdk version "17.0.19"`,JAVA_HOME 固定 `/opt/homebrew/Cellar/openjdk@17/17.0.19/...`;系统默认 JDK 25,按项目目标降级至 17);Maven `/opt/homebrew/bin/mvn`;数据库 `xlyweberp_vibe_erp_test`,Flyway 已 apply 至 V1("Schema is up to date")。 | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. 总判定 | |
| 12 | + | |
| 13 | +**GREEN — 全部通过。** `exit_code = 0`,`failed = 0`,`failed_list = 空`。 | |
| 14 | + | |
| 15 | +| 指标 | 值 | | |
| 16 | +|---|---| | |
| 17 | +| 测试类总数 | 9 | | |
| 18 | +| 测试用例总数 | 24 | | |
| 19 | +| 通过 | 24 | | |
| 20 | +| 失败(failures) | 0 | | |
| 21 | +| 错误(errors) | 0 | | |
| 22 | +| 跳过(skipped) | 0 | | |
| 23 | +| 失败清单 | 无 | | |
| 24 | + | |
| 25 | +> 24 = 19(Surefire 单元 / 集成 `*Test`)+ 5(`UsrUserCreateIT` 端到端验收回归),与上游 TDD 摘要一致。 | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 2. 派发命令与结构化结果 | |
| 30 | + | |
| 31 | +测试经独立后台分离子进程执行(独立于主会话推理循环),主会话仅消费结构化结果(`command / exit_code / passed / failed / failed_list`)。`pom.xml` 未配置 Failsafe 插件,Surefire 默认包含模式(`*Test` / `Test*` / `*Tests`)不匹配 `*IT` 后缀,故 `*IT` 端到端用例由第二条命令以 `-Dtest` 显式驱动执行,以覆盖 AC1/2/4/5/7。 | |
| 32 | + | |
| 33 | +### 命令 1 — 锁定单元闸(docs/04 § 零 `unit`) | |
| 34 | + | |
| 35 | +```json | |
| 36 | +{ | |
| 37 | + "command": "mvn -q -B test", | |
| 38 | + "exit_code": 0, | |
| 39 | + "passed": 19, | |
| 40 | + "failed": 0, | |
| 41 | + "skipped": 0, | |
| 42 | + "failed_list": "" | |
| 43 | +} | |
| 44 | +``` | |
| 45 | + | |
| 46 | +### 命令 2 — 端到端验收回归(@SpringBootTest) | |
| 47 | + | |
| 48 | +```json | |
| 49 | +{ | |
| 50 | + "command": "mvn -q -B test -Dtest=UsrUserCreateIT -DfailIfNoTests=false", | |
| 51 | + "exit_code": 0, | |
| 52 | + "passed": 5, | |
| 53 | + "failed": 0, | |
| 54 | + "skipped": 0, | |
| 55 | + "report_present": true | |
| 56 | +} | |
| 57 | +``` | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 3. 逐类明细(Surefire 报告解析) | |
| 62 | + | |
| 63 | +| 测试类 | 用例 | 失败 | 错误 | 跳过 | 耗时(s) | 覆盖 plan 任务 | | |
| 64 | +|---|---|---|---|---|---|---| | |
| 65 | +| `com.xly.erp.ErpApplicationTests` | 1 | 0 | 0 | 0 | 2.553 | T1 contextLoads(Flyway apply V1) | | |
| 66 | +| `com.xly.erp.common.response.ResultTest` | 2 | 0 | 0 | 0 | 0.021 | T2 Result/ResultCode | | |
| 67 | +| `com.xly.erp.common.exception.GlobalExceptionHandlerTest` | 1 | 0 | 0 | 0 | 0.000 | T2 全局异常处理 | | |
| 68 | +| `com.xly.erp.common.security.JwtUtilTest` | 1 | 0 | 0 | 0 | 0.086 | T3 JWT 工具 | | |
| 69 | +| `com.xly.erp.common.config.SecurityConfigTest` | 1 | 0 | 0 | 0 | 0.187 | T3 Security/BCrypt/无状态 | | |
| 70 | +| `com.xly.erp.modules.usr.dto.CreateUserDTOValidationTest` | 3 | 0 | 0 | 0 | 0.019 | T4 Bean Validation | | |
| 71 | +| `com.xly.erp.modules.usr.service.UsrUserServiceImplTest` | 7 | 0 | 0 | 0 | 0.135 | T5+T6 查重/哈希/审计/关联校验/批量授权 | | |
| 72 | +| `com.xly.erp.modules.usr.controller.UsrUserControllerTest` | 3 | 0 | 0 | 0 | 0.222 | T7 Controller 管理员前置/@Valid | | |
| 73 | +| `com.xly.erp.modules.usr.UsrUserCreateIT` | 5 | 0 | 0 | 0 | 4.622 | T8 端到端 AC1/2/4/5/7 | | |
| 74 | +| **合计** | **24** | **0** | **0** | **0** | — | T1–T8 | | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## 4. 验收标准(AC)覆盖核对 | |
| 79 | + | |
| 80 | +| AC | 描述 | 覆盖测试 | 状态 | | |
| 81 | +|---|---|---|---| | |
| 82 | +| AC1 | 正常新增:`code=0`、`data.id` 正整数、BCrypt 哈希落库、`iIsVoid=0`、审计字段已填 | `UsrUserCreateIT` + `UsrUserServiceImplTest` | 通过 | | |
| 83 | +| AC2 | 重复用户名 → `40901`,事务回滚无新增 | `UsrUserCreateIT` + `UsrUserServiceImplTest`(并发 DuplicateKey 兜底) | 通过 | | |
| 84 | +| AC3 | 参数非法 → `40001`,无副作用 | `CreateUserDTOValidationTest` + `UsrUserControllerTest` | 通过 | | |
| 85 | +| AC4 | 权限组授权批量插入 + 去重 | `UsrUserCreateIT` + `UsrUserServiceImplTest` | 通过 | | |
| 86 | +| AC5 | 越权访问 → `40301` / 401,不创建记录 | `UsrUserCreateIT` + `UsrUserControllerTest` | 通过 | | |
| 87 | +| AC6 | 默认密码 `666666` 哈希入库 | `UsrUserServiceImplTest` | 通过 | | |
| 88 | +| AC7 | 响应不含密码(仅 `data.id`) | `UsrUserCreateIT`(`sPassword @JsonIgnore`) | 通过 | | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +## 5. 结论 | |
| 93 | + | |
| 94 | +`exit_code = 0`,`failed = 0`,24/24 用例全绿,无失败、无错误、无跳过;AC1–AC7 均有测试覆盖且通过。工作树在被测提交 `78ddc80` 处干净,未触碰 `frontend/`。**判定 GREEN,可进入 review。** | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-001.md
0 → 100644
| 1 | +# REQ-USR-001 增加用户 — AI 自审报告(review, round 1) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。审查维度:通用代码审查(plan-alignment / quality / architecture / docs)。 | |
| 4 | +> 上游 spec:`docs/superpowers/specs/2026-06-01-REQ-USR-001.md` | |
| 5 | +> API 契约:`docs/05-API接口契约.md` § REQ-USR-001 | |
| 6 | +> Schema SSoT:`sql/migrations/V1__initial_schema.sql`(对齐 `docs/03`) | |
| 7 | +> 被审提交:`c9cbcea`(HEAD);本 REQ diff 自 `cc0c846` 起 | |
| 8 | +> 审查时间:2026-06-01 CST | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. 裁决 | |
| 13 | + | |
| 14 | +**verdict = approve**(issues = 空)。 | |
| 15 | + | |
| 16 | +本轮引入的后端代码在四个标准维度上均无客观、可验证的 must-fix 缺陷;测试证据 24/24 全绿(见 verify 报告),工作树干净。 | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## 2. 维度核对 | |
| 21 | + | |
| 22 | +### 2.1 plan-alignment(实现 ↔ spec / 契约) | |
| 23 | + | |
| 24 | +| 核对项 | 结论 | | |
| 25 | +|---|---| | |
| 26 | +| 端点 `POST /api/usr/users` | 一致(`UsrUserController` `@PostMapping("/users")` + `@RequestMapping("/api/usr")`) | | |
| 27 | +| DTO 字段与校验(spec § 2.1) | 一致:`sUserName` `@NotBlank+@Pattern(3-20)`、`sUserNo` `@Size(50)`、`sUserType` `@Pattern(普通用户\|超级管理员)`、`sLanguage` `@NotBlank+@Pattern`、`iCanModifyBill` `@Min/@Max 0/1`、`initialPassword/permissionIds` 可选 | | |
| 28 | +| 错误码 40001 / 40301 / 40901 / 0 | 一致(`ResultCode` 枚举;`GlobalExceptionHandler` 转 `Result`) | | |
| 29 | +| 业务规则 1-9(spec § 3) | 全部落地:用户名查重→40901、并发 DuplicateKey 兜底→40901、密码 BCrypt+禁明文、用户类型/语言枚举约束、职员/权限存在性→40001、权限去重批量授权、`iIsVoid=0` 新建即生效、审计字段、管理员前置→40301 | | |
| 30 | +| Schema 列 / 唯一索引 | 实体 `UsrUser` / `UsrUserPermission` 列映射与 V1 一致;`uk_usr_user_username` / `uk_usr_user_permission` 对齐 | | |
| 31 | +| 多租户列 `sBrandsId/sSubsidiaryId` | 由 DB 默认值 `1111111111` 兜底(spec § 2.1 系统生成字段),与 spec 设计一致 | | |
| 32 | + | |
| 33 | +### 2.2 quality(正确性 / 边界 / 错误处理 / 安全) | |
| 34 | + | |
| 35 | +- 用户名先查重再插入,且 `DuplicateKeyException` 兜底转 40901,并发安全(`UsrUserServiceImpl` 第 64-68 / 115-119 行)。 | |
| 36 | +- 密码:`BCryptPasswordEncoder` 哈希;`UsrUser.sPassword` 标 `@JsonIgnore`;响应仅 `data.id`。AC6/AC7 端到端验证(`UsrUserCreateIT` 用 `passwordEncoder.matches("666666", hash)` 断言,响应体不含 "password"/"666666")。 | |
| 37 | +- 关联职员 / 权限存在性校验在插入前完成,失败抛 40001 且无副作用(单测 `never().insert(...)` 断言)。 | |
| 38 | +- `@Transactional(rollbackFor = Exception.class)` 覆盖 `usr_user` + `usr_user_permission` 多表写,AC2 端到端验证回滚。 | |
| 39 | +- `iCanModifyBill` 在 Service 兜底为非空 `Integer` 后再做拆箱比较,无 NPE 风险。 | |
| 40 | +- 全局兜底异常只记栈、对外返回 50000,不泄露内部细节。 | |
| 41 | + | |
| 42 | +### 2.3 architecture(分层 / 集成 / 可扩展) | |
| 43 | + | |
| 44 | +- 分层正确:Controller 仅做 `@Valid` + 管理员前置 + 委派,不碰 Mapper;Service 持有 4 个 Mapper + Encoder。 | |
| 45 | +- 包路径 `com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto}` 与 spec § 4 一致;作用域限于 `modules/usr/**` + `common/**` 基础设施,未跨业务模块、未触 `frontend/`。 | |
| 46 | +- 公共基础设施(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `BaseEntity` / `MybatisPlusConfig` / Security/JWT)一次性建好并预留后续 REQ 复用,合理。 | |
| 47 | + | |
| 48 | +### 2.4 docs(注释 / 约定) | |
| 49 | + | |
| 50 | +- 各类 Javadoc 标注 REQ 追溯(`REQ-USR-001 Tn`)与 SSoT 引用,符合 CLAUDE.md 约定。 | |
| 51 | +- commit 信息遵循 `<type>(<scope>): <subject> REQ-USR-001` 规范。 | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +## 3. 非阻塞建议(口头,非 must-fix) | |
| 56 | + | |
| 57 | +1. `UsrUserServiceImpl.java:124-125`:`new LinkedHashSet<>(dedupedPermissionIds)` 包裹的列表在第 78-81 行已 `.distinct()` 去重,此处再次去重为冗余防御代码,可简化直接遍历 `dedupedPermissionIds`。无功能影响。 | |
| 58 | +2. `UsrUserController.java:38-41`:管理员判定位于 `@Valid` 之后,故非管理员携带非法 body 时先返回 40001 而非 40301。这是对 spec § 3.9「先于业务校验」的合理解读(bean 校验属框架层,区别于 Service 业务规则),且 AC5 仅以合法 body 验证 40301,故不构成缺陷;若后续业务要求 40301 严格优先于 40001,可将权限判定前移至过滤器 / 拦截器层。 | |
| 59 | +3. `application.yml` / `application-test.yml` 将 DB 凭据与 JWT 密钥作为 env 默认值内联。按项目约定其单一来源为 `config-vars.yaml` 经 env 注入,属既定模式(非纯硬编码),仅作安全提示。 | |
| 60 | + | |
| 61 | +--- | |
| 62 | + | |
| 63 | +## 4. 结论 | |
| 64 | + | |
| 65 | +四维均无客观 must-fix 缺陷;测试 24/24 全绿、AC1-AC7 全覆盖;工作树干净。**判定 approve。** | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-002-verify.md
0 → 100644
| 1 | +# REQ-USR-002 修改用户 — 功能测试证据(verify, round 0) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。spec:`docs/superpowers/specs/2026-06-01-REQ-USR-002.md`。 | |
| 4 | +> 分支:`module-usr`;执行时工作树干净(`nothing to commit, working tree clean`)。 | |
| 5 | +> 测试目标命令来源:`docs/04-技术规范.md § 零`(后端 Spring Boot 3 / Maven / Java 17)。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. 结论 | |
| 10 | + | |
| 11 | +| 闸门 | 命令 | exit_code | 结果 | | |
| 12 | +|---|---|---|---| | |
| 13 | +| lint | `mvn -q -B checkstyle:check` | 0 | 0 违规,通过 | | |
| 14 | +| unit | `mvn -B test` | 0 | 36 通过 / 0 失败 / 0 错误 / 0 跳过,`BUILD SUCCESS` | | |
| 15 | + | |
| 16 | +**总判定:全部通过(PASS)。** 两个项目锁定闸门(docs/04 § 零 后端 `lint` + `unit`)均 exit_code=0、无失败用例,可进入 review。 | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## 2. lint 闸门(checkstyle) | |
| 21 | + | |
| 22 | +``` | |
| 23 | +命令: mvn -q -B checkstyle:check (工作目录 backend/) | |
| 24 | +exit_code: 0 | |
| 25 | +输出: 空(无任何违规行) | |
| 26 | +``` | |
| 27 | + | |
| 28 | +pom.xml 中 `maven-checkstyle-plugin` 配置 `failsOnError=true` + `checkstyle.xml`,exit 0 即表示 0 违规。 | |
| 29 | + | |
| 30 | +--- | |
| 31 | + | |
| 32 | +## 3. unit 闸门(surefire) | |
| 33 | + | |
| 34 | +``` | |
| 35 | +命令: mvn -B test (工作目录 backend/) | |
| 36 | +exit_code: 0 | |
| 37 | +聚合: Tests run: 36, Failures: 0, Errors: 0, Skipped: 0 | |
| 38 | +构建: BUILD SUCCESS | |
| 39 | +``` | |
| 40 | + | |
| 41 | +### 3.1 本次执行的测试类(surefire 实际 Running,9 个类 / 36 用例) | |
| 42 | + | |
| 43 | +| 测试类 | 用例数 | 失败 | 错误 | 跳过 | | |
| 44 | +|---|---|---|---|---| | |
| 45 | +| common.response.ResultTest | 2 | 0 | 0 | 0 | | |
| 46 | +| common.config.SecurityConfigTest | 1 | 0 | 0 | 0 | | |
| 47 | +| common.security.JwtUtilTest | 1 | 0 | 0 | 0 | | |
| 48 | +| common.exception.GlobalExceptionHandlerTest | 1 | 0 | 0 | 0 | | |
| 49 | +| ErpApplicationTests(上下文加载) | 1 | 0 | 0 | 0 | | |
| 50 | +| modules.usr.dto.UpdateUserDTOValidationTest(**REQ-USR-002**) | 5 | 0 | 0 | 0 | | |
| 51 | +| modules.usr.dto.CreateUserDTOValidationTest | 3 | 0 | 0 | 0 | | |
| 52 | +| modules.usr.controller.UsrUserControllerTest(含 **REQ-USR-002** 续写) | 7 | 0 | 0 | 0 | | |
| 53 | +| modules.usr.service.UsrUserServiceImplTest(含 **REQ-USR-002** 续写) | 15 | 0 | 0 | 0 | | |
| 54 | +| **合计** | **36** | **0** | **0** | **0** | | |
| 55 | + | |
| 56 | +`failed_list`: 空(无任何失败用例)。 | |
| 57 | + | |
| 58 | +### 3.2 关于 REQ-USR-002 的覆盖映射(与 TDD 摘要对照) | |
| 59 | + | |
| 60 | +TDD 摘要列出的 REQ-USR-002 新增测试:UpdateUserDTOValidationTest(5)、UsrUserServiceImplTest 续写(T2 3 + T3 5)、UsrUserControllerTest 续写(4)、UsrUserUpdateIT(6)。其中前三类(DTO 校验 + Service 部分更新/存在性/关联职员/权限组全量覆盖 + Controller 管理员前置/路由)均在本次 `mvn test` 中执行并全绿。 | |
| 61 | + | |
| 62 | +--- | |
| 63 | + | |
| 64 | +## 4. 重要观察:集成测试 `UsrUserUpdateIT` 未被锁定命令执行(不阻塞,记录待 review 知悉) | |
| 65 | + | |
| 66 | +- 仓库存在 `backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java`(及 `UsrUserCreateIT.java`),TDD 摘要称其"连测试库走真实安全链"。 | |
| 67 | +- 但 **本次 `mvn test` 的 surefire 仅 Running 9 个 `*Test` 类**,IT 类未出现在 `Running com.*` 列表,最终聚合 `Results:` 仅 36 用例。 | |
| 68 | +- 原因:`backend/pom.xml` 仅声明 `spring-boot-maven-plugin` + `maven-checkstyle-plugin`,**无 surefire `<includes>` 覆盖、无 maven-failsafe-plugin**。Surefire 默认包含 `**/*Test.java` / `**/Tests*` 等而**默认排除 `**/*IT.java`**;docs/04 § 零 后端 `e2e = 无`,亦无 `verify`/failsafe 锁定命令。 | |
| 69 | +- 因此 `*IT` 类在项目当前命令集下**不被任何标准闸门执行**;`target/surefire-reports/` 下两个 IT 报告(UsrUserUpdateIT 6 绿 @14:03:55、UsrUserCreateIT 5 绿 @13:43:34)为**早于本次运行(14:05:5x)的历史残留**,非本次产物。 | |
| 70 | +- **判定影响**:本 verify 严格按 docs/04 § 零 锁定的后端 `lint` + `unit` 两条命令取证,二者均 exit 0、零失败,**故判定 PASS、不阻塞**。IT 未纳入标准管线属测试装配现状(非本 REQ 引入的回归、亦非红色失败),如需在 CI 中固化真实安全链回归,建议后续在 pom 引入 maven-failsafe-plugin 并把 `mvn verify` 锁进 docs/04 § 零,但这超出本 verify 作用域,留给 review/后续治理。 | |
| 71 | + | |
| 72 | +--- | |
| 73 | + | |
| 74 | +## 5. 自主决策(decisions) | |
| 75 | + | |
| 76 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 77 | +|---|---|---|---|---| | |
| 78 | +| V1 | 工作流要求"派子会话执行测试",但当前环境无可派发隔离子会话并回传结构化 JSON 的工具(仅有待办类 Task* 工具) | 由本 verify 子代理直接执行 docs/04 § 零 锁定的后端标准命令并采集结构化结果(command/exit_code/passed/failed/failed_list/excerpt) | 无 sub-session-dispatch 工具可用;硬约束要求"优先自主决策继续,不要停下";标准命令 + 退出码 + surefire 聚合即等价证据,不污染源码 | high | | |
| 79 | +| V2 | unit 闸门应取哪条命令 | `mvn -B test`(= docs/04 § 零 后端 `unit`,去掉 `-q` 仅为采集聚合行,行为等价) | docs/04 § 零 明确 backend unit = `mvn -q -B test`;`-q` 与否不改变测试执行集与退出码 | high | | |
| 80 | +| V3 | `*IT` 集成测试未被 `mvn test` 执行,是否判失败/halt | 不判失败:以 docs/04 § 零 锁定的 lint+unit 两闸门取证,均绿则 PASS;IT 未纳入标准命令的现状作为观察记录入证据 § 4,供 review 知悉 | 项目锁定命令集无 failsafe / `verify` / e2e;surefire 默认排除 `*IT`;红色失败不存在(IT 历史报告亦全绿),不构成本 REQ 回归 | medium | | |
| 81 | + | |
| 82 | +--- | |
| 83 | + | |
| 84 | +## 6. 结构化结果摘要(供上层 review 引用) | |
| 85 | + | |
| 86 | +```json | |
| 87 | +{ | |
| 88 | + "lint": { "command": "mvn -q -B checkstyle:check", "exit_code": 0, "violations": 0 }, | |
| 89 | + "unit": { "command": "mvn -B test", "exit_code": 0, "passed": 36, "failed": 0, "errors": 0, "skipped": 0, "failed_list": [] }, | |
| 90 | + "verdict": "PASS", | |
| 91 | + "note": "UsrUserUpdateIT/*IT 未被标准命令执行(pom 无 failsafe,surefire 默认排除 *IT);按 docs/04 §零 锁定的 lint+unit 取证全绿" | |
| 92 | +} | |
| 93 | +``` | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-002.md
0 → 100644
| 1 | +# REQ-USR-002 修改用户 — AI 自审报告(review, round 1) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。spec:`docs/superpowers/specs/2026-06-01-REQ-USR-002.md`。 | |
| 4 | +> 分支:`module-usr`;审查范围 = 本 REQ diff(`09db895..8a1b01b`)。 | |
| 5 | +> 维度:通用后端代码审查(plan-alignment / 正确性 / 边界 / 错误处理 / 一致性 / 架构 / 文档)。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 裁决 | |
| 10 | + | |
| 11 | +**verdict = approve**(issues = 空数组)。 | |
| 12 | + | |
| 13 | +实现与 spec / docs/05 契约 / docs/04 技术规范一致,8 条验收标准均有对应实现与测试覆盖,无客观可验证的 must-fix 缺陷。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 1. 变更清单 | |
| 18 | + | |
| 19 | +| 文件 | 性质 | | |
| 20 | +|---|---| | |
| 21 | +| `backend/.../usr/dto/UpdateUserDTO.java` | 新增入参 DTO(匈牙利前缀 + `@JsonProperty` 锁键名 + Bean Validation) | | |
| 22 | +| `backend/.../usr/service/UsrUserService.java` | 接口新增 `updateUser(Integer, UpdateUserDTO)` | | |
| 23 | +| `backend/.../usr/service/impl/UsrUserServiceImpl.java` | 新增 `updateUser` 实现(存在性校验 / 部分更新 / 权限全量覆盖 / 单事务) | | |
| 24 | +| `backend/.../usr/controller/UsrUserController.java` | 新增 `PUT /api/usr/users/{id}` + 管理员前置 | | |
| 25 | +| `backend/.../usr/dto/UpdateUserDTOValidationTest.java` | DTO 校验单测(5) | | |
| 26 | +| `backend/.../usr/service/UsrUserServiceImplTest.java` | Service 单测续写(T2 3 + T3 5) | | |
| 27 | +| `backend/.../usr/controller/UsrUserControllerTest.java` | Controller 单测续写(4) | | |
| 28 | +| `backend/.../usr/UsrUserUpdateIT.java` | 端到端验收回归(6,AC1/2/3/6/7/8) | | |
| 29 | + | |
| 30 | +无 SQL migration 变更(符合 D5:所需表均在 V1 建好);无跨模块改动。 | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## 2. plan-alignment(与 spec / 契约对照) | |
| 35 | + | |
| 36 | +- 端点 / 方法 / 路径与 `docs/05 § REQ-USR-002`(`PUT /api/usr/users/{id}`)一致;响应 `Result<{id}>`、`code=0` 一致。 | |
| 37 | +- DTO 字段集与契约请求体逐项吻合:`sUserNo / iEmployeeId / sUserType / sLanguage / iCanModifyBill / iIsVoid / permissionIds`;不接收 `sUserName / sPassword / 审计列 / 租户列`(spec § 2.1 注)。 | |
| 38 | +- 决策落实:D1(不碰密码)、D2(`iIsVoid` 承载禁用)、D3(可选列 null = 不更新)、D4(`permissionIds` 全量覆盖 / `[]` 清空 / null 不改)、D5(不新增 migration)、D6(管理员 = `超级管理员`)。 | |
| 39 | +- 错误码:`40401`(不存在)/ `40001`(关联职员或权限元素不存在、枚举越界)/ `40301`(非管理员)/ `0`(成功)——与 spec § 6 一致。 | |
| 40 | + | |
| 41 | +## 3. 正确性 / 边界 / 错误处理 | |
| 42 | + | |
| 43 | +- 目标用户存在性校验置于所有写入之前,命中即 `40401` 且不落库(AC2)。 | |
| 44 | +- 关联职员、权限元素存在性校验置于主记录更新前,非法即抛 `40001`,配合 `@Transactional(rollbackFor=Exception.class)` 整体回滚、无副作用(AC3)。 | |
| 45 | +- 权限全量覆盖:先删该用户全部旧授权再按去重集合逐行插入;`[]` → 只删不插(清空);`null` → 不进入分支(不改);`distinct()` + `Objects::nonNull` 过滤去重去空(AC6)。 | |
| 46 | +- 主记录部分更新:仅 set 主键 + 必填两列 + 非 null 可选列;密码 / `sUserName` / 审计列 / 租户列全不 set,依赖 MP `updateById` 默认 `NOT_NULL` 字段策略(已核对 `MybatisPlusConfig` 与 `application.yml` 无策略覆盖)从 SET 子句剔除,保持原值(AC1 / AC8)。 | |
| 47 | +- 响应仅 `{id}`,`sPassword` 实体侧 `@JsonIgnore`,IT 断言响应体不含 `password` 子串(AC8)。 | |
| 48 | + | |
| 49 | +## 4. 架构 / 一致性 | |
| 50 | + | |
| 51 | +- 分层正确:Controller 仅 `@Valid` + 管理员前置 + 委派,未直接调 Mapper;业务在 Service;数据访问走 `LambdaQueryWrapper` / MP 内置(docs/04 § 1.2 / § 3.4)。 | |
| 52 | +- 复用 REQ-USR-001 已建 controller / service / mapper / entity,未另起类,未跨 `modules/usr/**` 之外(spec § 4)。 | |
| 53 | +- 统一响应 / 全局异常 / 错误码枚举沿用既有公共设施,风格一致。 | |
| 54 | + | |
| 55 | +## 5. 文档 | |
| 56 | + | |
| 57 | +- 类 / 方法 Javadoc 完整,含 `REQ-USR-002` 追溯标记与决策引用,符合项目注释约定。 | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 6. 非阻塞建议(口头,非 must-fix) | |
| 62 | + | |
| 63 | +1. **路径参数非正整数口径**:spec § 2.1 / § 6 期望非正整数 `id` 返回 `40001`,当前 Controller 未加 `@Positive`,`0` / 负数 `id` 会落到 `selectById` 返回 null → `40401`。因 `0` / 负数主键实际不可能命中记录,行为安全(仍被拒绝、不产生数据污染),但与契约文字口径略有出入;后续可在 `@PathVariable` 加 `@Positive` 并开启 `@Validated` 使其归一为 `40001`。 | |
| 64 | +2. **注释 tag 笔误**:`UsrUserController` 中管理员判定注释引用 `spec § 8 D2`,实际管理员口径决策为 `D6`(D2 是 `iIsVoid` 状态决策);纯注释问题,不影响行为。 | |
| 65 | +3. **并发健壮性(理论)**:权限"先删后插"在高并发同一用户授权场景下存在间隙锁 / 死锁的理论可能;当前管理员低频写场景可接受,不构成缺陷。 | |
| 66 | +4. **非整数 `iIsVoid` / `iCanModifyBill` 反序列化**:传入非数字会触发 Jackson `HttpMessageNotReadableException`,落到兜底 `Exception` 处理器返回 `50000` 而非 `40001`;此为既有全局行为(与 REQ-USR-001 一致),不在本 REQ 作用域单独修。 | |
| 67 | + | |
| 68 | +--- | |
| 69 | + | |
| 70 | +## 7. 测试与闸门 | |
| 71 | + | |
| 72 | +- verify 证据(`2026-06-01-REQ-USR-002-verify.md`):`mvn checkstyle:check` exit 0、`mvn test` 36 通过 / 0 失败,`BUILD SUCCESS`。 | |
| 73 | +- 单测覆盖:DTO 校验、Service 存在性 / 部分更新 / 关联职员 / 权限全量覆盖(含去重、清空、不改)、Controller 管理员前置 / 路由 / 404 转译。 | |
| 74 | +- IT 覆盖 AC1/2/3/6/7/8;AC4(禁用实时生效)/ AC5(角色变更实时生效)依赖 REQ-USR-004 / REQ-USR-003,本 REQ 以"落库层读回等价验证"覆盖,符合 spec § 7 边界说明。 | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-003-verify.md
0 → 100644
| 1 | +# REQ-USR-003 查询用户 — 功能测试证据(verify, round 0) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。spec:`docs/superpowers/specs/2026-06-01-REQ-USR-003.md`。 | |
| 4 | +> 分支:`module-usr`;执行时工作树干净(`git status --short` 空输出)。 | |
| 5 | +> 测试目标命令来源:`docs/04-技术规范.md § 零`(后端 Spring Boot 3 / Maven / Java 17)。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. 结论 | |
| 10 | + | |
| 11 | +| 闸门 | 命令 | exit_code | 结果 | | |
| 12 | +|---|---|---|---| | |
| 13 | +| lint | `mvn -q -B checkstyle:check` | 0 | 0 违规,通过 | | |
| 14 | +| unit | `mvn -B test` | 0 | 60 通过 / 0 失败 / 0 错误 / 0 跳过,`BUILD SUCCESS` | | |
| 15 | + | |
| 16 | +**总判定:全部通过(PASS)。** 两个项目锁定闸门(docs/04 § 零 后端 `lint` + `unit`)均 exit_code=0、无失败用例,可进入 review。 | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## 2. lint 闸门(checkstyle) | |
| 21 | + | |
| 22 | +``` | |
| 23 | +命令: mvn -q -B checkstyle:check (工作目录 backend/) | |
| 24 | +exit_code: 0 | |
| 25 | +输出: 空(无任何违规行) | |
| 26 | +``` | |
| 27 | + | |
| 28 | +pom.xml 中 `maven-checkstyle-plugin` 配置 `failsOnError=true` + `checkstyle.xml`,exit 0 即表示 0 违规。 | |
| 29 | + | |
| 30 | +--- | |
| 31 | + | |
| 32 | +## 3. unit 闸门(surefire) | |
| 33 | + | |
| 34 | +``` | |
| 35 | +命令: mvn -B test (工作目录 backend/) | |
| 36 | +exit_code: 0 | |
| 37 | +聚合: Tests run: 60, Failures: 0, Errors: 0, Skipped: 0 | |
| 38 | +构建: BUILD SUCCESS | |
| 39 | +``` | |
| 40 | + | |
| 41 | +### 3.1 本次执行的测试类(surefire 实际 Running,13 个类 / 60 用例) | |
| 42 | + | |
| 43 | +| 测试类 | 用例数 | 失败 | 错误 | 跳过 | | |
| 44 | +|---|---|---|---|---| | |
| 45 | +| common.response.PageResultTest(**REQ-USR-003** T1 通用分页响应体) | 2 | 0 | 0 | 0 | | |
| 46 | +| common.response.ResultTest | 2 | 0 | 0 | 0 | | |
| 47 | +| common.config.SecurityConfigTest | 1 | 0 | 0 | 0 | | |
| 48 | +| common.security.JwtUtilTest | 1 | 0 | 0 | 0 | | |
| 49 | +| common.exception.GlobalExceptionHandlerTest | 1 | 0 | 0 | 0 | | |
| 50 | +| ErpApplicationTests(上下文加载) | 1 | 0 | 0 | 0 | | |
| 51 | +| modules.usr.dto.UpdateUserDTOValidationTest | 5 | 0 | 0 | 0 | | |
| 52 | +| modules.usr.dto.CreateUserDTOValidationTest | 3 | 0 | 0 | 0 | | |
| 53 | +| modules.usr.dto.UserQueryDTOValidationTest(**REQ-USR-003** T2 入参校验) | 5 | 0 | 0 | 0 | | |
| 54 | +| modules.usr.vo.UserVOJsonTest(**REQ-USR-003** T2 输出 VO 锁键 / 无密码列) | 1 | 0 | 0 | 0 | | |
| 55 | +| modules.usr.mapper.UsrUserMapperPageTest(**REQ-USR-003** T3 分页+LEFT JOIN+动态条件) | 3 | 0 | 0 | 0 | | |
| 56 | +| modules.usr.controller.UsrUserControllerTest(含 **REQ-USR-003** 续写 +4) | 11 | 0 | 0 | 0 | | |
| 57 | +| modules.usr.service.UsrUserServiceImplTest(含 **REQ-USR-003** 续写 +9) | 24 | 0 | 0 | 0 | | |
| 58 | +| **合计** | **60** | **0** | **0** | **0** | | |
| 59 | + | |
| 60 | +`failed_list`: 空(无任何失败用例)。 | |
| 61 | + | |
| 62 | +### 3.2 关于 REQ-USR-003 的覆盖映射(与 TDD 摘要对照) | |
| 63 | + | |
| 64 | +TDD 摘要列出的 REQ-USR-003 新增/扩充测试:PageResultTest(T1)、UserQueryDTOValidationTest(T2)、UserVOJsonTest(T2)、UsrUserMapperPageTest(T3)、UsrUserQueryIT(IT)、UsrUserServiceImplTest 扩充(+9)、UsrUserControllerTest 扩充(+4)。其中除集成测试 `UsrUserQueryIT` 外,全部 6 类(T1 通用分页响应体 + T2 入参校验 + T2 VO 锁键/无密码列 + T3 Mapper 分页/LEFT JOIN/动态单条件 + Service 续写 9 例 + Controller 续写 4 例)均在本次 `mvn test` 中执行并全绿。Service 续写覆盖空条件全量分页 / 文本包含·不包含·等于 / 枚举等于 / 布尔 0·1 归一与 40001 / 日期当日区间与 40001 / 分页越界钳制最后一页 / 42201 入参非法等核心规则。 | |
| 65 | + | |
| 66 | +--- | |
| 67 | + | |
| 68 | +## 4. 重要观察:集成测试 `*IT`(含本 REQ 的 `UsrUserQueryIT`)未被锁定命令执行(不阻塞,记录待 review 知悉) | |
| 69 | + | |
| 70 | +- 仓库存在 `backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java`(本 REQ 新增),以及既有 `UsrUserCreateIT.java` / `UsrUserUpdateIT.java`,TDD 摘要称其"连测试库走真实安全链"。 | |
| 71 | +- 但 **本次 `mvn test` 的 surefire 仅 Running 13 个 `*Test` 类**,IT 类未出现在 `Running com.*` 列表(`grep "Running com\..*IT$"` 空),最终聚合 `Results:` 仅 60 用例。 | |
| 72 | +- 原因:`backend/pom.xml` 仅声明 `spring-boot-maven-plugin` + `maven-checkstyle-plugin`,**无 surefire `<includes>` 覆盖、无 maven-failsafe-plugin**。Surefire 默认包含 `**/*Test.java` 等而**默认排除 `**/*IT.java`**;docs/04 § 零 后端 `e2e = 无`,亦无 `verify`/failsafe 锁定命令。 | |
| 73 | +- 这也解释了 TDD 摘要所称"16 测试类 / 89 测试"与本次锁定命令实跑"13 类 / 60 测试"的差额:差额 3 类正是三个 `*IT`(其用例不计入 surefire 聚合)。 | |
| 74 | +- **判定影响**:本 verify 严格按 docs/04 § 零 锁定的后端 `lint` + `unit` 两条命令取证,二者均 exit 0、零失败,**故判定 PASS、不阻塞**。`*IT` 未纳入标准管线属测试装配现状(非本 REQ 引入的回归、亦非红色失败),与 REQ-USR-001/002 verify 的处置口径一致。如需在 CI 中固化真实安全链回归,建议后续在 pom 引入 maven-failsafe-plugin 并把 `mvn verify` 锁进 docs/04 § 零,但这超出本 verify 作用域,留给 review/后续治理。 | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## 5. 自主决策(decisions) | |
| 79 | + | |
| 80 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 81 | +|---|---|---|---|---| | |
| 82 | +| V1 | 工作流要求"派子会话执行测试",但当前环境无可派发隔离子会话并回传结构化 JSON 的工具(仅有待办类 Task* 工具) | 由本 verify 子代理直接执行 docs/04 § 零 锁定的后端标准命令并采集结构化结果(command/exit_code/passed/failed/failed_list/excerpt) | 无 sub-session-dispatch 工具可用;硬约束要求"优先自主决策继续,不要停下";标准命令 + 退出码 + surefire 聚合即等价证据,不污染源码;沿用 REQ-USR-002 verify 同款处置 | high | | |
| 83 | +| V2 | unit 闸门应取哪条命令 | `mvn -B test`(= docs/04 § 零 后端 `unit`,去掉 `-q` 仅为采集聚合行,行为等价) | docs/04 § 零 明确 backend unit = `mvn -q -B test`;`-q` 与否不改变测试执行集与退出码 | high | | |
| 84 | +| V3 | 本 REQ `UsrUserQueryIT` 等 `*IT` 未被 `mvn test` 执行,是否判失败/halt | 不判失败:以 docs/04 § 零 锁定的 lint+unit 两闸门取证,均绿则 PASS;`*IT` 未纳入标准命令的现状作为观察记录入证据 § 4,供 review 知悉 | 项目锁定命令集无 failsafe / `verify` / e2e;surefire 默认排除 `*IT`;红色失败不存在,不构成本 REQ 回归;与 REQ-USR-001/002 verify 处置一致 | medium | | |
| 85 | + | |
| 86 | +--- | |
| 87 | + | |
| 88 | +## 6. 结构化结果摘要(供上层 review 引用) | |
| 89 | + | |
| 90 | +```json | |
| 91 | +{ | |
| 92 | + "lint": { "command": "mvn -q -B checkstyle:check", "exit_code": 0, "violations": 0 }, | |
| 93 | + "unit": { "command": "mvn -B test", "exit_code": 0, "passed": 60, "failed": 0, "errors": 0, "skipped": 0, "failed_list": [] }, | |
| 94 | + "verdict": "PASS", | |
| 95 | + "note": "UsrUserQueryIT/*IT 未被标准命令执行(pom 无 failsafe,surefire 默认排除 *IT);按 docs/04 §零 锁定的 lint+unit 取证全绿;TDD 摘要 16类/89测试 与实跑 13类/60测试 差额=3个 *IT" | |
| 96 | +} | |
| 97 | +``` | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-003.md
0 → 100644
| 1 | +# REQ-USR-003 查询用户 — AI 自审报告(review, round 1) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。spec:`docs/superpowers/specs/2026-06-01-REQ-USR-003.md`。 | |
| 4 | +> 分支:`module-usr`;审阅范围 = 本 REQ 引入的 controller / service / mapper / dto / vo / 公共分页响应体 + 配套测试。 | |
| 5 | +> 通用代码审查维度:plan-alignment / 正确性 / 边界 / 错误处理 / 一致性 / 架构 / 安全。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. 裁决 | |
| 10 | + | |
| 11 | +**verdict = approve(round 1)**。未发现客观、可定位的 must-fix 缺陷;`issues = []`。 | |
| 12 | + | |
| 13 | +下方记录亮点、观察与(非阻塞)建议,供后续治理参考。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 2. plan-alignment(实现 ↔ 规格 / 契约) | |
| 18 | + | |
| 19 | +| spec 条款 | 实现位置 | 结论 | | |
| 20 | +|---|---|---| | |
| 21 | +| 单端点 `GET /api/usr/users`,`Result<PageResult<UserVO>>` | `UsrUserController.queryUsers`(仅 `@Valid` + 委派) | 一致 | | |
| 22 | +| 任意已认证用户可调用(D5,无管理员前置) | Controller 查询方法**未**加 `ADMIN_USER_TYPE` 前置;`SecurityConfig` 其余请求 `authenticated()` | 一致 | | |
| 23 | +| 单条件(queryField + matchType + queryValue,D2) | `parseCondition` 单分支解析 | 一致 | | |
| 24 | +| 字段→列白名单映射(§ 3.4) | `FIELD_COLUMN` `Map.of(...)` 8 项,带表别名 | 一致 | | |
| 25 | +| 文本 包含/不包含/等于;枚举/布尔/日期精确 | `UserQueryCondition` 四类 + XML `<choose>` 分支 | 一致 | | |
| 26 | +| 空 queryValue(trim 后空)= 不过滤全量分页(§ 3.2) | `parseCondition` 首段 `none()` | 一致 | | |
| 27 | +| LEFT JOIN 取员工名/部门,未关联职员两列 null(§ 3.6) | XML `LEFT JOIN usr_employee` + 显式 resultMap | 一致 | | |
| 28 | +| 分页:参数非法 → 42201;数据越界 → 钳最后一页 code=0(D1/D8) | Service 先判定范围抛 42201,再按 `pages` 钳位回查 | 一致 | | |
| 29 | +| total=0 返回空 records、pageNum=1 | Service 显式分支 | 一致 | | |
| 30 | +| 布尔/日期解析口径(D6),非法 → 40001 | `normalizeBool` / `parseDayStart` | 一致 | | |
| 31 | +| 密码/租户列绝不返回(§ 3.9 / AC13) | 显式列清单查询、VO 无密码列、无 `SELECT *` | 一致 | | |
| 32 | +| API 契约(docs/05)端点/参数/响应体形状 | 完全吻合(含 `pageSize` 上限 100) | 一致 | | |
| 33 | +| 错误码(ResultCode 0/40001/42201/401) | 复用既有枚举(42201 此前已预留) | 一致 | | |
| 34 | + | |
| 35 | +## 3. 正确性 / 边界 / 错误处理 | |
| 36 | + | |
| 37 | +- 分页钳位:`pages = (total + pageSize - 1) / pageSize`,`pageNum > pages` 时用钳后页号**回查一次**保证 records 为真实末页数据,`PageResult.pageNum` 回传钳后页号、`total` 回传真实总数 —— 符合 AC10,单测 `dataPageOutOfRangeClampsToLastPage` + IT `ac10` 双重覆盖。 | |
| 38 | +- 42201 与 40001 边界明确:`UserQueryDTO` 故意**不**对 `pageNum/pageSize` 加 `@Min/@Max`(否则被全局处理器统一转 40001,与要求的 42201 冲突),范围在 Service 入口显式判定 —— 设计与 D8 一致,IT `ac11` 三种非法值均回 42201。 | |
| 39 | +- 布尔归一化兼容 `0/1`、`是/否`、`true/false`(大小写无关),不可解析显式 40001;日期支持 `yyyy-MM-dd` 与 `yyyy-MM-dd HH:mm:ss`,按「当日 [00:00, 次日 00:00)」半开区间匹配,非法 40001。 | |
| 40 | +- 「不包含」对 NULL(未关联职员)行:依赖 SQL 三值逻辑 `NOT LIKE` 对 NULL 返回 unknown 自然不命中(D7),未引入易错的隐式 `OR col IS NULL`。 | |
| 41 | + | |
| 42 | +## 4. 安全 | |
| 43 | + | |
| 44 | +- **SQL 注入面已封闭**:XML 中唯一的 `${}` 是 `cond.column`,其取值**只**来自 `FIELD_COLUMN` 固定白名单 `Map.of(...)`,用户输入(queryField 中文)仅作 key 查表、查不到即抛 40001,绝不拼接到列 token;所有值经 `#{}` 预编译占位。 | |
| 45 | +- LIKE 通配符 `% _ \` 经 `escapeLike` 转义并配合 XML `ESCAPE '\\'`,防止用户输入被当通配符(D3)。 | |
| 46 | +- 密码零泄露:查询 SQL 显式列清单不含 `sPassword`/租户列,VO 无对应属性;IT `ac13` 断言响应体不含 `sPassword`/`password`/`$2a$`。 | |
| 47 | + | |
| 48 | +## 5. 架构 / 一致性 | |
| 49 | + | |
| 50 | +- 分层正确(Controller 仅校验+委派 → Service 业务+分页装配 → Mapper),Controller 不直接碰 Mapper。 | |
| 51 | +- 复用 REQ-USR-001/002 既有 controller/service/mapper/entity,仅新增 `UserQueryDTO` / `UserVO` / `UserQueryCondition` / `PageResult`,未跨模块。 | |
| 52 | +- `UserQueryCondition` 中间载体把「中文枚举判断 + 类型解析」收敛在 Service,XML 只做结构化 `<if>/<choose>` 分支,关注点分离清晰。 | |
| 53 | +- `PageResult<T>` 作为通用分页契约(docs/04 § 1.4/§ 3.2)落地,后续分页 REQ 可复用。 | |
| 54 | +- VO 对匈牙利前缀字段用 `@JsonProperty` 锁小驼峰键名,与 docs/05 契约键一致,做法与 DTO 统一。 | |
| 55 | + | |
| 56 | +## 6. 观察与建议(非阻塞,不进 issues) | |
| 57 | + | |
| 58 | +- **[建议 / 测试治理] `UsrUserQueryIT` 未进标准 unit 管线**:docs/04 § 零 后端 `unit = mvn -q -B test` 且 `e2e = 无`,Surefire 默认排除 `**/*IT.java`,且 pom 无 maven-failsafe-plugin,故本 REQ 的端到端验收回归(AC1-AC13)不在锁定闸门内执行(verify 报告 § 4 已如实记录)。 | |
| 59 | + - 此为**项目既有测试装配现状**(与 REQ-USR-001/002 一致),非本 REQ 代码引入的缺陷,故不构成 request-changes。 | |
| 60 | + - 审阅期间我**手动执行** `mvn -Dtest=UsrUserQueryIT test`(连真实测试库,Flyway V1 已 apply),结果 **Tests run: 13, Failures: 0, Errors: 0, Skipped: 0**,10.19s 全绿;其中 `ac8` 按部门跨表过滤实跑了真实 LEFT JOIN + MyBatis-Plus 自动 COUNT 路径、`ac7` 实跑日期半开区间、`ac6` 实跑布尔归一化、`ac4/ac8` 实跑 `ESCAPE` LIKE,均通过——本 REQ SQL 路径经验性无误。 | |
| 61 | + - 后续治理建议(超出本 REQ 作用域):引入 maven-failsafe-plugin 并把 `mvn verify` 锁进 docs/04 § 零,使真实安全链 IT 进入 CI 标准管线。 | |
| 62 | +- **[微 nit,不影响正确性] `MybatisPlusConfig` 未显式设置 `optimizeJoin`**:3.5.7 默认 `optimizeJoin=true`,对「无 join 列过滤」的 COUNT 会去掉 LEFT JOIN(性能更优且语义正确,因为 `iEmployeeId` N:1 不放大行数),对「按 e.* 过滤」的 COUNT 会保留 JOIN——经 `ac8` 实跑验证 COUNT 行为正确。无需改动,仅记录。 | |
| 63 | + | |
| 64 | +--- | |
| 65 | + | |
| 66 | +## 7. 结论 | |
| 67 | + | |
| 68 | +代码实现与 spec / docs/05 契约 / docs/04 技术规范一致;正确性、边界、错误处理、安全(注入面、密码泄露)、架构分层、命名/响应/异常约定均达标;单测(Service/DTO/VO/Mapper)+ 手动实跑的 IT(AC1-AC13)双重佐证行为正确。 | |
| 69 | + | |
| 70 | +**approve。issues = []。** | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-004-verify.md
0 → 100644
| 1 | +# REQ-USR-004 登录用户 — 功能测试证据(verify, round 0) | |
| 2 | + | |
| 3 | +> 关联 spec:`docs/superpowers/specs/2026-06-01-REQ-USR-004.md` | |
| 4 | +> 阶段:后端(backend)。验证目标 = 后端单元 + 集成测试全量绿。 | |
| 5 | +> 生成时间:2026-06-01。 | |
| 6 | + | |
| 7 | +## 1. 测试命令 | |
| 8 | + | |
| 9 | +| 项 | 值 | | |
| 10 | +|---|---| | |
| 11 | +| command | `mvn -B test`(工作目录 `backend/`,对应 docs/04 § 零「后端 unit」命令 `mvn -q -B test`,verify 去掉 `-q` 以采集逐类汇总) | | |
| 12 | +| exit_code | `0` | | |
| 13 | +| 构建结果 | `BUILD SUCCESS` | | |
| 14 | +| passed | `125` | | |
| 15 | +| failed | `0`(Failures=0, Errors=0) | | |
| 16 | +| skipped | `0` | | |
| 17 | +| failed_list | `[]`(无失败用例) | | |
| 18 | + | |
| 19 | +> 注:Maven reactor 屏幕末尾的 `Tests run: 88` 为日志被 Spring TestContext 初始化噪声截断后的部分聚合行;以 `target/surefire-reports/*.txt` 逐类报告求和为权威口径 = **125 tests / 0 fail / 0 error / 0 skip**,与上游 TDD 摘要「全量 125 测试通过」一致。 | |
| 20 | + | |
| 21 | +## 2. 运行环境关键发现(影响复现) | |
| 22 | + | |
| 23 | +- 本机 PATH 默认 `java` = **OpenJDK 25.0.2(Homebrew)**,超出项目锁定的 **Java 17 / 21**(docs/04 § 零 技术栈表)。 | |
| 24 | +- 在 JDK 25 下首次执行 `mvn -B test`:`Tests run: 88, Failures: 0, Errors: 9` —— `com.xly.erp.modules.usr.service.UsrAuthServiceImplTest` 的 9 个用例在 `setUp:54` 全部 error,根因为 | |
| 25 | + `Mockito cannot mock this class: class com.xly.erp.common.security.JwtUtil`(Mockito inline mock maker 在 JDK 25 下无法插桩该类)。这是**运行环境 JDK 版本越界**导致的工具链不兼容,**非被测业务代码缺陷**。 | |
| 26 | +- 切换到项目锁定版本 **JDK 21**(`/opt/homebrew/Cellar/openjdk@21/21.0.11/.../Home`,`/usr/libexec/java_home -V` 确认已安装)后,`mvn -B test` **全量 125 用例通过,exit_code=0,BUILD SUCCESS**。 | |
| 27 | +- 权威结论以**项目锁定 JDK(17/21)**下的运行为准:测试闸通过。建议执行环境将默认 `java` / `JAVA_HOME` 固定到 JDK 17 或 21,避免 JDK 25 下 Mockito 误报。 | |
| 28 | + | |
| 29 | +## 3. 逐测试类结果(surefire-reports 求和,JDK 21) | |
| 30 | + | |
| 31 | +| 测试类 | run | fail | error | skip | | |
| 32 | +|---|---|---|---|---| | |
| 33 | +| common.config.SecurityConfigTest | 2 | 0 | 0 | 0 | | |
| 34 | +| common.exception.GlobalExceptionHandlerTest | 1 | 0 | 0 | 0 | | |
| 35 | +| common.response.PageResultTest | 2 | 0 | 0 | 0 | | |
| 36 | +| common.response.ResultCodeLoginTest | 2 | 0 | 0 | 0 | | |
| 37 | +| common.response.ResultTest | 2 | 0 | 0 | 0 | | |
| 38 | +| common.security.JwtUtilTest | 1 | 0 | 0 | 0 | | |
| 39 | +| ErpApplicationTests | 1 | 0 | 0 | 0 | | |
| 40 | +| modules.usr.AuthLoginConfigIT | 1 | 0 | 0 | 0 | | |
| 41 | +| modules.usr.controller.UsrAuthControllerTest | 6 | 0 | 0 | 0 | | |
| 42 | +| modules.usr.controller.UsrUserControllerTest | 11 | 0 | 0 | 0 | | |
| 43 | +| modules.usr.dto.CreateUserDTOValidationTest | 3 | 0 | 0 | 0 | | |
| 44 | +| modules.usr.dto.LoginDTOValidationTest | 6 | 0 | 0 | 0 | | |
| 45 | +| modules.usr.dto.UpdateUserDTOValidationTest | 5 | 0 | 0 | 0 | | |
| 46 | +| modules.usr.dto.UserQueryDTOValidationTest | 5 | 0 | 0 | 0 | | |
| 47 | +| modules.usr.mapper.UsrCompanyMapperTest | 2 | 0 | 0 | 0 | | |
| 48 | +| modules.usr.mapper.UsrUserMapperPageTest | 3 | 0 | 0 | 0 | | |
| 49 | +| modules.usr.service.UsrAuthServiceImplTest | 9 | 0 | 0 | 0 | | |
| 50 | +| modules.usr.service.UsrUserServiceImplTest | 24 | 0 | 0 | 0 | | |
| 51 | +| modules.usr.UsrLoginIT | 12 | 0 | 0 | 0 | | |
| 52 | +| modules.usr.UsrUserCreateIT | 5 | 0 | 0 | 0 | | |
| 53 | +| modules.usr.UsrUserQueryIT | 13 | 0 | 0 | 0 | | |
| 54 | +| modules.usr.UsrUserUpdateIT | 6 | 0 | 0 | 0 | | |
| 55 | +| modules.usr.vo.CompanyOptionVOJsonTest | 1 | 0 | 0 | 0 | | |
| 56 | +| modules.usr.vo.LoginVOJsonTest | 1 | 0 | 0 | 0 | | |
| 57 | +| modules.usr.vo.UserVOJsonTest | 1 | 0 | 0 | 0 | | |
| 58 | +| **合计(25 类)** | **125** | **0** | **0** | **0** | | |
| 59 | + | |
| 60 | +## 4. REQ-USR-004 验收覆盖映射(spec § 7 ↔ 测试) | |
| 61 | + | |
| 62 | +REQ-USR-004 专属覆盖落在以下测试类(其余为既有 USR-001/002/003 回归,全绿): | |
| 63 | + | |
| 64 | +- `UsrLoginIT`(12 例,ac1–ac12 端到端):正确凭据登录成功 + 返回 token/user 且不含 sPassword(ac1/11)、token 可被受保护接口接受(ac2/12)、登录更新 tLastLoginDate(ac3)、密码错误统一 40101(ac4)、账号不存在与密码错误响应完全一致(ac5)、禁用用户 40302(ac6)、参数缺失 40001(ac7)、companyId 非法 40001(ac8)、连续失败限流 42901 + 成功清零(ac9)、GET /api/usr/companies 放行全量返回(ac10)、JWT 含 exp(ac12)。 | |
| 65 | +- `UsrAuthServiceImplTest`(9 例):认证主流程顺序判定、防枚举、限流计数、BCrypt 比对、@Transactional 更新登录时间等 Service 单元。 | |
| 66 | +- `UsrAuthControllerTest`(6 例):`POST /api/usr/login` + `GET /api/usr/companies` 校验与委派。 | |
| 67 | +- `LoginDTOValidationTest`(6)/`LoginVOJsonTest`(1)/`CompanyOptionVOJsonTest`(1)/`UsrCompanyMapperTest`(2)/`ResultCodeLoginTest`(2,含 42901)/`SecurityConfigTest`(2,含放行 /api/usr/companies)/`AuthLoginConfigIT`(1,限流配置项 5/300)。 | |
| 68 | + | |
| 69 | +## 5. 结论 | |
| 70 | + | |
| 71 | +- 测试闸:**绿**(exit_code=0,125/125 通过,0 失败 0 错误 0 跳过,BUILD SUCCESS)—— 以项目锁定 JDK 21 运行为权威口径。 | |
| 72 | +- 工作树:commit 前为 clean(仅本证据文件为新增)。 | |
| 73 | +- 唯一风险提示:执行宿主默认 `java` 为越界的 JDK 25,会触发 Mockito 对 `JwtUtil` 的插桩失败(9 error);非代码缺陷,需将默认 JDK 固定为 17/21。 | ... | ... |
docs/superpowers/reviews/2026-06-01-REQ-USR-004.md
0 → 100644
| 1 | +# REQ-USR-004 登录用户 — AI 代码自审(review, round 1) | |
| 2 | + | |
| 3 | +> 关联 spec:`docs/superpowers/specs/2026-06-01-REQ-USR-004.md` | |
| 4 | +> 阶段:后端(backend)。审查维度 = 通用代码审查(正确性 / 边界 / 错误处理 / 一致性 / 分层 / 安全)。 | |
| 5 | +> 裁决:**approve** | |
| 6 | +> 生成时间:2026-06-01。 | |
| 7 | + | |
| 8 | +## 1. 审查范围 | |
| 9 | + | |
| 10 | +本轮针对 `cb7705e..26523d4`(REQ-USR-004 引入的全部 commit)做 diff 级自审,覆盖: | |
| 11 | + | |
| 12 | +- 生产代码:`UsrAuthController` / `UsrAuthService` / `UsrAuthServiceImpl` / `LoginDTO` / `LoginVO` / `CompanyOptionVO` / `UsrCompany` / `UsrCompanyMapper` / `ResultCode`(新增 `42901`)/ `SecurityConfig`(放行 `/api/usr/companies`)/ `application.yml`(jwt + auth.login 配置)。 | |
| 13 | +- 测试代码:`UsrLoginIT`(12 端到端)、`UsrAuthServiceImplTest`(9)、`UsrAuthControllerTest`(6)、`LoginDTOValidationTest`、`LoginVOJsonTest`、`CompanyOptionVOJsonTest`、`UsrCompanyMapperTest`、`ResultCodeLoginTest`、`AuthLoginConfigIT`、`SecurityConfigTest`。 | |
| 14 | + | |
| 15 | +## 2. 裁决依据(逐维度) | |
| 16 | + | |
| 17 | +### 2.1 计划一致性(plan-alignment)— 通过 | |
| 18 | + | |
| 19 | +- 认证判定顺序与 spec § 3 规则 1 / § 8 D3 完全一致:①限流窗(命中 → `42901`)→ ②按 `sUserName(trim)` 查用户,null → `40101` → ③BCrypt 比对,不匹配 → `40101` → ④`iIsVoid=1` → `40302`(先验密码再判禁用)→ ⑤`companyId` 存在性 → `40001` → ⑥签发 JWT + 更新 `tLastLoginDate` + 清零计数。 | |
| 20 | +- 防账号枚举:用户不存在与密码错误均抛 `ResultCode.UNAUTHORIZED`(同码 `40101` 同 message),调用方无法区分(AC4/AC5)。 | |
| 21 | +- 禁用账号不计入失败计数(`recordFailure` 仅在 ②③ 调用,④不调用),避免锁死禁用账号——与 D3 意图一致。 | |
| 22 | +- `tLastLoginDate` 采用「仅 id + 时间」的部分更新(`loginTimeUpdate` 只 set 主键与时间),不触碰其他列(规则 7)。 | |
| 23 | +- 写操作置于 `@Transactional(rollbackFor = Exception.class)`;`listCompanies` 标 `@Transactional(readOnly = true)`(D6 / 规则 10)。 | |
| 24 | + | |
| 25 | +### 2.2 正确性 / 边界 / 错误处理 — 通过 | |
| 26 | + | |
| 27 | +- `getSUserName` 上 `@JsonProperty("sUserName")` 正确锁定契约 JSON 键,规避 Jackson 对匈牙利前缀 getter 的大驼峰推断(与 `CreateUserDTO`/`UserVO` 同做法)。 | |
| 28 | +- `LoginDTO` 三字段 `@NotBlank`/`@NotNull` + `@Size` 校验齐备,失败经 `GlobalExceptionHandler` 统一转 `40001`(AC7)。 | |
| 29 | +- `iIsVoid` 判定带 null 防护(`!= null && == 1`)。 | |
| 30 | +- 限流窗过期自动重置(`isLocked` 内 `lockUntilEpochSec <= now` 时 `attempts.remove`),成功登录 `attempts.remove` 清零(AC9)。 | |
| 31 | +- `companyId` 非法在密码校验通过后才判,归 `40001`(AC8),不泄露认证维度信息。 | |
| 32 | + | |
| 33 | +### 2.3 安全 — 通过 | |
| 34 | + | |
| 35 | +- 密码经 `PasswordEncoder.matches` 比对,无明文存取;`LoginVO` 不含 `sPassword`,无 `SELECT *` 透传实体。 | |
| 36 | +- 密码明文不进日志 / 异常 message(`BusinessException` 仅携带 `ResultCode` 文案)。 | |
| 37 | +- JWT 密钥取自配置(`jwt.secret`,env 注入),令牌含 `exp`(`JwtUtil.generateToken` 设 expiration),claim 含 subject + `sUserType`(AC12 / 规则 4)。 | |
| 38 | +- `SecurityConfig.PERMIT_ALL_PATHS` 追加 `/api/usr/companies`,登录前可访问,且抽常量供单测断言防漂移(AC10)。 | |
| 39 | + | |
| 40 | +### 2.4 分层 / 架构 / 一致性 — 通过 | |
| 41 | + | |
| 42 | +- Controller 仅 `@Valid` + 委派,不碰 Mapper / 业务逻辑;Service 持有逻辑;Mapper 走 MyBatis-Plus `BaseMapper`,参数化查询防注入。 | |
| 43 | +- 错误码集中于 `ResultCode`(新增 `42901 LOGIN_RATE_LIMITED`)。 | |
| 44 | +- 端点路径 / 请求体 / 响应体字段名(`token`、`user{ id, sUserName, sUserType, sLanguage }`)与 `docs/05 § REQ-USR-004` 一致。 | |
| 45 | +- 包路径符合 spec § 4(`modules/usr/{controller,service,service.impl,mapper,entity,dto,vo}`),未跨业务模块。 | |
| 46 | + | |
| 47 | +### 2.5 测试 — 通过 | |
| 48 | + | |
| 49 | +verify 证据(`2026-06-01-REQ-USR-004-verify.md`):JDK 21 下 `mvn -B test` = 125/125 通过、0 失败 / 0 错误 / 0 跳过、BUILD SUCCESS。AC1–AC12 由 `UsrLoginIT` 端到端逐条覆盖,Service / Controller / DTO / VO / Mapper / 安全配置 / 限流配置均有单元或集成断言。 | |
| 50 | + | |
| 51 | +## 3. 非阻塞建议(verbal,不计入 must-fix) | |
| 52 | + | |
| 53 | +1. **限流计数与事务非原子**:`recordFailure` / `attempts.remove` 为进程内内存副作用,不随 `@Transactional` 回滚。但失败计数仅在抛异常路径(②③)发生,此时事务内无其他已提交副作用;成功路径的 `attempts.remove` 在更新提交前执行,若 `tLastLoginDate` 更新回滚则计数已被清零——属可接受的极小窗口,且 D7 已声明为 MVP 限制(后续可平滑替换 Redis)。建议后续将限流移出事务方法或改为 Redis 原子计数。 | |
| 54 | +2. **契约反向同步**:`42901` 限流码与 `GET /api/usr/companies` 端点尚未补入 `docs/05`,spec § 8 D1/D7 已将其列为非强制反向同步建议,建议择机补齐保持 SSoT 完整。 | |
| 55 | +3. **限流维度**:当前按 `sUserName` 计数,分布式部署下进程内计数不共享;与 D7 一致,留待 Redis 化时一并处理。 | |
| 56 | + | |
| 57 | +以上均为改进性建议,无客观可定位缺陷,不构成 must-fix。 | |
| 58 | + | |
| 59 | +## 4. 结论 | |
| 60 | + | |
| 61 | +- 计划一致性 / 正确性 / 边界 / 错误处理 / 安全 / 分层 / 一致性 / 测试 全部通过。 | |
| 62 | +- 未发现客观、可验证的阻断性缺陷。 | |
| 63 | +- 裁决:**approve**,`issues = []`。 | ... | ... |
docs/superpowers/specs/2026-06-01-REQ-USR-001.md
0 → 100644
| 1 | +# REQ-USR-001 增加用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-001.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`。 | |
| 5 | +> 本规格只消费已锁定事实,忽略 UI 描述(控件类型/按钮位置/布局),但校验规则与业务规则全部下沉到后端 DTO + Service。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Goal(目标) | |
| 10 | + | |
| 11 | +后台管理员新建用户账号:指定用户名、密码(默认初始化)、用户类型、语言、可选关联职员、可选权限组授权,保存后账号立即生效可用。对外仅提供一个端点:`POST /api/usr/users`。 | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## 2. 输入 / 输出 | |
| 16 | + | |
| 17 | +### 2.1 输入(请求) | |
| 18 | + | |
| 19 | +- **Method / Path**:`POST /api/usr/users` | |
| 20 | +- **Auth**:需要 Bearer JWT,且调用方必须为管理员 / 超级管理员(`sUserType = 超级管理员`)。普通用户调用返回 `40301`。 | |
| 21 | +- **请求体(JSON)→ `CreateUserDTO`**: | |
| 22 | + | |
| 23 | +| DTO 字段 | 类型 | 必填 | 校验 | 落库列(`usr_user`) | 说明 | | |
| 24 | +|---|---|---|---|---|---| | |
| 25 | +| `sUserName` | String | 是 | `@NotBlank` + `@Pattern(regexp="^[A-Za-z0-9_]{3,20}$")` | `sUserName` | 登录账号,3-20 位字母/数字/下划线,全局唯一 | | |
| 26 | +| `sUserNo` | String | 否 | `@Size(max=50)` | `sUserNo` | 用户号,可由前端在选择职员后带出(后端按传入值落库;为空则存 null) | | |
| 27 | +| `iEmployeeId` | Integer | 否 | — | `iEmployeeId` | 关联职员 ID;传入时必须为 `usr_employee` 中存在的记录,否则 `40001` | | |
| 28 | +| `sUserType` | String | 是 | `@NotBlank` + 取值 ∈ {`普通用户`,`超级管理员`},默认 `普通用户` | `sUserType` | 用户类型;前端未传时由 Service 兜底为 `普通用户` | | |
| 29 | +| `sLanguage` | String | 是 | `@NotBlank` + 取值 ∈ {`中文`,`英文`,`繁体`} | `sLanguage` | 界面语言 | | |
| 30 | +| `iCanModifyBill` | Integer | 否 | 取值 ∈ {0,1},默认 0 | `iCanModifyBill` | 单据修改权限 | | |
| 31 | +| `permissionIds` | List\<Integer\> | 否 | 元素须为 `usr_permission` 中存在的 id(去重) | 写 `usr_user_permission` | 权限组勾选;为空 / null 表示不授权 | | |
| 32 | +| `initialPassword` | String | 否 | `@Size(max=100)`,默认 `666666` | `sPassword`(BCrypt 哈希) | 初始密码;未传时取默认 `666666`,BCrypt 哈希后入库 | | |
| 33 | + | |
| 34 | +> 系统生成字段(不接受前端传入,由后端填充):`tCreateDate`=当前时间;`sCreator`=当前登录用户(从 JWT/SecurityContext 取 `sUserName`);`sPassword`=BCrypt(initialPassword);`iIsVoid`=0(新建即生效);`sBrandsId`/`sSubsidiaryId`=表默认值 `1111111111`(标准列默认,多租户隔离)。`tLastLoginDate` 保持 null。 | |
| 35 | + | |
| 36 | +### 2.2 输出(响应) | |
| 37 | + | |
| 38 | +- 成功:`Result<{ id: number }>`,`code=0`,`data.id` = 新建用户主键 `usr_user.iIncrement`。 | |
| 39 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈。 | |
| 40 | + | |
| 41 | +--- | |
| 42 | + | |
| 43 | +## 3. 业务规则 | |
| 44 | + | |
| 45 | +1. **用户名全局唯一**:插入前按 `sUserName` 查重(命中唯一索引 `uk_usr_user_username`),存在则抛 `BusinessException(40901)`。即便并发命中唯一约束(`DuplicateKeyException`),也统一转换为 `40901`。 | |
| 46 | +2. **密码初始化 + 哈希存储**:`initialPassword` 缺省为 `666666`;一律经 `BCryptPasswordEncoder` 哈希后写入 `sPassword`,**禁止明文落库 / 进日志 / 进响应**。响应与任何查询均不返回密码。 | |
| 47 | +3. **用户类型默认与约束**:`sUserType` 缺省 `普通用户`;取值仅限 {`普通用户`,`超级管理员`},越界 `40001`。 | |
| 48 | +4. **语言取值约束**:`sLanguage` 仅限 {`中文`,`英文`,`繁体`}(依据 docs/03 业务含义与 REQ 卡片下拉来源),越界 `40001`。默认值见 § 8 决策记录。 | |
| 49 | +5. **关联职员可选且需存在**:传 `iEmployeeId` 时校验 `usr_employee` 存在;不存在 `40001`。职员删除时外键 `ON DELETE SET NULL`,与本 REQ 写入无冲突。 | |
| 50 | +6. **权限组授权(多对多)**:`permissionIds` 非空时,校验每个 id 在 `usr_permission` 存在(不存在 `40001`),去重后为新用户在 `usr_user_permission` 批量插入 `(iUserId, iPermissionId)` 行;唯一索引 `uk_usr_user_permission` 防重复授权。 | |
| 51 | +7. **新建即生效**:`iIsVoid=0`,无需额外激活流程,保存后即可登录(登录由 REQ-USR-004 负责)。 | |
| 52 | +8. **制单人 / 创建时间审计**:`sCreator` 取当前登录用户名,`tCreateDate` 取当前时间;按 docs/04 § 3.4 可由 `BaseEntity` + MP 自动填充,或在 Service 显式赋值(实现二选一,结果一致)。 | |
| 53 | +9. **权限校验前置**:仅 `超级管理员` / 管理员可调用;非管理员 `40301`(在 Spring Security 或 Service 入口判定,先于业务校验)。 | |
| 54 | + | |
| 55 | +--- | |
| 56 | + | |
| 57 | +## 4. 约束(技术 / 安全) | |
| 58 | + | |
| 59 | +- **分层**(docs/04 § 1.2):`UsrUserController`(仅 `@Valid` 校验 + 委派)→ `UsrUserService` / `UsrUserServiceImpl`(业务)→ `UsrUserMapper` / `UsrUserPermissionMapper`(MyBatis-Plus)。Controller 禁止直接操作 Mapper。 | |
| 60 | +- **包路径**:`com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}`。本 REQ 仅触及 `modules/usr/**`,不跨模块。 | |
| 61 | +- **命名**(docs/04 § 1.3):类 `UsrUserController` / `UsrUserServiceImpl` / `UsrUserMapper`;方法 `createUser`;REST 路径 `/api/usr/users`。 | |
| 62 | +- **统一响应**(docs/04 § 1.4):返回 `Result<T>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举。 | |
| 63 | +- **异常处理**(docs/04 § 1.5):业务错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 校验失败由全局处理器转 `40001`。 | |
| 64 | +- **事务**(docs/04 § 1.6):`createUser` 涉及 `usr_user` + `usr_user_permission` 多表写,方法上加 `@Transactional(rollbackFor = Exception.class)`,任一步失败整体回滚。 | |
| 65 | +- **认证 / 密码**(docs/04 § 1.7):受保护接口经 `JwtAuthenticationFilter`;密码用 `BCryptPasswordEncoder`,禁明文。 | |
| 66 | +- **数据访问**(docs/04 § 3.4):只走 Mapper;查重 / 校验用 `LambdaQueryWrapper` 或 MP 内置。 | |
| 67 | +- **配置**:JWT 密钥、DB 凭据只从 `config-vars.yaml` / `application.yml` 读取,不硬编码。`admin_init`(admin/666666)为系统初始管理员,与本 REQ 默认密码 `666666` 一致。 | |
| 68 | +- **schema**:本 REQ 所需表(`usr_user` / `usr_employee` / `usr_permission` / `usr_user_permission`)已由 `sql/migrations/V1__initial_schema.sql` 建好,**无需新增 migration**。 | |
| 69 | + | |
| 70 | +--- | |
| 71 | + | |
| 72 | +## 5. Schema 引用(docs/03 SSoT) | |
| 73 | + | |
| 74 | +- **写**:`usr_user`(主键 `iIncrement`;唯一索引 `uk_usr_user_username` on `sUserName`;列见 docs/03 § usr_user)。 | |
| 75 | +- **写**:`usr_user_permission`(关联表;唯一索引 `uk_usr_user_permission` on `(iUserId,iPermissionId)`;外键 `fk_usr_up_user` / `fk_usr_up_permission` 均 CASCADE)。 | |
| 76 | +- **读**:`usr_employee`(校验 `iEmployeeId` 存在);`usr_permission`(校验 `permissionIds` 存在)。 | |
| 77 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrUserPermission` ↔ `usr_user_permission`,`UsrEmployee` ↔ `usr_employee`,`UsrPermission` ↔ `usr_permission`(匈牙利前缀列名,实体字段与列名映射保持一致)。 | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 82 | + | |
| 83 | +- 端点契约见 `docs/05-API接口契约.md` § REQ-USR-001。 | |
| 84 | +- 错误码: | |
| 85 | + - `40001` — 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 | |
| 86 | + - `40901` — 用户名已存在(`sUserName` 唯一冲突)。 | |
| 87 | + - `40301` — 无权限(非管理员调用)。 | |
| 88 | + - `0` — 成功,返回 `data.id`。 | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +## 7. 验收标准(Acceptance Criteria) | |
| 93 | + | |
| 94 | +1. **正常新增**:管理员携带合法 body(`sUserName` 合规、`sUserType`/`sLanguage` 合法)调用 `POST /api/usr/users` → `code=0`,`data.id` 为正整数;`usr_user` 新增一行,`sPassword` 为 BCrypt 哈希(非明文,可被 `BCryptPasswordEncoder.matches("666666", hash)` 校验通过),`iIsVoid=0`,`sCreator`=调用者用户名,`tCreateDate` 已填。 | |
| 95 | +2. **重复用户名**:以已存在的 `sUserName` 调用 → `code=40901`,无新增行(事务回滚)。 | |
| 96 | +3. **参数非法**:`sUserName` 不满足 3-20 位字母数字下划线 / `sUserType` 或 `sLanguage` 越界 / `iEmployeeId` 或 `permissionIds` 引用不存在的 id → `code=40001`,无副作用。 | |
| 97 | +4. **权限组授权**:传 `permissionIds=[a,b]`(均存在)→ `usr_user_permission` 新增 2 行 `(newUserId, a/b)`;重复 id 去重后仅写一次。 | |
| 98 | +5. **越权访问**:普通用户(`sUserType=普通用户`)或无 / 失效 token 调用 → `code=40301`(无权限)/ 401(未认证),不创建记录。 | |
| 99 | +6. **默认密码**:不传 `initialPassword` 时,按 `666666` 哈希入库;可用 `666666` 经后续登录流程通过。 | |
| 100 | +7. **响应不含密码**:成功响应体仅含 `data.id`,绝不含 `sPassword` 或明文密码。 | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## 8. 自主决策记录(decisions) | |
| 105 | + | |
| 106 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 107 | +|---|---|---|---|---| | |
| 108 | +| D1 | `sLanguage` 默认值(REQ 卡片标"无默认",docs/03 标默认 `中文` 且带"需用户审阅"占位) | 后端**不强制默认**,`sLanguage` 为必填、必须由请求显式传入合法值;若 DB 层因 NOT NULL DEFAULT `中文` 而存在兜底,仅作为防御性约束,不作为业务默认 | REQ 卡片「语言 必填=是、默认值=—」优先表达业务意图;docs/03 的 `中文` 默认带审阅占位,不作为锁定事实。取必填可避免歧义 | medium | | |
| 109 | +| D2 | 管理员判定口径 | 以 `sUserType=超级管理员` 视为有调用权限;普通用户禁止。具体角色映射若后续有 Spring Security 角色体系再细化 | docs/05 标注"仅管理员/超级管理员可调用",本库用户类型枚举仅 {普通用户,超级管理员},无独立管理员角色表 | medium | | |
| 110 | +| D3 | `sUserNo` 来源 | 后端按请求传入值落库(前端在选择职员后带出员工编号/姓名填入),后端不主动从职员表反查覆盖 | REQ 卡片业务规则"关联职员选择后自动输入"属前端交互;契约 `sUserNo(可选)` 直接接收,后端阶段不重复实现前端联动 | high | | |
| 111 | +| D4 | 不新增 migration | 复用 `V1__initial_schema.sql` 已建的 4 张表 | 4 张依赖表(usr_user 写、usr_employee/usr_permission 读、usr_user_permission 写)均已在 V1 建好,结构与 docs/03 一致,无 schema 变更 | high | | |
| 112 | +| D5 | 密码字段命名 | 请求体用 `initialPassword`,落库列 `sPassword`(BCrypt) | 与 docs/05 契约一致(`initialPassword(可选,默认 666666)`);docs/04 § 1.7 要求 BCrypt | high | | |
| 113 | + | |
| 114 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本后端 REQ 作用域内消解;本规格按上表 D1 取最有依据解读继续,不阻塞。 | ... | ... |
docs/superpowers/specs/2026-06-01-REQ-USR-002.md
0 → 100644
| 1 | +# REQ-USR-002 修改用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-002.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`。 | |
| 5 | +> 本规格只消费已锁定事实,忽略 UI 描述(控件类型/按钮位置/布局/预加载/下拉数据源),但校验规则与业务规则全部下沉到后端 DTO + Service。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Goal(目标) | |
| 10 | + | |
| 11 | +后台管理员修改已有用户的基本信息:用户号、关联职员、用户类型、语言、单据修改权限、作废(禁用)标志、权限组授权;保存后变更立即生效(角色/状态实时反映在用户列表,禁用账号无法登录)。对外仅提供一个端点:`PUT /api/usr/users/{id}`。 | |
| 12 | + | |
| 13 | +`sUserName`(登录账号,全局唯一标识)不可修改;密码不在本接口修改(见 § 8 D1)。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 2. 输入 / 输出 | |
| 18 | + | |
| 19 | +### 2.1 输入(请求) | |
| 20 | + | |
| 21 | +- **Method / Path**:`PUT /api/usr/users/{id}` | |
| 22 | +- **路径参数**:`id`(`Integer`,目标用户主键 `usr_user.iIncrement`);必须为正整数,对应记录须存在,否则 `40401`。 | |
| 23 | +- **Auth**:需要 Bearer JWT,且调用方必须为管理员 / 超级管理员(`sUserType = 超级管理员`)。普通用户调用返回 `40301`(先于业务校验判定)。 | |
| 24 | +- **请求体(JSON)→ `UpdateUserDTO`**: | |
| 25 | + | |
| 26 | +| DTO 字段 | 类型 | 必填 | 校验 | 落库列(`usr_user`) | 说明 | | |
| 27 | +|---|---|---|---|---|---| | |
| 28 | +| `sUserNo` | String | 否 | `@Size(max=50)` | `sUserNo` | 用户号;传入即覆盖,为 null 不更新该列(见 § 8 D3 部分更新语义) | | |
| 29 | +| `iEmployeeId` | Integer | 否 | — | `iEmployeeId` | 关联职员 ID;非 null 时须为 `usr_employee` 中存在的记录,否则 `40001` | | |
| 30 | +| `sUserType` | String | 是 | `@NotBlank` + 取值 ∈ {`普通用户`,`超级管理员`} | `sUserType` | 用户类型;越界 `40001` | | |
| 31 | +| `sLanguage` | String | 是 | `@NotBlank` + 取值 ∈ {`中文`,`英文`,`繁体`} | `sLanguage` | 界面语言;越界 `40001` | | |
| 32 | +| `iCanModifyBill` | Integer | 否 | 取值 ∈ {0,1} | `iCanModifyBill` | 单据修改权限;为 null 不更新该列 | | |
| 33 | +| `iIsVoid` | Integer | 否 | 取值 ∈ {0,1} | `iIsVoid` | 作废 / 禁用标志:0 正常 / 1 禁用;为 null 不更新该列(见 § 8 D2) | | |
| 34 | +| `permissionIds` | List\<Integer\> | 否 | 元素须为 `usr_permission` 中存在的 id(去重) | 重写 `usr_user_permission` | 权限组勾选的全量集合(见 § 8 D4 全量覆盖语义);为 null 表示不改动现有授权 | | |
| 35 | + | |
| 36 | +> 不接受前端传入 / 后端忽略的字段:`sUserName`(唯一标识不可改)、`sPassword`(密码不在本接口修改)、`tCreateDate` / `sCreator`(创建审计只读,保持原值)、`tLastLoginDate`(由登录流程维护)、`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId`(主键与租户列不在本接口变更)。若请求体携带这些字段,一律忽略不落库。 | |
| 37 | + | |
| 38 | +### 2.2 输出(响应) | |
| 39 | + | |
| 40 | +- 成功:`Result<{ id: number }>`,`code=0`,`data.id` = 被修改用户主键 `usr_user.iIncrement`(= 路径 `id`)。 | |
| 41 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈。 | |
| 42 | +- 响应不返回密码 / 任何敏感字段(仅含 `data.id`)。 | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +## 3. 业务规则 | |
| 47 | + | |
| 48 | +1. **目标用户必须存在**:按路径 `id` 查 `usr_user`;查不到(或已物理不存在)抛 `BusinessException(40401)`,不做任何写入。 | |
| 49 | +2. **`sUserName` 不可修改**:本接口不接收 / 不更新 `sUserName`;即便请求体携带也忽略。登录账号唯一性不在本接口被破坏。 | |
| 50 | +3. **密码不在本接口修改**:`sPassword` 列保持原值不动;本接口不读取、不重置、不返回密码(密码重置/初始化由其他流程负责,REQ 卡片表内"密码自动初始化"属前端/独立流程描述,见 § 8 D1)。 | |
| 51 | +4. **用户类型约束**:`sUserType` 必填,取值仅限 {`普通用户`,`超级管理员`},越界 `40001`。角色变更需调用方具备管理员权限(已由 § 3 规则 9 的前置权限校验保证)。 | |
| 52 | +5. **语言取值约束**:`sLanguage` 必填,取值仅限 {`中文`,`英文`,`繁体`},越界 `40001`。 | |
| 53 | +6. **关联职员可选且需存在**:传非 null `iEmployeeId` 时校验 `usr_employee` 存在;不存在 `40001`。显式传 null 表示解除关联(置空 `iEmployeeId`)的处理见 § 8 D3。 | |
| 54 | +7. **作废 / 禁用实时生效**:`iIsVoid=1` 落库后该账号即不可登录(登录由 REQ-USR-004 校验 `iIsVoid`,命中返回 `40302`),无需额外流程;`iIsVoid=0` 恢复正常。取值仅限 {0,1},越界 `40001`。 | |
| 55 | +8. **权限组授权(多对多,全量覆盖)**:`permissionIds` 非 null 时,先校验每个 id 在 `usr_permission` 存在(不存在 `40001`),去重后**以该集合全量覆盖**该用户在 `usr_user_permission` 的授权——删除不在集合内的旧授权、插入新增授权(实现可"先删后插"或差量更新,结果须等于目标集合);空数组 `[]` 表示清空全部授权;`permissionIds` 为 null 表示本次不改动授权。唯一索引 `uk_usr_user_permission` 防重复。 | |
| 56 | +9. **权限校验前置**:仅 `超级管理员` / 管理员可调用;非管理员 `40301`(在 Spring Security 或 Service 入口判定,先于业务校验)。 | |
| 57 | +10. **审计字段只读**:`sCreator` / `tCreateDate` 保持原值,本接口不更新;不引入"最后修改人/时间"列(docs/03 `usr_user` 无此列,不新增 migration)。 | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 4. 约束(技术 / 安全) | |
| 62 | + | |
| 63 | +- **分层**(docs/04 § 1.2):`UsrUserController`(仅 `@Valid` 校验 + 委派)→ `UsrUserService` / `UsrUserServiceImpl`(业务)→ `UsrUserMapper` / `UsrUserPermissionMapper`(MyBatis-Plus)。Controller 禁止直接操作 Mapper。本 REQ 在已存在的 `UsrUserService` 上新增 `updateUser(Integer id, UpdateUserDTO dto)` 方法,复用 REQ-USR-001 已建的 controller / service / mapper / entity,不另起类。 | |
| 64 | +- **包路径**:`com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}`;新增 DTO `UpdateUserDTO` 置于 `modules/usr/dto`。本 REQ 仅触及 `modules/usr/**`,不跨模块。 | |
| 65 | +- **命名**(docs/04 § 1.3):方法 `updateUser`;REST 路径 `PUT /api/usr/users/{id}`。 | |
| 66 | +- **统一响应**(docs/04 § 1.4):返回 `Result<T>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举。 | |
| 67 | +- **异常处理**(docs/04 § 1.5):业务错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 校验失败由全局处理器转 `40001`。 | |
| 68 | +- **事务**(docs/04 § 1.6):`updateUser` 涉及 `usr_user` + `usr_user_permission` 多表写,方法上加 `@Transactional(rollbackFor = Exception.class)`,任一步失败整体回滚(权限覆盖与主记录更新同事务)。 | |
| 69 | +- **认证 / 密码**(docs/04 § 1.7):受保护接口经 `JwtAuthenticationFilter`;本接口不触碰密码,更不得将密码写入日志 / 响应。 | |
| 70 | +- **数据访问**(docs/04 § 3.4):只走 Mapper;查询 / 校验 / 删授权用 `LambdaQueryWrapper` 或 MP 内置;禁止 Controller 直接操作 Mapper。 | |
| 71 | +- **配置**:JWT 密钥、DB 凭据只从 `config-vars.yaml` / `application.yml` 读取,不硬编码。 | |
| 72 | +- **schema**:本 REQ 所需表(`usr_user` 写 / `usr_employee` 读 / `usr_permission` 读 / `usr_user_permission` 写)已由 `sql/migrations/V1__initial_schema.sql` 建好,结构与 docs/03 一致,**无需新增 migration**(见 § 8 D5)。 | |
| 73 | + | |
| 74 | +--- | |
| 75 | + | |
| 76 | +## 5. Schema 引用(docs/03 SSoT) | |
| 77 | + | |
| 78 | +- **写**:`usr_user`(主键 `iIncrement`;更新 `sUserNo` / `iEmployeeId` / `sUserType` / `sLanguage` / `iCanModifyBill` / `iIsVoid`;不更新 `sUserName` / `sPassword` / 审计列 / 租户列)。 | |
| 79 | +- **写**:`usr_user_permission`(关联表;唯一索引 `uk_usr_user_permission` on `(iUserId,iPermissionId)`;外键 `fk_usr_up_user` / `fk_usr_up_permission` 均 CASCADE)——按 `iUserId=id` 全量覆盖授权。 | |
| 80 | +- **读**:`usr_employee`(校验 `iEmployeeId` 存在,外键 `fk_usr_user_employee` ON DELETE SET NULL);`usr_permission`(校验 `permissionIds` 存在)。 | |
| 81 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrUserPermission` ↔ `usr_user_permission`,`UsrEmployee` ↔ `usr_employee`,`UsrPermission` ↔ `usr_permission`(匈牙利前缀列名,实体字段与列名映射保持一致)。 | |
| 82 | + | |
| 83 | +--- | |
| 84 | + | |
| 85 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 86 | + | |
| 87 | +- 端点契约见 `docs/05-API接口契约.md` § REQ-USR-002(`PUT /api/usr/users/{id}`)。 | |
| 88 | +- 错误码: | |
| 89 | + - `40001` — 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id(`iEmployeeId` / `permissionIds` 元素)不存在 / `id` 非正整数)。 | |
| 90 | + - `40401` — 用户不存在(路径 `id` 无对应 `usr_user` 记录)。 | |
| 91 | + - `40301` — 无权限(非管理员调用)。 | |
| 92 | + - `0` — 成功,返回 `data.id`。 | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## 7. 验收标准(Acceptance Criteria) | |
| 97 | + | |
| 98 | +1. **正常修改基本信息**:管理员对已存在用户 `id` 携带合法 body(`sUserType` / `sLanguage` 合法)调用 `PUT /api/usr/users/{id}` → `code=0`,`data.id` = 该 `id`;`usr_user` 对应行的 `sUserType` / `sLanguage` / `iCanModifyBill` / `sUserNo` / `iEmployeeId` 按传入值更新,`sUserName` / `sPassword` / `sCreator` / `tCreateDate` 保持原值不变。 | |
| 99 | +2. **用户不存在**:以不存在的 `id` 调用 → `code=40401`,无任何写入。 | |
| 100 | +3. **参数非法**:`sUserType` 或 `sLanguage` 越界 / `iEmployeeId` 引用不存在职员 / `permissionIds` 含不存在权限 id / `iIsVoid` 或 `iCanModifyBill` 非 0/1 → `code=40001`,无副作用(事务回滚)。 | |
| 101 | +4. **禁用实时生效**:传 `iIsVoid=1` 修改成功后,该账号经 REQ-USR-004 登录返回 `40302`(账号已禁用);改回 `iIsVoid=0` 后可正常登录。 | |
| 102 | +5. **角色变更实时生效**:修改 `sUserType` 成功后,REQ-USR-003 查询该用户返回的 `sUserType` 即为新值。 | |
| 103 | +6. **权限组全量覆盖**:传 `permissionIds=[a,b]`(均存在,原授权为 `[a,c]`)→ 覆盖后 `usr_user_permission` 中该用户授权恰为 `{a,b}`(删除 c、保留/插入 a、新增 b);传 `permissionIds=[]` → 清空该用户全部授权;不传 `permissionIds`(null)→ 现有授权保持不变;重复 id 去重后仅写一次。 | |
| 104 | +7. **越权访问**:普通用户或无 / 失效 token 调用 → `code=40301`(无权限)/ 401(未认证),不发生任何修改。 | |
| 105 | +8. **密码不变 & 响应不含密码**:调用前后 `sPassword` 列字节级不变(用原密码仍可登录);成功响应体仅含 `data.id`,绝不含 `sPassword` 或明文密码。 | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## 8. 自主决策记录(decisions) | |
| 110 | + | |
| 111 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 112 | +|---|---|---|---|---| | |
| 113 | +| D1 | REQ 卡片输入表把「密码」标为"保存后自动设为初始化",与 docs/05 契约"密码不在本接口修改"冲突 | 后端**不在本接口修改/重置密码**,`sPassword` 保持原值;body 不含密码字段 | docs/05 契约(SSoT,明确"密码不在本接口修改")+ REQ 卡片「跨字段规则: 密码不在该接口修改」明文一致,权重高于输入表内"系统生成"备注(该备注属新增场景遗留/前端描述) | high | | |
| 114 | +| D2 | 「状态/禁用」字段:REQ 目标与验收提到"状态""被禁用账号无法登录",但 REQ 输入表 1 未显式列出状态字段 | 用 `usr_user.iIsVoid`(0 正常/1 禁用)承载"状态",作为可选入参 `iIsVoid` | docs/03 `usr_user.iIsVoid` 即"作废/禁用标志,禁用后不可登录";docs/05 REQ-USR-002 请求体已显式包含 `iIsVoid`;与验收"禁用账号无法登录"语义吻合 | high | | |
| 115 | +| D3 | 部分更新语义:可选字段(`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid`)传 null 时是"置空列"还是"不更新" | 采用**不更新(保持原值)**语义:null 表示本次不改动该列;不提供显式"清空"区分(JSON null 与缺省不区分),与权限 `permissionIds=null` 不改动一致 | REST PUT 在本契约场景按"提交需修改的字段"实践更安全,避免误把未填字段清空原有数据;契约未要求区分"置空 vs 不传",取保守不更新可避免数据丢失。前端编辑表单预加载原值后整体回填,行为等价 | medium | | |
| 116 | +| D4 | `permissionIds` 更新语义(增量追加 vs 全量覆盖) | **全量覆盖**:以传入集合替换该用户全部授权;`[]` 清空;null 不改动 | REQ 卡片表 2「权限组」复选框预加载原值、提交即当前勾选全集,语义为"这一组就是最终授权";全量覆盖与编辑界面"勾选即最终态"一致,避免无法取消授权 | high | | |
| 117 | +| D5 | 是否新增 migration | 复用 `V1__initial_schema.sql` 已建表,不新增 | 写 `usr_user` / `usr_user_permission`、读 `usr_employee` / `usr_permission` 均已在 V1 建好,结构与 docs/03 一致,本 REQ 无 schema 变更(也不新增"最后修改人/时间"列,docs/03 未定义) | high | | |
| 118 | +| D6 | 管理员判定口径 | 与 REQ-USR-001 一致:以 `sUserType=超级管理员` 视为有调用权限;普通用户禁止 | docs/05 标注"仅管理员/超级管理员可调用";本库用户类型枚举仅 {普通用户,超级管理员},无独立角色表;保持模块内一致 | medium | | |
| 119 | + | |
| 120 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本后端 REQ 作用域内消解;本规格沿用 REQ-USR-001 的取值锁定(语言 ∈ {中文,英文,繁体})继续,不阻塞。 | ... | ... |
docs/superpowers/specs/2026-06-01-REQ-USR-003.md
0 → 100644
| 1 | +# REQ-USR-003 查询用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`。 | |
| 5 | +> 本规格只消费已锁定事实,忽略 UI 描述(控件类型/下拉数据源/列表布局/序号渲染),但查询条件、匹配规则、分页规则全部下沉到后端 DTO + Service。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Goal(目标) | |
| 10 | + | |
| 11 | +后台用户可按单一条件(用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 作废 / 登录日期 / 制单人)配合匹配方式(包含 / 不包含 / 等于)筛选并分页浏览用户列表。查询为**只读**,不产生任何写副作用;密码与敏感字段绝不返回。对外仅提供一个端点:`GET /api/usr/users`。 | |
| 12 | + | |
| 13 | +输出列含跨表字段「员工名」「部门」,来源 `usr_employee`,按 `usr_user.iEmployeeId` 左关联取得(用户未关联职员时该两列为 null)。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 2. 输入 / 输出 | |
| 18 | + | |
| 19 | +### 2.1 输入(请求) | |
| 20 | + | |
| 21 | +- **Method / Path**:`GET /api/usr/users` | |
| 22 | +- **Auth**:需要 Bearer JWT(任意已认证用户可调用,无管理员限制——查询为只读,docs/05 标注 Auth「需要(Bearer JWT)」未附加角色约束,见 § 8 D5)。无 / 失效 token → 401。 | |
| 23 | +- **Query 参数 → `UserQueryDTO`**(全部可选,空条件返回全量分页): | |
| 24 | + | |
| 25 | +| DTO 字段 | 类型 | 必填 | 默认 | 校验 | 说明 | | |
| 26 | +|---|---|---|---|---|---| | |
| 27 | +| `queryField` | String | 否 | `用户名` | 取值 ∈ {`用户名`,`员工名`,`用户号`,`部门`,`用户类型`,`作废`,`登录日期`,`制单人`},越界 `40001` | 查询字段;为空时按默认 `用户名` 解释(仅当 `queryValue` 非空才生效) | | |
| 28 | +| `matchType` | String | 否 | `包含` | 取值 ∈ {`包含`,`不包含`,`等于`},越界 `40001` | 匹配方式;对枚举/外键/日期型字段仅 `等于` 有效(见 § 3 规则 5) | | |
| 29 | +| `queryValue` | String | 否 | — | `@Size(max=100)` | 查询值;为空 / null 表示不施加该条件(选择全部,见 § 3 规则 2) | | |
| 30 | +| `pageNum` | Integer | 否 | `1` | ≥ 1,否则 `42201` | 页码,从 1 起 | | |
| 31 | +| `pageSize` | Integer | 否 | `10` | 1 ≤ pageSize ≤ 100,否则 `42201` | 每页条数;上限 100(docs/04 § 3.2、docs/05 REQ-USR-003) | | |
| 32 | + | |
| 33 | +> 不接受 / 忽略的入参:任何写类字段、密码、租户列覆盖等本接口均不接收(GET 只读)。多条件组合不在本 REQ 范围(卡片输入表为单 `queryField` + 单 `matchType` + 单 `queryValue`,见 § 8 D2)。 | |
| 34 | + | |
| 35 | +### 2.2 输出(响应) | |
| 36 | + | |
| 37 | +- 成功:`Result<PageResult<UserVO>>`,`code=0`。 | |
| 38 | +- `PageResult<T>`(docs/04 § 1.4 / § 3.2):`{ records: UserVO[], total: number, pageNum: number, pageSize: number }`。 | |
| 39 | +- `UserVO`(docs/05 REQ-USR-003 契约 + 卡片输出表 1,**不含 `sPassword` 及任何敏感字段**): | |
| 40 | + | |
| 41 | +| VO 字段 | 类型 | 来源 | 卡片输出列 | 说明 | | |
| 42 | +|---|---|---|---|---| | |
| 43 | +| `id` | Integer | `usr_user.iIncrement` | 序号映射主键 | 主键 ID;「序号」列由前端按行号渲染,后端只回 `id`(见 § 8 D4) | | |
| 44 | +| `sUserName` | String | `usr_user.sUserName` | 用户名 | 登录账号 | | |
| 45 | +| `employeeName` | String | `usr_employee.sEmployeeName` | 员工名 | 左关联,未关联职员为 null | | |
| 46 | +| `sUserNo` | String | `usr_user.sUserNo` | 用户号 | | | |
| 47 | +| `department` | String | `usr_employee.sDepartment` | 部门 | 左关联,未关联职员为 null | | |
| 48 | +| `sUserType` | String | `usr_user.sUserType` | 用户类型 | | | |
| 49 | +| `sLanguage` | String | `usr_user.sLanguage` | 语言 | | | |
| 50 | +| `iIsVoid` | Integer | `usr_user.iIsVoid` | 作废 | 0 正常 / 1 已作废(布尔语义,回传 0/1) | | |
| 51 | +| `tLastLoginDate` | DateTime | `usr_user.tLastLoginDate` | 登录日期 | 可为 null(从未登录) | | |
| 52 | +| `sCreator` | String | `usr_user.sCreator` | 制单人 | | | |
| 53 | +| `tCreateDate` | DateTime | `usr_user.tCreateDate` | 制单日期 | | | |
| 54 | + | |
| 55 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈。 | |
| 56 | +- 响应**绝不返回** `sPassword` 或任何敏感字段(VO 不含密码列;不得 `SELECT *` 透传实体)。 | |
| 57 | + | |
| 58 | +--- | |
| 59 | + | |
| 60 | +## 3. 业务规则 | |
| 61 | + | |
| 62 | +1. **只读无副作用**:本接口仅 `SELECT`,不写任何表、不更新 `tLastLoginDate`、不落审计;方法可标 `@Transactional(readOnly = true)`(可选)。 | |
| 63 | +2. **空条件返回全量分页**:`queryValue` 为空 / null(trim 后为空串亦视为空)时不施加业务过滤条件,仅按分页返回全部用户;此时 `queryField` / `matchType` 不生效。 | |
| 64 | +3. **单条件查询**:仅按 `queryField` 指定的单字段 + `matchType` 施加一个过滤条件(卡片输入表为单字段单匹配,非多条件 AND/OR 组合,见 § 8 D2)。 | |
| 65 | +4. **字段 → 列映射**(`queryField` 中文 → 实际查询列): | |
| 66 | + - `用户名` → `usr_user.sUserName`(文本) | |
| 67 | + - `员工名` → `usr_employee.sEmployeeName`(文本,跨表) | |
| 68 | + - `用户号` → `usr_user.sUserNo`(文本) | |
| 69 | + - `部门` → `usr_employee.sDepartment`(文本,跨表) | |
| 70 | + - `用户类型` → `usr_user.sUserType`(枚举:{普通用户,超级管理员}) | |
| 71 | + - `作废` → `usr_user.iIsVoid`(布尔 0/1) | |
| 72 | + - `登录日期` → `usr_user.tLastLoginDate`(日期时间) | |
| 73 | + - `制单人` → `usr_user.sCreator`(文本) | |
| 74 | +5. **匹配方式语义**(按 `matchType` 与字段类型组合,docs/04 § 3.2「文本条件模糊匹配,枚举/外键条件精确匹配」): | |
| 75 | + - **文本字段**(用户名 / 员工名 / 用户号 / 部门 / 制单人):`包含` → `LIKE '%v%'`;`不包含` → `NOT LIKE '%v%'`;`等于` → `= v`(精确)。`queryValue` 中的 LIKE 通配符 `%` `_` 须转义(见 § 8 D3)。 | |
| 76 | + - **枚举字段**(用户类型):仅 `等于`(`= v`)有业务意义;`包含` 按 `LIKE '%v%'`、`不包含` 按 `NOT LIKE '%v%'` 也允许执行(容错,不报错),但推荐 `等于`。 | |
| 77 | + - **布尔字段**(作废):`queryValue` 须可解析为 0/1(接受 `0`/`1`,亦可接受 `是`/`否`、`true`/`false` 归一化为 1/0,见 § 8 D6);按 `等于` 处理(`= 0/1`);`包含`/`不包含` 退化为 `=`/`<>`。无法解析为 0/1 → `40001`。 | |
| 78 | + - **日期字段**(登录日期):`queryValue` 须为合法日期(`yyyy-MM-dd`)或日期时间(`yyyy-MM-dd HH:mm:ss`);`等于` → 命中该「整天」区间 `[day 00:00:00, day+1 00:00:00)`(仅给到日期时按当日匹配,见 § 8 D6);`包含`/`不包含` 在日期字段无模糊语义,按 `等于` 当日区间 / 取反处理。无法解析 → `40001`。 | |
| 79 | +6. **跨表关联**:员工名 / 部门来自 `usr_employee`,按 `usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**(未关联职员的用户仍出现在结果,员工名 / 部门为 null)。当 `queryField` ∈ {员工名, 部门} 时,过滤施加在 join 后的 `usr_employee` 列上(未关联职员的行因该列为 null,`包含`/`等于` 自然不命中、`不包含` 的 null 处理见 § 8 D7)。 | |
| 80 | +7. **分页规则**: | |
| 81 | + - `pageNum` < 1 或 `pageSize` < 1 或 `pageSize` > 100 → `42201`(参数非法,先于查询判定)。 | |
| 82 | + - `pageNum` 超过实际总页数(数据层「越界」,如请求第 99 页但仅 3 页)→ **返回最后一页**数据(卡片验收「分页参数越界时返回最后一页」),`PageResult.pageNum` 回传被钳制后的实际页号;`total` 仍为真实总数(见 § 8 D1)。total 为 0 时返回空 `records`、`pageNum` 回传 1。 | |
| 83 | + - 默认 `pageNum=1`、`pageSize=10`(docs/05 契约默认)。 | |
| 84 | +8. **空结果**:无匹配记录时返回 `code=0` + `records=[]` + `total=0`,**不报错**(卡片验收「无匹配时返回空列表而非报错」)。 | |
| 85 | +9. **密码与敏感字段不返回**:查询 SQL 不 SELECT `sPassword`;VO 不含密码;多租户列 `sBrandsId`/`sSubsidiaryId`/`sId` 不在输出列(输出列严格按 § 2.2 UserVO)。 | |
| 86 | + | |
| 87 | +--- | |
| 88 | + | |
| 89 | +## 4. 约束(技术 / 安全) | |
| 90 | + | |
| 91 | +- **分层**(docs/04 § 1.2):`UsrUserController`(仅 `@Valid` 校验 + 委派)→ `UsrUserService` / `UsrUserServiceImpl`(业务 + 分页装配)→ `UsrUserMapper`(MyBatis-Plus)。Controller 禁止直接操作 Mapper。本 REQ 在已存在的 `UsrUserService` 上新增 `queryUsers(UserQueryDTO dto)` 方法,复用 REQ-USR-001/002 已建的 controller / service / mapper / entity,不另起类。 | |
| 92 | +- **包路径**:`com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}`;新增查询入参 DTO `UserQueryDTO` 置于 `modules/usr/dto`,输出 `UserVO` 置于 `modules/usr/vo`。本 REQ 仅触及 `modules/usr/**`,不跨模块。 | |
| 93 | +- **命名**(docs/04 § 1.3):方法 `queryUsers`;REST 路径 `GET /api/usr/users`;DTO `UserQueryDTO`、VO `UserVO`。 | |
| 94 | +- **统一响应**(docs/04 § 1.4):返回 `Result<PageResult<UserVO>>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举(含 `42201`、`40001`)。 | |
| 95 | +- **异常处理**(docs/04 § 1.5):业务/参数错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 基础校验失败由全局处理器转 `40001`;分页越界(`pageNum<1`/`pageSize` 越界)转 `42201`(在 Controller/Service 入口判定或 `@Min/@Max` 校验 + 处理器映射,见 § 8 D8)。 | |
| 96 | +- **分页实现**(docs/04 § 3.2、§ 3.4):用 MyBatis-Plus 分页插件(`IPage`/`Page`)或在 Mapper XML 内 `LIMIT/OFFSET` + `COUNT(*)` 装配 `PageResult`;跨表 LEFT JOIN(usr_user ⋈ usr_employee)+ 动态条件建议用 Mapper XML(docs/04 § 3.4「复杂 SQL 写 XML」)。禁止 Controller 直接操作 Mapper。 | |
| 97 | +- **数据访问**(docs/04 § 3.4):只走 Mapper;文本条件模糊匹配、枚举/布尔/日期条件精确匹配(docs/04 § 3.2)。SQL 参数化(`#{}` 预编译占位)防注入;LIKE 通配符转义(§ 8 D3)。 | |
| 98 | +- **安全**:受保护接口经 `JwtAuthenticationFilter`;查询结果不含密码 / 不进日志敏感字段。 | |
| 99 | +- **配置**:JWT 密钥、DB 凭据只从 `config-vars.yaml` / `application.yml` 读取,不硬编码。 | |
| 100 | +- **schema**:本 REQ 仅**读** `usr_user` / `usr_employee`,二表已由 `sql/migrations/V1__initial_schema.sql` 建好,结构与 docs/03 一致,**无需新增 migration**(见 § 8 D9)。 | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## 5. Schema 引用(docs/03 SSoT) | |
| 105 | + | |
| 106 | +- **读**:`usr_user`(主键 `iIncrement`;输出列 `sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iIsVoid`/`tLastLoginDate`/`sCreator`/`tCreateDate`;过滤列另含 `iEmployeeId` 用于 join;**不 SELECT** `sPassword`)。相关索引:`idx_usr_user_employee`(iEmployeeId)、`idx_usr_user_type`(sUserType)、`uk_usr_user_username`(sUserName) 可被查询命中。 | |
| 107 | +- **读**:`usr_employee`(按 `usr_user.iEmployeeId = usr_employee.iIncrement` LEFT JOIN;取 `sEmployeeName`→员工名、`sDepartment`→部门;过滤员工名 / 部门时命中其列)。索引 `idx_usr_employee_name`(sEmployeeName) 可被命中。 | |
| 108 | +- 关系:`usr_user N:1 usr_employee`(外键 `fk_usr_user_employee`,ON DELETE SET NULL)。 | |
| 109 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrEmployee` ↔ `usr_employee`(匈牙利前缀列名,实体字段与列名映射保持一致);查询结果装配为 `UserVO`(非实体直出)。 | |
| 110 | + | |
| 111 | +--- | |
| 112 | + | |
| 113 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 114 | + | |
| 115 | +- 端点契约见 `docs/05-API接口契约.md` § REQ-USR-003(`GET /api/usr/users`)。 | |
| 116 | +- 错误码: | |
| 117 | + - `0` — 成功,返回 `PageResult<UserVO>`。 | |
| 118 | + - `42201` — 分页参数非法(`pageNum < 1` 或 `pageSize < 1` 或 `pageSize > 100`)。 | |
| 119 | + - `40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值无法解析为 0/1 / 日期字段值非法)。 | |
| 120 | + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。 | |
| 121 | + | |
| 122 | +--- | |
| 123 | + | |
| 124 | +## 7. 验收标准(Acceptance Criteria) | |
| 125 | + | |
| 126 | +1. **空条件全量分页**:不传任何 `queryValue`(或 `queryValue` 空)调用 `GET /api/usr/users?pageNum=1&pageSize=10` → `code=0`,`records` 为前 10 条用户,`total` = 用户总数,`pageNum=1`、`pageSize=10`;每条含 § 2.2 全部 VO 列,**不含 `sPassword`**。 | |
| 127 | +2. **文本「包含」筛选**:`queryField=用户名&matchType=包含&queryValue=adm` → 仅返回 `sUserName` 含 `adm` 的用户(LIKE `%adm%`),结果集正确。 | |
| 128 | +3. **文本「等于」精确**:`queryField=用户名&matchType=等于&queryValue=admin` → 仅返回 `sUserName` 严格等于 `admin` 的用户。 | |
| 129 | +4. **文本「不包含」**:`queryField=制单人&matchType=不包含&queryValue=admin` → 返回 `sCreator` 不含 `admin` 的用户。 | |
| 130 | +5. **枚举「等于」**:`queryField=用户类型&matchType=等于&queryValue=超级管理员` → 仅返回 `sUserType=超级管理员` 的用户。 | |
| 131 | +6. **布尔(作废)筛选**:`queryField=作废&queryValue=1` → 仅返回 `iIsVoid=1` 的用户;`queryValue=0` → 仅 `iIsVoid=0`;不可解析为 0/1(如 `abc`)→ `code=40001`。 | |
| 132 | +7. **日期(登录日期)筛选**:`queryField=登录日期&matchType=等于&queryValue=2026-06-01` → 返回 `tLastLoginDate` 落在 2026-06-01 当日的用户;非法日期值 → `code=40001`。 | |
| 133 | +8. **跨表员工名 / 部门**:返回每条 VO 的 `employeeName` / `department` 来自该用户关联职员;未关联职员(`iEmployeeId` 为 null)的用户仍在结果中,两列为 null。`queryField=部门&matchType=包含&queryValue=财务` 仅返回所属部门含「财务」的用户。 | |
| 134 | +9. **无匹配返回空**:筛选条件无命中 → `code=0`,`records=[]`,`total=0`(不报错)。 | |
| 135 | +10. **分页越界返回最后一页**:总数据仅 3 页(每页 10),请求 `pageNum=99` → `code=0`,`records` 为最后一页数据,`PageResult.pageNum` 回传实际最后页号,`total` 为真实总数。 | |
| 136 | +11. **分页参数非法**:`pageNum=0` 或 `pageSize=0` 或 `pageSize=500` → `code=42201`。 | |
| 137 | +12. **未认证**:无 / 失效 token 调用 → 401,不返回任何用户数据。 | |
| 138 | +13. **密码绝不泄露**:任意查询的响应体均不含 `sPassword` 字段、不含明文密码。 | |
| 139 | + | |
| 140 | +--- | |
| 141 | + | |
| 142 | +## 8. 自主决策记录(decisions) | |
| 143 | + | |
| 144 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 145 | +|---|---|---|---|---| | |
| 146 | +| D1 | 「分页越界」与 docs/05 `42201` 的边界冲突:卡片验收「越界返回最后一页」vs 契约 `pageNum<1` 报 `42201` | 区分两类:**参数非法**(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`;**数据越界**(`pageNum` 超总页数但本身合法 ≥1)→ 钳制到最后一页返回 `code=0`。`pageNum` 回传钳制后实际页号 | 卡片验收「越界返回最后一页」明确针对「请求页超出数据范围」;docs/05 `42201` 文义为「pageNum<1 或 pageSize 超上限」即非法入参——二者作用域不同,可并存不矛盾 | high | | |
| 147 | +| D2 | 查询是单条件还是多条件组合 | **单条件**:单 `queryField` + 单 `matchType` + 单 `queryValue` | 卡片输入表 1 为三行单值(查询字段/匹配方式/查询值),无多字段并列;docs/05 query 参数亦为单组,无条件数组 | high | | |
| 148 | +| D3 | LIKE 模糊查询中 `queryValue` 含 `%`/`_`/`\` 等通配符的处理 | 对 `%` `_` `\` 做转义(`ESCAPE '\\'`),按字面匹配,防止用户输入被当通配符 / 注入 | 安全与正确性默认实践;docs/04 § 3.2 文本模糊匹配未禁止转义,转义更稳妥 | high | | |
| 149 | +| D4 | 卡片输出列「序号」如何承载 | 后端 VO 回 `id`(主键),「序号」由前端按行号渲染;后端不生成连续行号 | 卡片标「序号—系统生成」属列表展示行号(前端职责);docs/05 `UserVO` 含 `id` 不含「序号」;后端阶段不做 UI 行号 | high | | |
| 150 | +| D5 | 是否限制仅管理员可查询 | **不限制**,任意已认证用户可调用 | docs/05 REQ-USR-003 Auth 仅标「需要(Bearer JWT)」,未附加「仅管理员」(对比 REQ-USR-001/002 明确「仅管理员/超级管理员」);只读查询无敏感写操作 | high | | |
| 151 | +| D6 | 布尔(作废)/ 日期(登录日期)字段的 `queryValue` 解析口径 | 作废:接受 `0`/`1`(兼容 `是`/`否`、`true`/`false` 归一化),否则 `40001`;登录日期:接受 `yyyy-MM-dd`(按当日区间)或 `yyyy-MM-dd HH:mm:ss`,否则 `40001` | 卡片「查询值=手工输入文本」需后端按目标列类型解析;docs/04 § 3.2「枚举/外键精确匹配」延伸到布尔/日期取精确区间;非法值显式 `40001` 比静默放行更可靠 | medium | | |
| 152 | +| D7 | 跨表字段(员工名/部门)「不包含」对 NULL(未关联职员)行的处理 | `不包含` 仅对该列**非 null** 的行做 `NOT LIKE`;未关联职员(列为 null)的行不纳入「不包含」结果(SQL `NOT LIKE` 对 NULL 返回 unknown 自然不命中) | 遵循标准 SQL 三值逻辑;卡片未对 null 行的「不包含」语义特别要求,取 SQL 默认行为,避免引入易错的 `OR col IS NULL` 隐式语义 | medium | | |
| 153 | +| D8 | `42201` 的实现层判定方式 | 在 Controller/Service 入口显式判定 `pageNum`/`pageSize` 范围并抛 `BusinessException(42201)`(或 `@Min/@Max` + 全局处理器映射到 `42201`),不依赖分页插件静默兜底 | docs/04 § 1.5 业务/参数错误统一走 `BusinessException` + 全局处理器;显式判定才能精确回 `42201` 而非 `40001` | medium | | |
| 154 | +| D9 | 是否新增 migration | 不新增,复用 `V1__initial_schema.sql` | 本 REQ 纯读 `usr_user` / `usr_employee`,二表已在 V1 建好,结构与 docs/03 一致,无 schema 变更 | high | | |
| 155 | + | |
| 156 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本只读查询 REQ 作用域内消解;本规格沿用 USR 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),不阻塞。 | ... | ... |
docs/superpowers/specs/2026-06-01-REQ-USR-004.md
0 → 100644
| 1 | +# REQ-USR-004 登录用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-004.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`。 | |
| 5 | +> 本规格只消费已锁定事实,忽略 UI 描述(登录卡片布局/输入框图标/「版本」下拉控件外观/密码框星号显示),但身份认证规则、凭据校验、令牌签发、最后登录时间更新等业务规则全部下沉到后端 DTO + Service。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Goal(目标) | |
| 10 | + | |
| 11 | +用户通过「用户名 + 密码(+ 版本/公司)」完成身份认证:账号存在、未禁用且密码 BCrypt 比对通过时,签发一枚带过期时间的无状态 JWT 访问令牌并返回用户基础信息;登录成功同步更新该用户的 `tLastLoginDate`。认证失败时返回不区分「账号不存在 / 密码错误」的统一失败提示以防账号枚举。 | |
| 12 | + | |
| 13 | +对外提供端点: | |
| 14 | + | |
| 15 | +- 主端点(本 REQ 核心):`POST /api/usr/login`(放行,无需 token)。 | |
| 16 | +- 配套只读端点(支撑登录页「版本」下拉,见 § 8 D1):`GET /api/usr/companies`(放行,返回可选公司/版本列表)。 | |
| 17 | + | |
| 18 | +> 「版本」下拉数据来源为 `usr_company`,需求卡片标注「页面加载时」预加载;docs/05 当前只定义了 `POST /api/usr/login`,未单列公司列表端点。为使登录链路自洽(前端拿不到 companyId 就无法提交登录),本 REQ 在后端补齐 `GET /api/usr/companies` 只读端点(见 § 6、§ 8 D1)。 | |
| 19 | + | |
| 20 | +--- | |
| 21 | + | |
| 22 | +## 2. 输入 / 输出 | |
| 23 | + | |
| 24 | +### 2.1 输入(请求)— `POST /api/usr/login` | |
| 25 | + | |
| 26 | +- **Method / Path**:`POST /api/usr/login` | |
| 27 | +- **Auth**:否(登录端点,Spring Security 放行;不携带 / 不校验 token)。 | |
| 28 | +- **Content-Type**:`application/json` | |
| 29 | +- **Body → `LoginDTO`**(`com.xly.erp.modules.usr.dto.LoginDTO`): | |
| 30 | + | |
| 31 | +| DTO 字段 | 类型 | 必填 | 校验 | 说明 | | |
| 32 | +|---|---|---|---|---| | |
| 33 | +| `sUserName` | String | 是 | `@NotBlank`,`@Size(max=50)`,trim 后非空 | 登录用户名(账号)。缺失 / 空 → `40001` | | |
| 34 | +| `password` | String | 是 | `@NotBlank`,`@Size(max=100)` | 登录密码明文(经 HTTPS 传输),服务端用 BCrypt 与 `usr_user.sPassword` 比对,**绝不落日志、不回显**。缺失 / 空 → `40001` | | |
| 35 | +| `companyId` | Integer | 是 | `@NotNull` | 登录「版本」下拉选中的 `usr_company.iIncrement`。缺失 → `40001`。仅做存在性校验,不参与账号-密码认证绑定(见 § 3 规则 6、§ 8 D2) | | |
| 36 | + | |
| 37 | +> 不接受 / 忽略的入参:任何用户属性写字段(用户类型 / 语言 / 权限等)、租户列覆盖、token 等本端点均不接收。 | |
| 38 | + | |
| 39 | +### 2.2 输出(响应)— `POST /api/usr/login` | |
| 40 | + | |
| 41 | +- 成功:`Result<LoginVO>`,`code=0`。 | |
| 42 | +- `LoginVO`(`com.xly.erp.modules.usr.vo.LoginVO`,对齐 docs/05 REQ-USR-004 契约,**不含 `sPassword` 及任何敏感字段**): | |
| 43 | + | |
| 44 | +| VO 字段 | 类型 | 来源 | 说明 | | |
| 45 | +|---|---|---|---| | |
| 46 | +| `token` | String | JWT 签发 | `Bearer` 访问令牌,无状态,带过期时间(见 § 3 规则 4、§ 4 配置) | | |
| 47 | +| `user` | Object | `usr_user` | 登录用户基础信息(嵌套对象,见下) | | |
| 48 | +| `user.id` | Integer | `usr_user.iIncrement` | 用户主键 ID | | |
| 49 | +| `user.sUserName` | String | `usr_user.sUserName` | 登录账号 | | |
| 50 | +| `user.sUserType` | String | `usr_user.sUserType` | 用户类型(普通用户 / 超级管理员) | | |
| 51 | +| `user.sLanguage` | String | `usr_user.sLanguage` | 界面语言(中文 / 英文 / 繁体) | | |
| 52 | + | |
| 53 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈、不携带任何区分账号是否存在的细节。 | |
| 54 | + | |
| 55 | +### 2.3 输入 / 输出 — `GET /api/usr/companies`(配套只读端点) | |
| 56 | + | |
| 57 | +- **Method / Path**:`GET /api/usr/companies` | |
| 58 | +- **Auth**:否(登录页加载时调用,需在登录前可访问,Spring Security 放行,见 § 8 D1)。 | |
| 59 | +- **请求**:无参数(一次性返回全部可登录公司 / 版本;当前数据量小,不分页,见 § 8 D5)。 | |
| 60 | +- **响应**:`Result<List<CompanyOptionVO>>`,`code=0`。`CompanyOptionVO`: | |
| 61 | + | |
| 62 | +| VO 字段 | 类型 | 来源 | 说明 | | |
| 63 | +|---|---|---|---| | |
| 64 | +| `id` | Integer | `usr_company.iIncrement` | 公司 / 版本主键,登录时回传为 `companyId` | | |
| 65 | +| `sCompanyName` | String | `usr_company.sCompanyName` | 公司名称(下拉显示文本) | | |
| 66 | +| `sVersion` | String | `usr_company.sVersion` | 版本 / 账套标识(可为 null) | | |
| 67 | + | |
| 68 | +--- | |
| 69 | + | |
| 70 | +## 3. 业务规则 | |
| 71 | + | |
| 72 | +1. **认证主流程**(顺序判定): | |
| 73 | + 1. 基础参数校验(`@Valid`):`sUserName` / `password` / `companyId` 任一缺失或为空 → `40001`(不进入查库)。 | |
| 74 | + 2. 按 `sUserName` 精确查 `usr_user`(`uk_usr_user_username` 命中)。**未查到用户** → `40101`(认证失败,统一提示,不暴露「账号不存在」)。 | |
| 75 | + 3. 命中用户后判 `iIsVoid`:`iIsVoid=1`(已禁用 / 作废)→ `40302`(账号已禁用)。详见规则 5 与 § 8 D3 的判定顺序。 | |
| 76 | + 4. BCrypt 比对 `password` 明文与 `usr_user.sPassword` 哈希:**不匹配** → `40101`(认证失败,统一提示,不暴露「密码错误」)。 | |
| 77 | + 5. 全部通过 → 签发 JWT + 更新 `tLastLoginDate` + 返回 `LoginVO`。 | |
| 78 | +2. **防账号枚举**:用户不存在与密码错误**返回完全相同**的错误码 `40101` 与提示文案(如「用户名或密码错误」),调用方无法据响应区分二者(卡片边界要求)。 | |
| 79 | +3. **统一失败提示**:所有 `40101` 走同一 message,不在日志 / 响应里输出明文密码或「该用户名不存在」类可枚举信息。 | |
| 80 | +4. **令牌签发(JWT,无状态)**: | |
| 81 | + - 登录成功用 `config-vars.yaml` `secrets.jwt_secret` 签发 JWT(docs/04 § 1.7),**必须**含过期时间(exp claim)。 | |
| 82 | + - claim 至少携带用户标识(subject = `sUserName` 或 `user.id`)与用户类型 `sUserType`(供后续受保护接口鉴权 / 角色判定,对齐 REQ-USR-001/002 的「仅管理员」约束)。 | |
| 83 | + - 令牌不落库(卡片 / docs/03 业务注记「JWT 为无状态,不落库」)。 | |
| 84 | +5. **禁用 / 删除用户禁止登录**:`iIsVoid=1` 的用户即使凭据正确也禁止登录,返回 `40302`。本库为软删除模型(无独立「已删除」列,禁用即 `iIsVoid=1`),「已禁用」与「已删除」在本 REQ 统一按 `iIsVoid=1` 处理(见 § 8 D4)。 | |
| 85 | +6. **companyId 的语义**:`companyId` 为必填且必须能在 `usr_company` 找到对应记录(存在性校验),但**不参与**用户名-密码认证绑定——`usr_company` 与 `usr_user` 无外键关系(docs/03「当前仅供登录时选择,不与 usr_user 建强外键」)。`companyId` 不存在于 `usr_company` 时按参数非法 `40001` 处理(见 § 8 D2)。`companyId` 不写入 JWT 的认证主体,仅作登录上下文(可选放入 claim,非强制)。 | |
| 86 | +7. **更新最后登录时间**:认证全部通过后,将该用户 `tLastLoginDate` 更新为当前服务器时间(`LocalDateTime.now()`)。该写操作在 Service 内置于 `@Transactional(rollbackFor = Exception.class)`,与令牌签发同一逻辑事务(更新失败则整体回滚、登录视为失败,见 § 8 D6)。仅更新 `tLastLoginDate`,不触碰其他列。 | |
| 87 | +8. **连续失败限流 / 锁定**:卡片「连续登录失败需有锁定或限流策略」。本 REQ 后端阶段采用**轻量登录限流**作为 MVP 落地(见 § 8 D7):按 `sUserName`(或来源 IP+用户名)在内存 / 进程级计数器累计连续失败次数,达到阈值后在冷却窗内对该账号的登录请求直接返回 `42901`(请求过于频繁,请稍后重试)。阈值与窗口取默认值(连续失败 5 次、锁定 5 分钟,见 § 4 配置 / § 8 D7);成功登录清零计数。不依赖 Redis(技术栈虽列 Redis,但当前无连接配置,落进程内存即可,后续可平滑替换为 Redis 计数)。 | |
| 88 | +9. **密码与敏感字段不返回**:`LoginVO` 不含 `sPassword`;不得 `SELECT *` 透传实体到响应;密码明文不进任何日志 / 异常 message。 | |
| 89 | +10. **`GET /api/usr/companies` 只读无副作用**:仅 `SELECT usr_company`,不写任何表,可标 `@Transactional(readOnly = true)`(可选);返回全部公司(无禁用列,全部视为可选)。 | |
| 90 | + | |
| 91 | +--- | |
| 92 | + | |
| 93 | +## 4. 约束(技术 / 安全) | |
| 94 | + | |
| 95 | +- **分层**(docs/04 § 1.2):`UsrAuthController`(或在既有 `UsrUserController` 上新增 `login` / `companies` 端点,见 § 8 D8,仅 `@Valid` 校验 + 委派)→ `UsrAuthService` / `UsrAuthServiceImpl`(认证 + 令牌签发 + 更新登录时间)→ `UsrUserMapper` / `UsrCompanyMapper`(MyBatis-Plus)。Controller 禁止直接操作 Mapper、禁止写业务逻辑。 | |
| 96 | +- **包路径**:`com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}`。新增入参 `LoginDTO` 置 `modules/usr/dto`;出参 `LoginVO` / `CompanyOptionVO` 置 `modules/usr/vo`;`usr_company` 实体 `UsrCompany` 置 `modules/usr/entity`、`UsrCompanyMapper` 置 `modules/usr/mapper`(若 REQ-USR-001/003 未建则本 REQ 新建,见 § 8 D9)。本 REQ 仅触及 `modules/usr/**` 与 `common/security/**`(JWT 工具,若尚未建),不跨业务模块。 | |
| 97 | +- **命名**(docs/04 § 1.3):Service 方法 `login(LoginDTO dto)`、`listCompanies()`;REST 路径 `POST /api/usr/login`、`GET /api/usr/companies`;DTO `LoginDTO`,VO `LoginVO` / `CompanyOptionVO`。 | |
| 98 | +- **统一响应**(docs/04 § 1.4):返回 `Result<LoginVO>` / `Result<List<CompanyOptionVO>>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举(含 `40101` / `40302` / `40001` / `42901`)。 | |
| 99 | +- **异常处理**(docs/04 § 1.5):认证 / 业务错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 基础校验失败由全局处理器转 `40001`。 | |
| 100 | +- **事务**(docs/04 § 1.6):`login` 含写(更新 `tLastLoginDate`),Service 实现方法加 `@Transactional(rollbackFor = Exception.class)`;`listCompanies` 只读。 | |
| 101 | +- **认证 / 安全**(docs/04 § 1.7): | |
| 102 | + - 密码用 `BCryptPasswordEncoder` 比对,禁止明文存取 / 比较。 | |
| 103 | + - JWT 密钥取自 `config-vars.yaml` `secrets.jwt_secret`(映射到 `application.yml`,禁止硬编码进源码 / 日志),令牌带过期时间。 | |
| 104 | + - Spring Security 放行 `POST /api/usr/login` 与 `GET /api/usr/companies`(登录前可访问);其余 USR 写 / 查端点(REQ-001/002/003)仍经 `JwtAuthenticationFilter` 校验。 | |
| 105 | +- **数据访问**(docs/04 § 3.4):只走 Mapper(MyBatis-Plus `LambdaQueryWrapper` / BaseMapper);按 `sUserName` 查用户、按 `companyId` 查公司均参数化(`#{}`/MP 预编译)防注入。**不 SELECT** `sPassword` 到响应;查认证用户时仅取必要列(含 `sPassword` 供比对,但不外泄)。 | |
| 106 | +- **配置**(新增,写入 `application.yml`,值引自 `config-vars.yaml` / 留默认): | |
| 107 | + - `jwt.secret`(引 `secrets.jwt_secret`)、`jwt.expiration`(令牌有效期,默认 7200 秒 / 2 小时,见 § 8 D10)。 | |
| 108 | + - 登录限流:`auth.login.max-fail`(默认 5)、`auth.login.lock-seconds`(默认 300)。 | |
| 109 | +- **schema**:本 REQ 仅**读** `usr_user`(认证 + 取 `sPassword`/`iIsVoid`/`sUserType`/`sLanguage`)与 `usr_company`(版本下拉 + companyId 校验),**写** `usr_user.tLastLoginDate`(UPDATE,非 DDL)。二表已由 `sql/migrations/V1__initial_schema.sql` 建好,结构与 docs/03 一致,**无需新增 migration**(无字段 / 索引 / 约束变更,见 § 8 D11)。 | |
| 110 | + | |
| 111 | +--- | |
| 112 | + | |
| 113 | +## 5. Schema 引用(docs/03 SSoT) | |
| 114 | + | |
| 115 | +- **读 + 写**:`usr_user`(主键 `iIncrement`;认证取 `sUserName`(命中 `uk_usr_user_username`)、`sPassword`(BCrypt 比对,不外泄)、`iIsVoid`(禁用判定)、`sUserType` / `sLanguage`(回 `LoginVO`);登录成功 **UPDATE** `tLastLoginDate`)。 | |
| 116 | +- **读**:`usr_company`(主键 `iIncrement`;`GET /api/usr/companies` 取 `sCompanyName` / `sVersion` 作下拉项;`login` 校验 `companyId` 存在性。命中唯一索引 `uk_usr_company_name`(按名)/主键(按 id))。`usr_company` 与 `usr_user` 无外键(独立支撑表)。 | |
| 117 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrCompany` ↔ `usr_company`(匈牙利前缀列名,实体字段与列名映射一致);认证结果装配为 `LoginVO`(非实体直出),公司列表装配为 `CompanyOptionVO`。 | |
| 118 | +- 不涉及 `usr_employee` / `usr_permission` / `usr_user_permission`(登录不读权限 / 职员,权限校验在受保护接口的过滤器侧按 token claim 判定,本 REQ 仅签发 token)。 | |
| 119 | + | |
| 120 | +--- | |
| 121 | + | |
| 122 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 123 | + | |
| 124 | +- 主端点契约见 `docs/05-API接口契约.md` § REQ-USR-004(`POST /api/usr/login`)。 | |
| 125 | +- 配套只读端点 `GET /api/usr/companies` 为本 REQ 自洽补齐(docs/05 未单列,建议反向同步补入契约清单,见 § 8 D1)。 | |
| 126 | +- 错误码: | |
| 127 | + - `0` — 成功。`POST /api/usr/login` 返回 `LoginVO`;`GET /api/usr/companies` 返回 `List<CompanyOptionVO>`。 | |
| 128 | + - `40001` — 参数校验失败(缺 `sUserName` / `password` / `companyId`,或 `companyId` 在 `usr_company` 不存在)。 | |
| 129 | + - `40101` — 认证失败(用户名或密码错误;**不区分**以防账号枚举)。 | |
| 130 | + - `40302` — 账号已禁用(`iIsVoid=1`,禁止登录)。 | |
| 131 | + - `42901` — 登录过于频繁(连续失败超阈值,账号临时锁定 / 限流,见 § 3 规则 8 / § 8 D7)。 | |
| 132 | + - 401 — 受保护接口无 / 失效 token(由安全过滤器返回,非本端点产生;本登录端点放行)。 | |
| 133 | + | |
| 134 | +> 错误码常量统一登记到 `ResultCode` 枚举(docs/04 § 1.4);`42901` 为本 REQ 引入的限流码(docs/05 REQ-USR-004 未列,限流为卡片「锁定 / 限流策略」要求的落地,建议反向同步补入契约错误码段,见 § 8 D7)。 | |
| 135 | + | |
| 136 | +--- | |
| 137 | + | |
| 138 | +## 7. 验收标准(Acceptance Criteria) | |
| 139 | + | |
| 140 | +1. **正确凭据登录成功**:以存在、`iIsVoid=0` 的用户的正确用户名 + 密码 + 合法 `companyId` 调用 `POST /api/usr/login` → `code=0`,返回非空 `token` 与 `user{ id, sUserName, sUserType, sLanguage }`;响应**不含** `sPassword`。 | |
| 141 | +2. **令牌可被后续接口接受**:步骤 1 返回的 `token` 作为 `Authorization: Bearer <token>` 调用任一受保护接口(如 `GET /api/usr/users`)→ 通过 JWT 过滤器(不返回 401)。 | |
| 142 | +3. **登录成功更新登录时间**:登录成功后再查该用户,`tLastLoginDate` 已更新为本次登录的时间(≥ 登录前值 / 由 null 变为非 null)。 | |
| 143 | +4. **密码错误**:正确用户名 + 错误密码 → `code=40101`,message 为统一失败提示,**不**返回 token、**不**泄露「密码错误」字样。 | |
| 144 | +5. **账号不存在**:不存在的用户名 → `code=40101`,错误码与 message 与「密码错误」**完全一致**(无法据响应区分账号是否存在)。 | |
| 145 | +6. **禁用用户被拒**:`iIsVoid=1` 的用户即使凭据正确 → `code=40302`(账号已禁用),不签发 token、不更新登录时间。 | |
| 146 | +7. **参数缺失**:缺 `sUserName` / 缺 `password` / 缺 `companyId` 任一 → `code=40001`,不进入认证。 | |
| 147 | +8. **companyId 非法**:`companyId` 不存在于 `usr_company` → `code=40001`(参数非法)。 | |
| 148 | +9. **连续失败限流**:对同一账号连续 5 次密码错误后,第 6 次(在锁定窗内)即使密码正确也返回 `code=42901`(请求过于频繁);冷却窗过后或一次成功登录后计数清零,恢复正常。 | |
| 149 | +10. **公司/版本列表**:未登录调用 `GET /api/usr/companies` → `code=0`,返回 `usr_company` 全量项 `{ id, sCompanyName, sVersion }`,可供前端「版本」下拉渲染;该端点无需 token 即可访问。 | |
| 150 | +11. **密码绝不泄露**:任意成功 / 失败响应体均不含 `sPassword`、不含明文密码;应用日志中不出现登录明文密码。 | |
| 151 | +12. **JWT 含过期时间**:签发的 token 解码后含 exp claim(有限有效期),过期后调用受保护接口被拒(401)。 | |
| 152 | + | |
| 153 | +--- | |
| 154 | + | |
| 155 | +## 8. 自主决策记录(decisions) | |
| 156 | + | |
| 157 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 158 | +|---|---|---|---|---| | |
| 159 | +| D1 | docs/05 只定义 `POST /api/usr/login`,但卡片「版本」下拉需在页面加载时从 `usr_company` 取数,无对应列表端点 | 本 REQ 补齐只读端点 `GET /api/usr/companies`(放行、无参、返回全量),并建议反向同步补入 docs/05 | 卡片输入表「版本…显示来源=公司表…预加载=页面加载时」+ 卡片「依赖接口=无(…登录版本下拉数据来自 usr_company 基础数据读取)」明确该数据须由后端提供;登录前必须能拿到 companyId 否则登录链路不自洽;端点纯读、放行不触安全约束 | high | | |
| 160 | +| D2 | `companyId` 在认证中的作用 + 非法 companyId 的错误码 | `companyId` 必填且做存在性校验,但**不**参与账号-密码认证绑定(usr_company 与 usr_user 无外键);不存在 → `40001`(参数非法) | docs/03「usr_company…仅供登录时选择,不与 usr_user 建强外键」;卡片「版本=必填」;docs/05 `40001` 文义含「缺…版本」,非法 companyId 同属参数维度归 `40001`(区别于认证维度 `40101`) | high | | |
| 161 | +| D3 | 禁用判定与密码比对的先后顺序(先查到用户后,先判 iIsVoid 还是先比密码) | **先比密码再判禁用**或**先判禁用**均可返回正确码,但为防「禁用码 `40302` 反向暴露账号存在」,本规格采用:查到用户后**先 BCrypt 比对密码**,密码错 → `40101`;密码对再判 `iIsVoid=1` → `40302` | 若先返回 `40302` 而不验证密码,攻击者可用任意密码探测「某账号存在且被禁用」;先验密码再返禁用码,使 `40302` 仅在凭据正确时出现,最小化枚举面;卡片「禁用用户登录被拒」与「防枚举」二者兼顾 | medium | | |
| 162 | +| D4 | 卡片「已删除用户禁止登录」但 docs/03 无独立「已删除」列 | 「已禁用」与「已删除」在本 REQ 统一按 `iIsVoid=1` 判定(软删除模型),均返回 `40302` | docs/03 `usr_user` 仅有 `iIsVoid`(作废/禁用),业务注记「iIsVoid=1 表示禁用,禁止登录」;无 hard-delete 字段,作废即逻辑删除,二者同义 | high | | |
| 163 | +| D5 | `GET /api/usr/companies` 是否分页 | 不分页,一次返回全量 | 公司/版本为低基数基础数据(账套数量极少),下拉一次性加载更简单;卡片标「预加载」即一次取全 | high | | |
| 164 | +| D6 | 更新 `tLastLoginDate` 失败是否回滚登录 | 置于同一 `@Transactional`,更新失败则整体回滚、登录返回失败 | docs/04 § 1.6 写操作加事务;登录成功的副作用(记录登录时间)应原子,避免「签发了 token 但登录时间未记」的不一致;实现简单可靠 | medium | | |
| 165 | +| D7 | 卡片「连续失败锁定/限流」的后端落地形态与阈值 | MVP 采用进程内(内存)按用户名计数的轻量限流:连续失败 5 次 → 锁定 300 秒,期间返回新错误码 `42901`;成功登录清零;阈值/窗口写入配置项 `auth.login.max-fail` / `auth.login.lock-seconds` | 卡片明确要求「锁定或限流策略」,必须落地某机制;技术栈列了 Redis 但 config-vars.yaml 无 Redis 连接(`redis_password` 为注释占位),故先用进程内计数,接口/配置预留后续平滑替换 Redis;阈值取通用安全默认(5 次/5 分钟);新增 `42901` 限流码(docs/05 未列,建议反向补入) | medium | | |
| 166 | +| D8 | 登录端点放在新 Controller 还是既有 UsrUserController | 倾向新建 `UsrAuthController`(认证语义独立于用户 CRUD),亦可挂既有 `UsrUserController`;二者均满足分层约束,实现期择一 | docs/04 § 1.2 示例 Controller 为 `UsrUserController`(CRUD),登录属认证关注点,单列 `UsrAuthController` 更内聚;非硬约束,故标为实现期可调 | medium | | |
| 167 | +| D9 | `UsrCompany` 实体 / `UsrCompanyMapper` 是否已存在 | 若 REQ-USR-001/003 未建则本 REQ 新建(属 `modules/usr` 内,非跨模块) | REQ-001/003 spec 仅涉及 `usr_user` / `usr_employee`,未建 `usr_company` 访问层;本 REQ 首次读 `usr_company`,需补实体 + Mapper,仍在 USR 模块内 | high | | |
| 168 | +| D10 | JWT 过期时间默认值 | 默认 7200 秒(2 小时),写入 `application.yml` `jwt.expiration`,可配 | docs/04 § 1.7 / docs/05 仅要求「有过期时间」未给具体值;2 小时为后台管理系统常见会话时长的稳妥默认;落配置便于调整 | medium | | |
| 169 | +| D11 | 是否新增 migration | 不新增,复用 `V1__initial_schema.sql` | 本 REQ 仅读 `usr_user` / `usr_company` 并 UPDATE `usr_user.tLastLoginDate`(DML 非 DDL),无字段/索引/约束变更,二表已在 V1 建好且结构与 docs/03 一致 | high | | |
| 170 | + | |
| 171 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本登录 REQ 作用域内消解;本规格沿用 USR 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),`sVersion` 仅作下拉展示字段直透,不阻塞。 | |
| 172 | +> 反向同步建议(非本 stage 强制):实现期可将 `GET /api/usr/companies` 端点与 `42901` 限流错误码补入 `docs/05-API接口契约.md` § REQ-USR-004,保持契约 SSoT 完整。 | ... | ... |
scripts/test.mjs
| ... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 | // 命令字符串来自 docs/04 §零(构建/lint/单测/e2e)——由 skeleton-gen 在 Plan 期填充。 |
| 10 | 10 | |
| 11 | 11 | import { spawnSync } from 'node:child_process' |
| 12 | -import { existsSync } from 'node:fs' | |
| 12 | +import { existsSync, readdirSync } from 'node:fs' | |
| 13 | 13 | import { dirname, join } from 'node:path' |
| 14 | 14 | import { fileURLToPath } from 'node:url' |
| 15 | 15 | |
| ... | ... | @@ -29,6 +29,65 @@ function run(label, command, cwd = PROJECT_ROOT) { |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | +// ── JDK 固定(后端)───────────────────────────────────────────────── | |
| 33 | +// 本项目锁定 Java 17(docs/04 §零)。但开发机默认 JDK 可能更新(如 JDK 25), | |
| 34 | +// 而 Maven Surefire fork 出的测试 JVM 会沿用默认 JDK:Mockito 自带的 Byte Buddy | |
| 35 | +// 不支持过新的 class file 版本(JDK 25 = 69,Byte Buddy 仅到 Java 22 = 66), | |
| 36 | +// 导致 `Mockito cannot mock class ...` 在 setUp 阶段整片报 error。 | |
| 37 | +// 这里在跑后端 Maven 前,把本进程(及其 spawnSync 子进程)的 JAVA_HOME 固定到 | |
| 38 | +// 一个 Java 17 运行时;不改写全局/用户 profile,仅影响本次测试闸进程树。 | |
| 39 | +function javaMajor(javaHome) { | |
| 40 | + if (!javaHome) return null | |
| 41 | + const bin = join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') | |
| 42 | + if (!existsSync(bin)) return null | |
| 43 | + const res = spawnSync(bin, ['-version'], { encoding: 'utf8' }) | |
| 44 | + const out = `${res.stdout || ''}${res.stderr || ''}` | |
| 45 | + const m = out.match(/version "(\d+)/) // "17.0.19" → 17;"25.0.2" → 25 | |
| 46 | + return m ? Number(m[1]) : null | |
| 47 | +} | |
| 48 | + | |
| 49 | +function resolveJava17Home() { | |
| 50 | + // 1) 显式覆盖:JAVA17_HOME | |
| 51 | + if (javaMajor(process.env.JAVA17_HOME) === 17) return process.env.JAVA17_HOME | |
| 52 | + // 2) 现有 JAVA_HOME 恰好已是 17 | |
| 53 | + if (javaMajor(process.env.JAVA_HOME) === 17) return process.env.JAVA_HOME | |
| 54 | + // 3) macOS:/usr/libexec/java_home -v 17 | |
| 55 | + if (process.platform === 'darwin') { | |
| 56 | + const r = spawnSync('/usr/libexec/java_home', ['-v', '17'], { encoding: 'utf8' }) | |
| 57 | + if (r.status === 0) { const h = (r.stdout || '').trim(); if (javaMajor(h) === 17) return h } | |
| 58 | + } | |
| 59 | + // 4) 常见 Linux 安装位置(best-effort) | |
| 60 | + for (const base of ['/usr/lib/jvm', '/opt/java', '/opt']) { | |
| 61 | + if (!existsSync(base)) continue | |
| 62 | + try { | |
| 63 | + for (const name of readdirSync(base)) { | |
| 64 | + if (!/17/.test(name)) continue | |
| 65 | + const h = join(base, name) | |
| 66 | + if (javaMajor(h) === 17) return h | |
| 67 | + } | |
| 68 | + } catch { /* 忽略不可读目录 */ } | |
| 69 | + } | |
| 70 | + return null | |
| 71 | +} | |
| 72 | + | |
| 73 | +// 仅在默认 JDK 不是 17 时才介入;找不到 17 则告警并沿用默认(不擅自失败)。 | |
| 74 | +function ensureJava17() { | |
| 75 | + if (javaMajor(process.env.JAVA_HOME) === 17) { | |
| 76 | + console.log(`[test.mjs] JAVA_HOME 已是 Java 17:${process.env.JAVA_HOME}`) | |
| 77 | + return | |
| 78 | + } | |
| 79 | + const home = resolveJava17Home() | |
| 80 | + if (!home) { | |
| 81 | + console.warn('[test.mjs] WARN: 未找到 Java 17(JAVA17_HOME / JAVA_HOME / java_home -v 17 / 常见路径均未命中);' | |
| 82 | + + '将以默认 JDK 跑后端测试。若默认 JDK 过新,Mockito/Byte Buddy 可能在 mock 时整片报 error。') | |
| 83 | + return | |
| 84 | + } | |
| 85 | + process.env.JAVA_HOME = home | |
| 86 | + const sep = process.platform === 'win32' ? ';' : ':' | |
| 87 | + process.env.PATH = `${join(home, 'bin')}${sep}${process.env.PATH || ''}` | |
| 88 | + console.log(`[test.mjs] 固定 JAVA_HOME=Java 17 → ${home}`) | |
| 89 | +} | |
| 90 | + | |
| 32 | 91 | // Stack detection (runtime, mode-agnostic) |
| 33 | 92 | const hasBackend = existsSync(join(PROJECT_ROOT, 'backend')) |
| 34 | 93 | const hasFrontend = existsSync(join(PROJECT_ROOT, 'frontend')) |
| ... | ... | @@ -40,6 +99,9 @@ if (!hasBackend && !hasFrontend) { |
| 40 | 99 | const backendDir = join(PROJECT_ROOT, 'backend') |
| 41 | 100 | const frontendDir = join(PROJECT_ROOT, 'frontend') |
| 42 | 101 | |
| 102 | +// 后端存在时,先把测试用 JDK 固定到 Java 17(见 ensureJava17 注释)。 | |
| 103 | +if (hasBackend) ensureJava17() | |
| 104 | + | |
| 43 | 105 | console.log('[test.mjs] 1/5 setup test db') |
| 44 | 106 | run('setup-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`) |
| 45 | 107 | ... | ... |