Commit 3bd7a62ada115b4cb1d7d59e8e9fabf1da169bef

Authored by zichun
2 parents cc0c8462 5fa08044

merge(usr): integrate module-usr

Showing 86 changed files with 8449 additions and 5 deletions

Too many changes to show.

To preserve performance only 57 of 86 files are displayed.

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} &lt;&gt; #{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} &lt; #{cond.dateStart} OR ${cond.column} &gt;= #{cond.dateEnd})
  66 + </when>
  67 + <otherwise>
  68 + ${cond.column} &gt;= #{cond.dateStart} AND ${cond.column} &lt; #{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 +}
... ...