Commit 3bd7a62ada115b4cb1d7d59e8e9fabf1da169bef
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} <> #{cond.boolValue} | |
| 56 | + </when> | |
| 57 | + <otherwise> | |
| 58 | + ${cond.column} = #{cond.boolValue} | |
| 59 | + </otherwise> | |
| 60 | + </choose> | |
| 61 | + </if> | |
| 62 | + <if test="cond != null and cond.date"> | |
| 63 | + <choose> | |
| 64 | + <when test="cond.dateNegated"> | |
| 65 | + (${cond.column} < #{cond.dateStart} OR ${cond.column} >= #{cond.dateEnd}) | |
| 66 | + </when> | |
| 67 | + <otherwise> | |
| 68 | + ${cond.column} >= #{cond.dateStart} AND ${cond.column} < #{cond.dateEnd} | |
| 69 | + </otherwise> | |
| 70 | + </choose> | |
| 71 | + </if> | |
| 72 | + </where> | |
| 73 | + ORDER BY u.iIncrement | |
| 74 | + </select> | |
| 75 | + | |
| 76 | +</mapper> | ... | ... |
backend/src/test/java/com/xly/erp/ErpApplicationTests.java
0 → 100644
| 1 | +package com.xly.erp; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 5 | +import org.springframework.test.context.ActiveProfiles; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 骨架健康自检:证明 pom 依赖 / 启动类 / application.yml 自洽, | |
| 9 | + * Flyway 能对测试库 apply V1,MyBatis-Plus 装配成功。 | |
| 10 | + * | |
| 11 | + * <p>REQ-USR-001 T1。</p> | |
| 12 | + */ | |
| 13 | +@SpringBootTest | |
| 14 | +@ActiveProfiles("test") | |
| 15 | +class ErpApplicationTests { | |
| 16 | + | |
| 17 | + @Test | |
| 18 | + void contextLoads() { | |
| 19 | + // Spring 上下文成功加载即通过。 | |
| 20 | + } | |
| 21 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 7 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T3:BCryptPasswordEncoder Bean 行为(轻量单元,直接 new SecurityConfig 取 Bean)。 | |
| 11 | + */ | |
| 12 | +class SecurityConfigTest { | |
| 13 | + | |
| 14 | + @Test | |
| 15 | + void bcryptEncoderEncodesAndMatches() { | |
| 16 | + SecurityConfig config = new SecurityConfig(); | |
| 17 | + PasswordEncoder encoder = config.passwordEncoder(); | |
| 18 | + assertThat(encoder).isInstanceOf(BCryptPasswordEncoder.class); | |
| 19 | + | |
| 20 | + String hash = encoder.encode("666666"); | |
| 21 | + assertThat(hash).isNotEqualTo("666666"); | |
| 22 | + assertThat(encoder.matches("666666", hash)).isTrue(); | |
| 23 | + assertThat(encoder.matches("wrong", hash)).isFalse(); | |
| 24 | + } | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * REQ-USR-004 T5:放行清单含 GET /api/usr/companies(与既有 /api/usr/login 同口径放行)。 | |
| 28 | + */ | |
| 29 | + @Test | |
| 30 | + void companiesEndpointPermitted() { | |
| 31 | + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/companies"); | |
| 32 | + // 不删除既有放行项:/api/usr/login 仍在清单。 | |
| 33 | + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/login"); | |
| 34 | + } | |
| 35 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.xly.erp.common.response.Result; | |
| 6 | +import com.xly.erp.common.response.ResultCode; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T2:全局异常处理器把 BusinessException 转为统一 Result。 | |
| 11 | + */ | |
| 12 | +class GlobalExceptionHandlerTest { | |
| 13 | + | |
| 14 | + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void businessExceptionMapsToResult() { | |
| 18 | + BusinessException ex = new BusinessException(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 19 | + Result<Void> r = handler.handleBusinessException(ex); | |
| 20 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 21 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 22 | + } | |
| 23 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/PageResultTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import java.util.List; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * REQ-USR-003 T1:通用分页响应体 PageResult。 | |
| 10 | + * | |
| 11 | + * <p>验证四字段(records/total/pageNum/pageSize)装配读回一致,空 records 允许。</p> | |
| 12 | + */ | |
| 13 | +class PageResultTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + void ofAssemblesAllFields() { | |
| 17 | + List<String> records = List.of("a", "b", "c"); | |
| 18 | + PageResult<String> page = PageResult.of(records, 23L, 2L, 10L); | |
| 19 | + | |
| 20 | + assertThat(page.getRecords()).isEqualTo(records); | |
| 21 | + assertThat(page.getTotal()).isEqualTo(23L); | |
| 22 | + assertThat(page.getPageNum()).isEqualTo(2L); | |
| 23 | + assertThat(page.getPageSize()).isEqualTo(10L); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void emptyRecordsAllowed() { | |
| 28 | + PageResult<String> page = PageResult.of(List.of(), 0L, 1L, 10L); | |
| 29 | + | |
| 30 | + assertThat(page.getRecords()).isNotNull().isEmpty(); | |
| 31 | + assertThat(page.getTotal()).isZero(); | |
| 32 | + assertThat(page.getPageNum()).isEqualTo(1L); | |
| 33 | + } | |
| 34 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-004 T1:登录相关错误码断言。 | |
| 9 | + * | |
| 10 | + * <p>新增登录限流码 {@code LOGIN_RATE_LIMITED=42901};并确认登录流程复用的既有码 | |
| 11 | + * {@code UNAUTHORIZED=40101} / {@code ACCOUNT_DISABLED=40302} / {@code PARAM_INVALID=40001} | |
| 12 | + * 取值不变(不重复定义、不漂移)。</p> | |
| 13 | + */ | |
| 14 | +class ResultCodeLoginTest { | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void loginRateLimitedCodeIs42901() { | |
| 18 | + assertThat(ResultCode.LOGIN_RATE_LIMITED.getCode()).isEqualTo(42901); | |
| 19 | + assertThat(ResultCode.LOGIN_RATE_LIMITED.getMessage()).isNotBlank(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + void existingLoginCodesPresent() { | |
| 24 | + assertThat(ResultCode.UNAUTHORIZED.getCode()).isEqualTo(40101); | |
| 25 | + assertThat(ResultCode.ACCOUNT_DISABLED.getCode()).isEqualTo(40302); | |
| 26 | + assertThat(ResultCode.PARAM_INVALID.getCode()).isEqualTo(40001); | |
| 27 | + } | |
| 28 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ResultTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T2:统一响应体 Result 行为校验。 | |
| 9 | + */ | |
| 10 | +class ResultTest { | |
| 11 | + | |
| 12 | + @Test | |
| 13 | + void successCarriesCodeZeroAndData() { | |
| 14 | + Result<String> r = Result.success("hello"); | |
| 15 | + assertThat(r.getCode()).isEqualTo(0); | |
| 16 | + assertThat(r.getData()).isEqualTo("hello"); | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + void failCarriesBusinessCodeAndMessage() { | |
| 21 | + Result<Void> r = Result.fail(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 22 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 23 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 24 | + assertThat(r.getData()).isNull(); | |
| 25 | + } | |
| 26 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T3:JWT 签发 / 解析往返。 | |
| 9 | + * | |
| 10 | + * <p>用 config-vars 的 jwt_secret 签发含 sUserName + sUserType claim 的 token, | |
| 11 | + * 解析后能取回相同值且校验通过。</p> | |
| 12 | + */ | |
| 13 | +class JwtUtilTest { | |
| 14 | + | |
| 15 | + private static final String SECRET = | |
| 16 | + "a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2"; | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + void generateThenParseRoundTrip() { | |
| 20 | + JwtUtil jwtUtil = new JwtUtil(SECRET, 43200000L); | |
| 21 | + String token = jwtUtil.generateToken("admin", "超级管理员"); | |
| 22 | + | |
| 23 | + assertThat(jwtUtil.validateToken(token)).isTrue(); | |
| 24 | + assertThat(jwtUtil.getUserName(token)).isEqualTo("admin"); | |
| 25 | + assertThat(jwtUtil.getUserType(token)).isEqualTo("超级管理员"); | |
| 26 | + } | |
| 27 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Value; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.test.context.ActiveProfiles; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * REQ-USR-004 T6:登录限流配置项可解析(spec § 8 D7)。 | |
| 12 | + * | |
| 13 | + * <p>@SpringBootTest + test profile 下断言 auth.login.max-fail / auth.login.lock-seconds | |
| 14 | + * 已声明且能解析为整数(默认 5 / 300),确保 Service @Value 注入不会因缺键启动失败。</p> | |
| 15 | + */ | |
| 16 | +@SpringBootTest | |
| 17 | +@ActiveProfiles("test") | |
| 18 | +class AuthLoginConfigIT { | |
| 19 | + | |
| 20 | + @Value("${auth.login.max-fail}") | |
| 21 | + private int maxFail; | |
| 22 | + | |
| 23 | + @Value("${auth.login.lock-seconds}") | |
| 24 | + private long lockSeconds; | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void loginConfigDefaultsBound() { | |
| 28 | + assertThat(maxFail).isEqualTo(5); | |
| 29 | + assertThat(lockSeconds).isEqualTo(300L); | |
| 30 | + } | |
| 31 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 5 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 7 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 8 | + | |
| 9 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 10 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 11 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrCompany; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 16 | +import java.nio.charset.StandardCharsets; | |
| 17 | +import java.time.LocalDateTime; | |
| 18 | +import java.util.Base64; | |
| 19 | +import java.util.HashMap; | |
| 20 | +import java.util.Map; | |
| 21 | +import org.junit.jupiter.api.AfterEach; | |
| 22 | +import org.junit.jupiter.api.BeforeEach; | |
| 23 | +import org.junit.jupiter.api.Test; | |
| 24 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 25 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 26 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 27 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 28 | +import org.springframework.test.context.ActiveProfiles; | |
| 29 | +import org.springframework.test.web.servlet.MockMvc; | |
| 30 | +import org.springframework.test.web.servlet.MvcResult; | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。 | |
| 34 | + * | |
| 35 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1); | |
| 36 | + * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture | |
| 37 | + * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。</p> | |
| 38 | + */ | |
| 39 | +@SpringBootTest | |
| 40 | +@AutoConfigureMockMvc | |
| 41 | +@ActiveProfiles("test") | |
| 42 | +class UsrLoginIT { | |
| 43 | + | |
| 44 | + private static final String USER_PREFIX = "it_login_"; | |
| 45 | + private static final String RL_USER_PREFIX = "it_login_rl_"; | |
| 46 | + private static final String CO_PREFIX = "IT_LOGIN_CO_"; | |
| 47 | + private static final String RAW_PWD = "666666"; | |
| 48 | + | |
| 49 | + @Autowired | |
| 50 | + private MockMvc mockMvc; | |
| 51 | + | |
| 52 | + @Autowired | |
| 53 | + private ObjectMapper objectMapper; | |
| 54 | + | |
| 55 | + @Autowired | |
| 56 | + private PasswordEncoder passwordEncoder; | |
| 57 | + | |
| 58 | + @Autowired | |
| 59 | + private UsrUserMapper usrUserMapper; | |
| 60 | + | |
| 61 | + @Autowired | |
| 62 | + private UsrCompanyMapper usrCompanyMapper; | |
| 63 | + | |
| 64 | + private Integer companyId; | |
| 65 | + | |
| 66 | + @BeforeEach | |
| 67 | + void seedCompany() { | |
| 68 | + UsrCompany c = new UsrCompany(); | |
| 69 | + c.setSCompanyName(CO_PREFIX + "总部"); | |
| 70 | + c.setSVersion("企业版"); | |
| 71 | + usrCompanyMapper.insert(c); | |
| 72 | + companyId = c.getIIncrement(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @AfterEach | |
| 76 | + void cleanup() { | |
| 77 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 78 | + .likeRight(UsrUser::getSUserName, USER_PREFIX)); | |
| 79 | + usrCompanyMapper.delete(Wrappers.<UsrCompany>lambdaQuery() | |
| 80 | + .likeRight(UsrCompany::getSCompanyName, CO_PREFIX)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private Integer insertUser(String userName, int isVoid) { | |
| 84 | + UsrUser u = new UsrUser(); | |
| 85 | + u.setSUserName(userName); | |
| 86 | + u.setSPassword(passwordEncoder.encode(RAW_PWD)); | |
| 87 | + u.setSUserType("普通用户"); | |
| 88 | + u.setSLanguage("中文"); | |
| 89 | + u.setICanModifyBill(0); | |
| 90 | + u.setIIsVoid(isVoid); | |
| 91 | + u.setSCreator("it_login_seed"); | |
| 92 | + usrUserMapper.insert(u); | |
| 93 | + return u.getIIncrement(); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private String loginBody(String userName, String password, Integer compId) throws Exception { | |
| 97 | + Map<String, Object> body = new HashMap<>(); | |
| 98 | + if (userName != null) { | |
| 99 | + body.put("sUserName", userName); | |
| 100 | + } | |
| 101 | + if (password != null) { | |
| 102 | + body.put("password", password); | |
| 103 | + } | |
| 104 | + if (compId != null) { | |
| 105 | + body.put("companyId", compId); | |
| 106 | + } | |
| 107 | + return objectMapper.writeValueAsString(body); | |
| 108 | + } | |
| 109 | + | |
| 110 | + private MvcResult doLogin(String userName, String password, Integer compId) throws Exception { | |
| 111 | + return mockMvc.perform(post("/api/usr/login") | |
| 112 | + .contentType("application/json") | |
| 113 | + .content(loginBody(userName, password, compId))) | |
| 114 | + .andReturn(); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void ac1LoginSuccess() throws Exception { | |
| 119 | + String userName = USER_PREFIX + "ac1"; | |
| 120 | + Integer id = insertUser(userName, 0); | |
| 121 | + | |
| 122 | + MvcResult result = mockMvc.perform(post("/api/usr/login") | |
| 123 | + .contentType("application/json") | |
| 124 | + .content(loginBody(userName, RAW_PWD, companyId))) | |
| 125 | + .andExpect(status().isOk()) | |
| 126 | + .andExpect(jsonPath("$.code").value(0)) | |
| 127 | + .andExpect(jsonPath("$.data.token").isNotEmpty()) | |
| 128 | + .andExpect(jsonPath("$.data.user.id").value(id)) | |
| 129 | + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) | |
| 130 | + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")) | |
| 131 | + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) | |
| 132 | + .andReturn(); | |
| 133 | + | |
| 134 | + String resp = result.getResponse().getContentAsString(); | |
| 135 | + assertThat(resp).doesNotContain("sPassword"); | |
| 136 | + assertThat(resp.toLowerCase()).doesNotContain("password"); | |
| 137 | + assertThat(resp).doesNotContain(RAW_PWD); | |
| 138 | + } | |
| 139 | + | |
| 140 | + @Test | |
| 141 | + void ac2TokenAcceptedByProtectedApi() throws Exception { | |
| 142 | + String userName = USER_PREFIX + "ac2"; | |
| 143 | + insertUser(userName, 0); | |
| 144 | + MvcResult login = doLogin(userName, RAW_PWD, companyId); | |
| 145 | + String token = objectMapper.readTree(login.getResponse().getContentAsString()) | |
| 146 | + .path("data").path("token").asText(); | |
| 147 | + assertThat(token).isNotBlank(); | |
| 148 | + | |
| 149 | + // 用 token 调受保护接口(REQ-USR-003)→ 非 401。 | |
| 150 | + mockMvc.perform(get("/api/usr/users") | |
| 151 | + .param("pageNum", "1").param("pageSize", "10") | |
| 152 | + .header("Authorization", "Bearer " + token)) | |
| 153 | + .andExpect(status().isOk()) | |
| 154 | + .andExpect(jsonPath("$.code").value(0)); | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 158 | + void ac3LoginUpdatesLastLoginDate() throws Exception { | |
| 159 | + String userName = USER_PREFIX + "ac3"; | |
| 160 | + Integer id = insertUser(userName, 0); | |
| 161 | + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); | |
| 162 | + | |
| 163 | + doLogin(userName, RAW_PWD, companyId); | |
| 164 | + | |
| 165 | + LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate(); | |
| 166 | + assertThat(after).isNotNull(); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + void ac4WrongPassword40101() throws Exception { | |
| 171 | + String userName = USER_PREFIX + "ac4"; | |
| 172 | + insertUser(userName, 0); | |
| 173 | + | |
| 174 | + MvcResult result = doLogin(userName, "wrongpwd", companyId); | |
| 175 | + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); | |
| 176 | + assertThat(resp.path("code").asInt()).isEqualTo(40101); | |
| 177 | + assertThat(resp.path("data").isNull()).isTrue(); | |
| 178 | + assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误"); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Test | |
| 182 | + void ac5UserNotFound40101SameAsWrongPassword() throws Exception { | |
| 183 | + String userName = USER_PREFIX + "ac5"; | |
| 184 | + insertUser(userName, 0); | |
| 185 | + | |
| 186 | + // 密码错误响应 | |
| 187 | + String wrongPwdMsg = objectMapper.readTree( | |
| 188 | + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()) | |
| 189 | + .path("message").asText(); | |
| 190 | + | |
| 191 | + // 用户不存在响应 | |
| 192 | + JsonNode notFound = objectMapper.readTree( | |
| 193 | + doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId) | |
| 194 | + .getResponse().getContentAsString()); | |
| 195 | + | |
| 196 | + assertThat(notFound.path("code").asInt()).isEqualTo(40101); | |
| 197 | + assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg); | |
| 198 | + } | |
| 199 | + | |
| 200 | + @Test | |
| 201 | + void ac6DisabledUser40302() throws Exception { | |
| 202 | + String userName = USER_PREFIX + "ac6"; | |
| 203 | + Integer id = insertUser(userName, 1); | |
| 204 | + | |
| 205 | + MvcResult result = doLogin(userName, RAW_PWD, companyId); | |
| 206 | + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); | |
| 207 | + assertThat(resp.path("code").asInt()).isEqualTo(40302); | |
| 208 | + assertThat(resp.path("data").path("token").isMissingNode() | |
| 209 | + || resp.path("data").isNull()).isTrue(); | |
| 210 | + | |
| 211 | + // 禁用登录不更新 tLastLoginDate。 | |
| 212 | + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); | |
| 213 | + } | |
| 214 | + | |
| 215 | + @Test | |
| 216 | + void ac7MissingParam40001() throws Exception { | |
| 217 | + String userName = USER_PREFIX + "ac7"; | |
| 218 | + insertUser(userName, 0); | |
| 219 | + | |
| 220 | + // 缺 sUserName | |
| 221 | + assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId) | |
| 222 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 223 | + // 缺 password | |
| 224 | + assertThat(objectMapper.readTree(doLogin(userName, null, companyId) | |
| 225 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 226 | + // 缺 companyId | |
| 227 | + assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null) | |
| 228 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); | |
| 229 | + } | |
| 230 | + | |
| 231 | + @Test | |
| 232 | + void ac8IllegalCompanyId40001() throws Exception { | |
| 233 | + String userName = USER_PREFIX + "ac8"; | |
| 234 | + insertUser(userName, 0); | |
| 235 | + | |
| 236 | + JsonNode resp = objectMapper.readTree( | |
| 237 | + doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString()); | |
| 238 | + assertThat(resp.path("code").asInt()).isEqualTo(40001); | |
| 239 | + } | |
| 240 | + | |
| 241 | + @Test | |
| 242 | + void ac9RateLimitAfter5Fails42901() throws Exception { | |
| 243 | + String userName = RL_USER_PREFIX + "ac9"; | |
| 244 | + insertUser(userName, 0); | |
| 245 | + | |
| 246 | + // 连续 5 次密码错误 → 每次 40101 | |
| 247 | + for (int i = 0; i < 5; i++) { | |
| 248 | + JsonNode resp = objectMapper.readTree( | |
| 249 | + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()); | |
| 250 | + assertThat(resp.path("code").asInt()).isEqualTo(40101); | |
| 251 | + } | |
| 252 | + // 第 6 次即使密码正确也 42901(锁定窗内) | |
| 253 | + JsonNode locked = objectMapper.readTree( | |
| 254 | + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()); | |
| 255 | + assertThat(locked.path("code").asInt()).isEqualTo(42901); | |
| 256 | + | |
| 257 | + // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。 | |
| 258 | + String resetUser = RL_USER_PREFIX + "ac9_reset"; | |
| 259 | + insertUser(resetUser, 0); | |
| 260 | + for (int i = 0; i < 3; i++) { | |
| 261 | + doLogin(resetUser, "wrongpwd", companyId); | |
| 262 | + } | |
| 263 | + // 一次成功清零 | |
| 264 | + assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId) | |
| 265 | + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0); | |
| 266 | + // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数) | |
| 267 | + JsonNode afterReset = objectMapper.readTree( | |
| 268 | + doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString()); | |
| 269 | + assertThat(afterReset.path("code").asInt()).isEqualTo(40101); | |
| 270 | + } | |
| 271 | + | |
| 272 | + @Test | |
| 273 | + void ac10CompaniesListNoToken() throws Exception { | |
| 274 | + mockMvc.perform(get("/api/usr/companies")) | |
| 275 | + .andExpect(status().isOk()) | |
| 276 | + .andExpect(jsonPath("$.code").value(0)) | |
| 277 | + .andExpect(jsonPath("$.data").isArray()) | |
| 278 | + .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists()); | |
| 279 | + } | |
| 280 | + | |
| 281 | + @Test | |
| 282 | + void ac11PasswordNeverLeaks() throws Exception { | |
| 283 | + String userName = USER_PREFIX + "ac11"; | |
| 284 | + insertUser(userName, 0); | |
| 285 | + | |
| 286 | + String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString(); | |
| 287 | + String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString(); | |
| 288 | + | |
| 289 | + for (String resp : new String[] {ok, fail}) { | |
| 290 | + assertThat(resp).doesNotContain("sPassword"); | |
| 291 | + assertThat(resp.toLowerCase()).doesNotContain("password"); | |
| 292 | + assertThat(resp).doesNotContain(RAW_PWD); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + @Test | |
| 297 | + void ac12JwtHasExpiry() throws Exception { | |
| 298 | + String userName = USER_PREFIX + "ac12"; | |
| 299 | + insertUser(userName, 0); | |
| 300 | + | |
| 301 | + String token = objectMapper.readTree( | |
| 302 | + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()) | |
| 303 | + .path("data").path("token").asText(); | |
| 304 | + assertThat(token).isNotBlank(); | |
| 305 | + | |
| 306 | + // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。 | |
| 307 | + String[] segments = token.split("\\."); | |
| 308 | + assertThat(segments.length).isEqualTo(3); | |
| 309 | + String payloadJson = new String( | |
| 310 | + Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8); | |
| 311 | + JsonNode payload = objectMapper.readTree(payloadJson); | |
| 312 | + assertThat(payload.has("exp")).isTrue(); | |
| 313 | + assertThat(payload.has("iat")).isTrue(); | |
| 314 | + assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong()); | |
| 315 | + } | |
| 316 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 5 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 7 | + | |
| 8 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 9 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 10 | +import com.xly.erp.common.security.JwtUtil; | |
| 11 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 16 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 17 | +import java.util.HashMap; | |
| 18 | +import java.util.List; | |
| 19 | +import java.util.Map; | |
| 20 | +import org.junit.jupiter.api.AfterEach; | |
| 21 | +import org.junit.jupiter.api.Test; | |
| 22 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 23 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 24 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 25 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 26 | +import org.springframework.test.context.ActiveProfiles; | |
| 27 | +import org.springframework.test.web.servlet.MockMvc; | |
| 28 | +import org.springframework.test.web.servlet.MvcResult; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * REQ-USR-001 T8:新增用户端到端验收回归(spec § 7)。 | |
| 32 | + * | |
| 33 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 34 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入; | |
| 35 | + * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。</p> | |
| 36 | + */ | |
| 37 | +@SpringBootTest | |
| 38 | +@AutoConfigureMockMvc | |
| 39 | +@ActiveProfiles("test") | |
| 40 | +class UsrUserCreateIT { | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + private MockMvc mockMvc; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + private ObjectMapper objectMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + private JwtUtil jwtUtil; | |
| 50 | + | |
| 51 | + @Autowired | |
| 52 | + private PasswordEncoder passwordEncoder; | |
| 53 | + | |
| 54 | + @Autowired | |
| 55 | + private UsrUserMapper usrUserMapper; | |
| 56 | + | |
| 57 | + @Autowired | |
| 58 | + private UsrUserPermissionMapper usrUserPermissionMapper; | |
| 59 | + | |
| 60 | + @Autowired | |
| 61 | + private UsrPermissionMapper usrPermissionMapper; | |
| 62 | + | |
| 63 | + private static final String CALLER = "it_admin"; | |
| 64 | + | |
| 65 | + @AfterEach | |
| 66 | + void cleanup() { | |
| 67 | + // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。 | |
| 68 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 69 | + .likeRight(UsrUser::getSUserName, "it_user_")); | |
| 70 | + usrPermissionMapper.delete(Wrappers.<UsrPermission>lambdaQuery() | |
| 71 | + .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_")); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private String adminToken() { | |
| 75 | + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private String normalToken() { | |
| 79 | + return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户"); | |
| 80 | + } | |
| 81 | + | |
| 82 | + private Map<String, Object> baseBody(String userName) { | |
| 83 | + Map<String, Object> body = new HashMap<>(); | |
| 84 | + body.put("sUserName", userName); | |
| 85 | + body.put("sLanguage", "中文"); | |
| 86 | + return body; | |
| 87 | + } | |
| 88 | + | |
| 89 | + private int insertPermissionFixture(String codeSuffix) { | |
| 90 | + UsrPermission perm = new UsrPermission(); | |
| 91 | + perm.setSPermissionName("IT权限" + codeSuffix); | |
| 92 | + perm.setSPermissionCode("IT_PERM_" + codeSuffix); | |
| 93 | + usrPermissionMapper.insert(perm); | |
| 94 | + return perm.getIIncrement(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void ac1NormalCreatePersistsHashedPassword() throws Exception { | |
| 99 | + String userName = "it_user_ac1"; | |
| 100 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 101 | + .header("Authorization", adminToken()) | |
| 102 | + .contentType("application/json") | |
| 103 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 104 | + .andExpect(status().isOk()) | |
| 105 | + .andExpect(jsonPath("$.code").value(0)) | |
| 106 | + .andExpect(jsonPath("$.data.id").isNumber()) | |
| 107 | + .andReturn(); | |
| 108 | + | |
| 109 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 110 | + Map<?, ?> data = (Map<?, ?>) resp.get("data"); | |
| 111 | + Integer newId = (Integer) data.get("id"); | |
| 112 | + | |
| 113 | + UsrUser saved = usrUserMapper.selectById(newId); | |
| 114 | + assertThat(saved).isNotNull(); | |
| 115 | + assertThat(saved.getSPassword()).isNotEqualTo("666666"); | |
| 116 | + assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue(); | |
| 117 | + assertThat(saved.getIIsVoid()).isZero(); | |
| 118 | + assertThat(saved.getSCreator()).isEqualTo(CALLER); | |
| 119 | + assertThat(saved.getTCreateDate()).isNotNull(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test | |
| 123 | + void ac2DuplicateUserNameRollsBack() throws Exception { | |
| 124 | + String userName = "it_user_ac2"; | |
| 125 | + mockMvc.perform(post("/api/usr/users") | |
| 126 | + .header("Authorization", adminToken()) | |
| 127 | + .contentType("application/json") | |
| 128 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 129 | + .andExpect(jsonPath("$.code").value(0)); | |
| 130 | + | |
| 131 | + long before = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 132 | + .eq(UsrUser::getSUserName, userName)); | |
| 133 | + | |
| 134 | + mockMvc.perform(post("/api/usr/users") | |
| 135 | + .header("Authorization", adminToken()) | |
| 136 | + .contentType("application/json") | |
| 137 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 138 | + .andExpect(jsonPath("$.code").value(40901)); | |
| 139 | + | |
| 140 | + long after = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 141 | + .eq(UsrUser::getSUserName, userName)); | |
| 142 | + assertThat(after).isEqualTo(before).isEqualTo(1L); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void ac4PermissionGrantWritesRows() throws Exception { | |
| 147 | + int permA = insertPermissionFixture("A"); | |
| 148 | + int permB = insertPermissionFixture("B"); | |
| 149 | + String userName = "it_user_ac4"; | |
| 150 | + Map<String, Object> body = baseBody(userName); | |
| 151 | + body.put("permissionIds", List.of(permA, permB, permA)); | |
| 152 | + | |
| 153 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 154 | + .header("Authorization", adminToken()) | |
| 155 | + .contentType("application/json") | |
| 156 | + .content(objectMapper.writeValueAsString(body))) | |
| 157 | + .andExpect(jsonPath("$.code").value(0)) | |
| 158 | + .andReturn(); | |
| 159 | + | |
| 160 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 161 | + Integer newId = (Integer) ((Map<?, ?>) resp.get("data")).get("id"); | |
| 162 | + | |
| 163 | + long grants = usrUserPermissionMapper.selectCount(Wrappers.<UsrUserPermission>lambdaQuery() | |
| 164 | + .eq(UsrUserPermission::getIUserId, newId)); | |
| 165 | + assertThat(grants).isEqualTo(2L); | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Test | |
| 169 | + void ac5NonAdminForbidden() throws Exception { | |
| 170 | + String userName = "it_user_ac5"; | |
| 171 | + | |
| 172 | + // 普通用户 token → 40301 | |
| 173 | + mockMvc.perform(post("/api/usr/users") | |
| 174 | + .header("Authorization", normalToken()) | |
| 175 | + .contentType("application/json") | |
| 176 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 177 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 178 | + | |
| 179 | + // 无 token → 401(安全链拦截,未认证) | |
| 180 | + mockMvc.perform(post("/api/usr/users") | |
| 181 | + .contentType("application/json") | |
| 182 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 183 | + .andExpect(status().isUnauthorized()); | |
| 184 | + | |
| 185 | + long created = usrUserMapper.selectCount(Wrappers.<UsrUser>lambdaQuery() | |
| 186 | + .eq(UsrUser::getSUserName, userName)); | |
| 187 | + assertThat(created).isZero(); | |
| 188 | + } | |
| 189 | + | |
| 190 | + @Test | |
| 191 | + void ac7ResponseHasNoPassword() throws Exception { | |
| 192 | + String userName = "it_user_ac7"; | |
| 193 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 194 | + .header("Authorization", adminToken()) | |
| 195 | + .contentType("application/json") | |
| 196 | + .content(objectMapper.writeValueAsString(baseBody(userName)))) | |
| 197 | + .andExpect(jsonPath("$.code").value(0)) | |
| 198 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 199 | + .andExpect(jsonPath("$.data.password").doesNotExist()) | |
| 200 | + .andReturn(); | |
| 201 | + | |
| 202 | + String responseBody = result.getResponse().getContentAsString(); | |
| 203 | + assertThat(responseBody).doesNotContain("666666"); | |
| 204 | + assertThat(responseBody.toLowerCase()).doesNotContain("password"); | |
| 205 | + } | |
| 206 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 5 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 7 | + | |
| 8 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 9 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 10 | +import com.xly.erp.common.security.JwtUtil; | |
| 11 | +import com.xly.erp.modules.usr.entity.UsrEmployee; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 13 | +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; | |
| 14 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 15 | +import java.time.LocalDateTime; | |
| 16 | +import org.junit.jupiter.api.AfterEach; | |
| 17 | +import org.junit.jupiter.api.BeforeEach; | |
| 18 | +import org.junit.jupiter.api.Test; | |
| 19 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 20 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 21 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 22 | +import org.springframework.test.context.ActiveProfiles; | |
| 23 | +import org.springframework.test.web.servlet.MockMvc; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * REQ-USR-003 T6:查询用户端到端验收回归(spec § 7 AC1-AC13)。 | |
| 27 | + * | |
| 28 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 29 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;查询无管理员限制 | |
| 30 | + * (任意已认证用户可调用,spec § 8 D5)。每个用例自管理 fixture 清理(命名前缀 | |
| 31 | + * {@code it3_user_} / {@code IT3职员} / {@code IT3_EMP_})。</p> | |
| 32 | + */ | |
| 33 | +@SpringBootTest | |
| 34 | +@AutoConfigureMockMvc | |
| 35 | +@ActiveProfiles("test") | |
| 36 | +class UsrUserQueryIT { | |
| 37 | + | |
| 38 | + private static final String USER_PREFIX = "it3_user_"; | |
| 39 | + private static final String EMP_NAME_PREFIX = "IT3职员"; | |
| 40 | + private static final String EMP_NO_PREFIX = "IT3_EMP_"; | |
| 41 | + | |
| 42 | + @Autowired | |
| 43 | + private MockMvc mockMvc; | |
| 44 | + | |
| 45 | + @Autowired | |
| 46 | + private ObjectMapper objectMapper; | |
| 47 | + | |
| 48 | + @Autowired | |
| 49 | + private JwtUtil jwtUtil; | |
| 50 | + | |
| 51 | + @Autowired | |
| 52 | + private UsrUserMapper usrUserMapper; | |
| 53 | + | |
| 54 | + @Autowired | |
| 55 | + private UsrEmployeeMapper usrEmployeeMapper; | |
| 56 | + | |
| 57 | + @BeforeEach | |
| 58 | + void cleanupBefore() { | |
| 59 | + purge(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @AfterEach | |
| 63 | + void cleanupAfter() { | |
| 64 | + purge(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + private void purge() { | |
| 68 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 69 | + .likeRight(UsrUser::getSUserName, USER_PREFIX)); | |
| 70 | + usrEmployeeMapper.delete(Wrappers.<UsrEmployee>lambdaQuery() | |
| 71 | + .likeRight(UsrEmployee::getSEmployeeName, EMP_NAME_PREFIX)); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private String token() { | |
| 75 | + return "Bearer " + jwtUtil.generateToken("it3_caller", "普通用户"); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private Integer seedEmployee(String nameSuffix, String dept) { | |
| 79 | + UsrEmployee emp = new UsrEmployee(); | |
| 80 | + emp.setSEmployeeName(EMP_NAME_PREFIX + nameSuffix); | |
| 81 | + emp.setSEmployeeNo(EMP_NO_PREFIX + nameSuffix); | |
| 82 | + emp.setSDepartment(dept); | |
| 83 | + usrEmployeeMapper.insert(emp); | |
| 84 | + return emp.getIIncrement(); | |
| 85 | + } | |
| 86 | + | |
| 87 | + private UsrUser seedUser(String nameSuffix, String creator, String userType) { | |
| 88 | + UsrUser u = new UsrUser(); | |
| 89 | + u.setSUserName(USER_PREFIX + nameSuffix); | |
| 90 | + u.setSPassword("$2a$dummyhashvalue000000000000000000000"); | |
| 91 | + u.setSUserType(userType); | |
| 92 | + u.setSLanguage("中文"); | |
| 93 | + u.setICanModifyBill(0); | |
| 94 | + u.setIIsVoid(0); | |
| 95 | + u.setSCreator(creator); | |
| 96 | + usrUserMapper.insert(u); | |
| 97 | + return u; | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + void ac1EmptyConditionFullPage() throws Exception { | |
| 102 | + seedUser("a1x", "it3_creator", "普通用户"); | |
| 103 | + seedUser("a1y", "it3_creator", "普通用户"); | |
| 104 | + | |
| 105 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 106 | + .param("pageNum", "1").param("pageSize", "10")) | |
| 107 | + .andExpect(status().isOk()) | |
| 108 | + .andExpect(jsonPath("$.code").value(0)) | |
| 109 | + .andExpect(jsonPath("$.data.pageNum").value(1)) | |
| 110 | + .andExpect(jsonPath("$.data.pageSize").value(10)) | |
| 111 | + .andExpect(jsonPath("$.data.records").isArray()) | |
| 112 | + .andExpect(jsonPath("$.data.records[0].sUserName").exists()) | |
| 113 | + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) | |
| 114 | + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void ac2TextContains() throws Exception { | |
| 119 | + seedUser("contains_target", "it3_creator", "普通用户"); | |
| 120 | + seedUser("other_one", "it3_creator", "普通用户"); | |
| 121 | + | |
| 122 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 123 | + .param("queryField", "用户名").param("matchType", "包含") | |
| 124 | + .param("queryValue", "contains_target")) | |
| 125 | + .andExpect(jsonPath("$.code").value(0)) | |
| 126 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "contains_target')]").exists()) | |
| 127 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "other_one')]").doesNotExist()); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + void ac3TextEquals() throws Exception { | |
| 132 | + seedUser("eq", "it3_creator", "普通用户"); | |
| 133 | + seedUser("eq_suffix", "it3_creator", "普通用户"); | |
| 134 | + String exact = USER_PREFIX + "eq"; | |
| 135 | + | |
| 136 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 137 | + .param("queryField", "用户名").param("matchType", "等于") | |
| 138 | + .param("queryValue", exact)) | |
| 139 | + .andExpect(jsonPath("$.code").value(0)) | |
| 140 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 141 | + .andExpect(jsonPath("$.data.records[0].sUserName").value(exact)); | |
| 142 | + } | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + void ac4TextNotContains() throws Exception { | |
| 146 | + seedUser("nc1", "it3_creator_keep", "普通用户"); | |
| 147 | + seedUser("nc2", "it3_creator_drop", "普通用户"); | |
| 148 | + | |
| 149 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 150 | + .param("queryField", "制单人").param("matchType", "不包含") | |
| 151 | + .param("queryValue", "drop").param("pageSize", "100")) | |
| 152 | + .andExpect(jsonPath("$.code").value(0)) | |
| 153 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc1')]").exists()) | |
| 154 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc2')]").doesNotExist()); | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 158 | + void ac5EnumEquals() throws Exception { | |
| 159 | + seedUser("admin_u", "it3_creator", "超级管理员"); | |
| 160 | + seedUser("normal_u", "it3_creator", "普通用户"); | |
| 161 | + | |
| 162 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 163 | + .param("queryField", "用户类型").param("matchType", "等于") | |
| 164 | + .param("queryValue", "超级管理员").param("pageSize", "100")) | |
| 165 | + .andExpect(jsonPath("$.code").value(0)) | |
| 166 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "admin_u')]").exists()) | |
| 167 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "normal_u')]").doesNotExist()); | |
| 168 | + } | |
| 169 | + | |
| 170 | + @Test | |
| 171 | + void ac6BoolFilter() throws Exception { | |
| 172 | + UsrUser voided = seedUser("voided", "it3_creator", "普通用户"); | |
| 173 | + voided.setIIsVoid(1); | |
| 174 | + usrUserMapper.updateById(voided); | |
| 175 | + seedUser("active", "it3_creator", "普通用户"); | |
| 176 | + | |
| 177 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 178 | + .param("queryField", "作废").param("queryValue", "1").param("pageSize", "100")) | |
| 179 | + .andExpect(jsonPath("$.code").value(0)) | |
| 180 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").exists()) | |
| 181 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").doesNotExist()); | |
| 182 | + | |
| 183 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 184 | + .param("queryField", "作废").param("queryValue", "0").param("pageSize", "100")) | |
| 185 | + .andExpect(jsonPath("$.code").value(0)) | |
| 186 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").exists()) | |
| 187 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").doesNotExist()); | |
| 188 | + | |
| 189 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 190 | + .param("queryField", "作废").param("queryValue", "abc")) | |
| 191 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 192 | + } | |
| 193 | + | |
| 194 | + @Test | |
| 195 | + void ac7DateFilter() throws Exception { | |
| 196 | + UsrUser dated = seedUser("dated", "it3_creator", "普通用户"); | |
| 197 | + dated.setTLastLoginDate(LocalDateTime.of(2026, 6, 1, 12, 0, 0)); | |
| 198 | + usrUserMapper.updateById(dated); | |
| 199 | + | |
| 200 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 201 | + .param("queryField", "登录日期").param("matchType", "等于") | |
| 202 | + .param("queryValue", "2026-06-01").param("pageSize", "100")) | |
| 203 | + .andExpect(jsonPath("$.code").value(0)) | |
| 204 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "dated')]").exists()); | |
| 205 | + | |
| 206 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 207 | + .param("queryField", "登录日期").param("matchType", "等于") | |
| 208 | + .param("queryValue", "2026-13-99")) | |
| 209 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 210 | + } | |
| 211 | + | |
| 212 | + @Test | |
| 213 | + void ac8CrossTableEmployeeDept() throws Exception { | |
| 214 | + Integer empId = seedEmployee("fin", "财务部IT3"); | |
| 215 | + UsrUser linked = seedUser("linked", "it3_creator", "普通用户"); | |
| 216 | + linked.setIEmployeeId(empId); | |
| 217 | + usrUserMapper.updateById(linked); | |
| 218 | + seedUser("unlinked", "it3_creator", "普通用户"); | |
| 219 | + | |
| 220 | + // 全量:关联用户带员工名 / 部门,未关联用户两列为 null。 | |
| 221 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "100")) | |
| 222 | + .andExpect(jsonPath("$.code").value(0)) | |
| 223 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX | |
| 224 | + + "linked')].department").value(org.hamcrest.Matchers.hasItem("财务部IT3"))) | |
| 225 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX | |
| 226 | + + "linked')].employeeName").value(org.hamcrest.Matchers.hasItem(EMP_NAME_PREFIX + "fin"))); | |
| 227 | + | |
| 228 | + // 按部门跨表过滤:仅返回所属部门含「财务」的用户。 | |
| 229 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 230 | + .param("queryField", "部门").param("matchType", "包含") | |
| 231 | + .param("queryValue", "财务").param("pageSize", "100")) | |
| 232 | + .andExpect(jsonPath("$.code").value(0)) | |
| 233 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "linked')]").exists()) | |
| 234 | + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "unlinked')]").doesNotExist()); | |
| 235 | + } | |
| 236 | + | |
| 237 | + @Test | |
| 238 | + void ac9NoMatchEmptyList() throws Exception { | |
| 239 | + seedUser("present", "it3_creator", "普通用户"); | |
| 240 | + | |
| 241 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 242 | + .param("queryField", "用户名").param("matchType", "等于") | |
| 243 | + .param("queryValue", "it3_user_zzz_no_such_user_xyz")) | |
| 244 | + .andExpect(jsonPath("$.code").value(0)) | |
| 245 | + .andExpect(jsonPath("$.data.total").value(0)) | |
| 246 | + .andExpect(jsonPath("$.data.records").isEmpty()); | |
| 247 | + } | |
| 248 | + | |
| 249 | + @Test | |
| 250 | + void ac10DataOutOfRangeLastPage() throws Exception { | |
| 251 | + // 插 3 条同制单人 fixture,按制单人精确过滤 → total=3,pageSize=2 → 2 页;请求 page99 钳到末页 2。 | |
| 252 | + seedUser("clamp1", "it3_clamp_creator", "普通用户"); | |
| 253 | + seedUser("clamp2", "it3_clamp_creator", "普通用户"); | |
| 254 | + seedUser("clamp3", "it3_clamp_creator", "普通用户"); | |
| 255 | + | |
| 256 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 257 | + .param("queryField", "制单人").param("matchType", "等于") | |
| 258 | + .param("queryValue", "it3_clamp_creator") | |
| 259 | + .param("pageNum", "99").param("pageSize", "2")) | |
| 260 | + .andExpect(jsonPath("$.code").value(0)) | |
| 261 | + .andExpect(jsonPath("$.data.total").value(3)) | |
| 262 | + .andExpect(jsonPath("$.data.pageNum").value(2)) | |
| 263 | + .andExpect(jsonPath("$.data.records.length()").value(1)); | |
| 264 | + } | |
| 265 | + | |
| 266 | + @Test | |
| 267 | + void ac11PageParamInvalid() throws Exception { | |
| 268 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageNum", "0")) | |
| 269 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 270 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "0")) | |
| 271 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 272 | + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "500")) | |
| 273 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 274 | + } | |
| 275 | + | |
| 276 | + @Test | |
| 277 | + void ac12NoToken() throws Exception { | |
| 278 | + mockMvc.perform(get("/api/usr/users")) | |
| 279 | + .andExpect(status().isUnauthorized()); | |
| 280 | + mockMvc.perform(get("/api/usr/users").header("Authorization", "Bearer invalid.token.value")) | |
| 281 | + .andExpect(status().isUnauthorized()); | |
| 282 | + } | |
| 283 | + | |
| 284 | + @Test | |
| 285 | + void ac13PasswordNeverLeaks() throws Exception { | |
| 286 | + seedUser("leakcheck", "it3_creator", "普通用户"); | |
| 287 | + String body = mockMvc.perform(get("/api/usr/users").header("Authorization", token()) | |
| 288 | + .param("pageSize", "100")) | |
| 289 | + .andExpect(jsonPath("$.code").value(0)) | |
| 290 | + .andReturn().getResponse().getContentAsString(); | |
| 291 | + | |
| 292 | + assertThat(body).doesNotContain("sPassword"); | |
| 293 | + assertThat(body.toLowerCase()).doesNotContain("password"); | |
| 294 | + assertThat(body).doesNotContain("$2a$"); | |
| 295 | + } | |
| 296 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 5 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 6 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 7 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 8 | + | |
| 9 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | |
| 10 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 11 | +import com.xly.erp.common.security.JwtUtil; | |
| 12 | +import com.xly.erp.modules.usr.entity.UsrPermission; | |
| 13 | +import com.xly.erp.modules.usr.entity.UsrUser; | |
| 14 | +import com.xly.erp.modules.usr.entity.UsrUserPermission; | |
| 15 | +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; | |
| 16 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | |
| 17 | +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; | |
| 18 | +import java.util.HashMap; | |
| 19 | +import java.util.List; | |
| 20 | +import java.util.Map; | |
| 21 | +import org.junit.jupiter.api.AfterEach; | |
| 22 | +import org.junit.jupiter.api.Test; | |
| 23 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 24 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 25 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 26 | +import org.springframework.test.context.ActiveProfiles; | |
| 27 | +import org.springframework.test.web.servlet.MockMvc; | |
| 28 | +import org.springframework.test.web.servlet.MvcResult; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * REQ-USR-002 T5:修改用户端到端验收回归(spec § 7)。 | |
| 32 | + * | |
| 33 | + * <p>@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 | |
| 34 | + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;真实库读写做端到端确认。 | |
| 35 | + * 每个用例自管理 fixture 清理(命名前缀 {@code it2_user_} / {@code IT2_PERM_})。</p> | |
| 36 | + * | |
| 37 | + * <p>边界说明(spec § 7):AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / | |
| 38 | + * REQ-USR-003 查询接口,尚未实现;本 IT 以「PUT iIsVoid=1 / 改 sUserType 后 selectById | |
| 39 | + * 读回库内 iIsVoid==1 / sUserType 为新值」做后端落库层等价验证,登录 / 查询联动留待对应 REQ | |
| 40 | + * 的 IT 覆盖。</p> | |
| 41 | + */ | |
| 42 | +@SpringBootTest | |
| 43 | +@AutoConfigureMockMvc | |
| 44 | +@ActiveProfiles("test") | |
| 45 | +class UsrUserUpdateIT { | |
| 46 | + | |
| 47 | + @Autowired | |
| 48 | + private MockMvc mockMvc; | |
| 49 | + | |
| 50 | + @Autowired | |
| 51 | + private ObjectMapper objectMapper; | |
| 52 | + | |
| 53 | + @Autowired | |
| 54 | + private JwtUtil jwtUtil; | |
| 55 | + | |
| 56 | + @Autowired | |
| 57 | + private UsrUserMapper usrUserMapper; | |
| 58 | + | |
| 59 | + @Autowired | |
| 60 | + private UsrUserPermissionMapper usrUserPermissionMapper; | |
| 61 | + | |
| 62 | + @Autowired | |
| 63 | + private UsrPermissionMapper usrPermissionMapper; | |
| 64 | + | |
| 65 | + private static final String CALLER = "it2_admin"; | |
| 66 | + | |
| 67 | + @AfterEach | |
| 68 | + void cleanup() { | |
| 69 | + usrUserMapper.delete(Wrappers.<UsrUser>lambdaQuery() | |
| 70 | + .likeRight(UsrUser::getSUserName, "it2_user_")); | |
| 71 | + usrPermissionMapper.delete(Wrappers.<UsrPermission>lambdaQuery() | |
| 72 | + .likeRight(UsrPermission::getSPermissionCode, "IT2_PERM_")); | |
| 73 | + } | |
| 74 | + | |
| 75 | + private String adminToken() { | |
| 76 | + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); | |
| 77 | + } | |
| 78 | + | |
| 79 | + private String normalToken() { | |
| 80 | + return "Bearer " + jwtUtil.generateToken("it2_normal", "普通用户"); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private Map<String, Object> createBody(String userName) { | |
| 84 | + Map<String, Object> body = new HashMap<>(); | |
| 85 | + body.put("sUserName", userName); | |
| 86 | + body.put("sLanguage", "中文"); | |
| 87 | + return body; | |
| 88 | + } | |
| 89 | + | |
| 90 | + private int createUser(String userName) throws Exception { | |
| 91 | + MvcResult result = mockMvc.perform(post("/api/usr/users") | |
| 92 | + .header("Authorization", adminToken()) | |
| 93 | + .contentType("application/json") | |
| 94 | + .content(objectMapper.writeValueAsString(createBody(userName)))) | |
| 95 | + .andExpect(jsonPath("$.code").value(0)) | |
| 96 | + .andReturn(); | |
| 97 | + Map<?, ?> resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); | |
| 98 | + return (Integer) ((Map<?, ?>) resp.get("data")).get("id"); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private int insertPermissionFixture(String codeSuffix) { | |
| 102 | + UsrPermission perm = new UsrPermission(); | |
| 103 | + perm.setSPermissionName("IT2权限" + codeSuffix); | |
| 104 | + perm.setSPermissionCode("IT2_PERM_" + codeSuffix); | |
| 105 | + usrPermissionMapper.insert(perm); | |
| 106 | + return perm.getIIncrement(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // ---------------- AC1:基本信息修改落库,且身份 / 密码 / 审计列不变 ---------------- | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + void ac1UpdateBasicInfoPersists() throws Exception { | |
| 113 | + int id = createUser("it2_user_ac1"); | |
| 114 | + UsrUser before = usrUserMapper.selectById(id); | |
| 115 | + | |
| 116 | + Map<String, Object> body = new HashMap<>(); | |
| 117 | + body.put("sUserType", "超级管理员"); | |
| 118 | + body.put("sLanguage", "英文"); | |
| 119 | + body.put("iCanModifyBill", 1); | |
| 120 | + body.put("sUserNo", "NO-AC1"); | |
| 121 | + | |
| 122 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 123 | + .header("Authorization", adminToken()) | |
| 124 | + .contentType("application/json") | |
| 125 | + .content(objectMapper.writeValueAsString(body))) | |
| 126 | + .andExpect(status().isOk()) | |
| 127 | + .andExpect(jsonPath("$.code").value(0)) | |
| 128 | + .andExpect(jsonPath("$.data.id").value(id)); | |
| 129 | + | |
| 130 | + UsrUser after = usrUserMapper.selectById(id); | |
| 131 | + assertThat(after.getSUserType()).isEqualTo("超级管理员"); | |
| 132 | + assertThat(after.getSLanguage()).isEqualTo("英文"); | |
| 133 | + assertThat(after.getICanModifyBill()).isEqualTo(1); | |
| 134 | + assertThat(after.getSUserNo()).isEqualTo("NO-AC1"); | |
| 135 | + // 身份 / 密码 / 审计列字节级不变。 | |
| 136 | + assertThat(after.getSUserName()).isEqualTo(before.getSUserName()); | |
| 137 | + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); | |
| 138 | + assertThat(after.getSCreator()).isEqualTo(before.getSCreator()); | |
| 139 | + assertThat(after.getTCreateDate()).isEqualTo(before.getTCreateDate()); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // ---------------- AC2:目标用户不存在 → 40401,无写入 ---------------- | |
| 143 | + | |
| 144 | + @Test | |
| 145 | + void ac2UpdateNonExistentReturns40401() throws Exception { | |
| 146 | + Map<String, Object> body = new HashMap<>(); | |
| 147 | + body.put("sUserType", "普通用户"); | |
| 148 | + body.put("sLanguage", "中文"); | |
| 149 | + | |
| 150 | + mockMvc.perform(put("/api/usr/users/2000000001") | |
| 151 | + .header("Authorization", adminToken()) | |
| 152 | + .contentType("application/json") | |
| 153 | + .content(objectMapper.writeValueAsString(body))) | |
| 154 | + .andExpect(status().isOk()) | |
| 155 | + .andExpect(jsonPath("$.code").value(40401)); | |
| 156 | + | |
| 157 | + assertThat(usrUserMapper.selectById(2000000001)).isNull(); | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ---------------- AC3:非法参数(不存在职员)→ 40001,整体回滚无副作用 ---------------- | |
| 161 | + | |
| 162 | + @Test | |
| 163 | + void ac3InvalidParamRollsBack() throws Exception { | |
| 164 | + int id = createUser("it2_user_ac3"); | |
| 165 | + UsrUser before = usrUserMapper.selectById(id); | |
| 166 | + | |
| 167 | + Map<String, Object> body = new HashMap<>(); | |
| 168 | + body.put("sUserType", "超级管理员"); | |
| 169 | + body.put("sLanguage", "英文"); | |
| 170 | + body.put("iEmployeeId", 2000000002); // 不存在的职员 | |
| 171 | + | |
| 172 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 173 | + .header("Authorization", adminToken()) | |
| 174 | + .contentType("application/json") | |
| 175 | + .content(objectMapper.writeValueAsString(body))) | |
| 176 | + .andExpect(status().isOk()) | |
| 177 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 178 | + | |
| 179 | + UsrUser after = usrUserMapper.selectById(id); | |
| 180 | + // 目标用户行各列与调用前一致(无副作用)。 | |
| 181 | + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); | |
| 182 | + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); | |
| 183 | + assertThat(after.getIEmployeeId()).isEqualTo(before.getIEmployeeId()); | |
| 184 | + } | |
| 185 | + | |
| 186 | + // ---------------- AC6:权限组全量覆盖 / 清空 / 不改 ---------------- | |
| 187 | + | |
| 188 | + @Test | |
| 189 | + void ac6PermissionOverwrite() throws Exception { | |
| 190 | + int id = createUser("it2_user_ac6"); | |
| 191 | + int permA = insertPermissionFixture("A"); | |
| 192 | + int permB = insertPermissionFixture("B"); | |
| 193 | + int permC = insertPermissionFixture("C"); | |
| 194 | + // 预置该用户授权为 {a, c} | |
| 195 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); | |
| 196 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permC)); | |
| 197 | + | |
| 198 | + Map<String, Object> body = new HashMap<>(); | |
| 199 | + body.put("sUserType", "普通用户"); | |
| 200 | + body.put("sLanguage", "中文"); | |
| 201 | + body.put("permissionIds", List.of(permA, permB, permA)); // 去重后 {a, b} | |
| 202 | + | |
| 203 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 204 | + .header("Authorization", adminToken()) | |
| 205 | + .contentType("application/json") | |
| 206 | + .content(objectMapper.writeValueAsString(body))) | |
| 207 | + .andExpect(jsonPath("$.code").value(0)); | |
| 208 | + | |
| 209 | + List<UsrUserPermission> grants = usrUserPermissionMapper.selectList( | |
| 210 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 211 | + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) | |
| 212 | + .containsExactlyInAnyOrder(permA, permB); // c 被删、b 新增、a 去重一次 | |
| 213 | + | |
| 214 | + // 传 [] → 清空全部授权 | |
| 215 | + Map<String, Object> clearBody = new HashMap<>(); | |
| 216 | + clearBody.put("sUserType", "普通用户"); | |
| 217 | + clearBody.put("sLanguage", "中文"); | |
| 218 | + clearBody.put("permissionIds", List.of()); | |
| 219 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 220 | + .header("Authorization", adminToken()) | |
| 221 | + .contentType("application/json") | |
| 222 | + .content(objectMapper.writeValueAsString(clearBody))) | |
| 223 | + .andExpect(jsonPath("$.code").value(0)); | |
| 224 | + long afterClear = usrUserPermissionMapper.selectCount( | |
| 225 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 226 | + assertThat(afterClear).isZero(); | |
| 227 | + | |
| 228 | + // 预置一条授权后,不传 permissionIds → 授权不变 | |
| 229 | + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); | |
| 230 | + Map<String, Object> noPermBody = new HashMap<>(); | |
| 231 | + noPermBody.put("sUserType", "普通用户"); | |
| 232 | + noPermBody.put("sLanguage", "中文"); | |
| 233 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 234 | + .header("Authorization", adminToken()) | |
| 235 | + .contentType("application/json") | |
| 236 | + .content(objectMapper.writeValueAsString(noPermBody))) | |
| 237 | + .andExpect(jsonPath("$.code").value(0)); | |
| 238 | + long afterNoChange = usrUserPermissionMapper.selectCount( | |
| 239 | + Wrappers.<UsrUserPermission>lambdaQuery().eq(UsrUserPermission::getIUserId, id)); | |
| 240 | + assertThat(afterNoChange).isEqualTo(1L); | |
| 241 | + } | |
| 242 | + | |
| 243 | + // ---------------- AC7:非管理员 / 无 token 被拦截,目标行未被修改 ---------------- | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + void ac7NonAdminAndNoTokenBlocked() throws Exception { | |
| 247 | + int id = createUser("it2_user_ac7"); | |
| 248 | + UsrUser before = usrUserMapper.selectById(id); | |
| 249 | + | |
| 250 | + Map<String, Object> body = new HashMap<>(); | |
| 251 | + body.put("sUserType", "超级管理员"); | |
| 252 | + body.put("sLanguage", "英文"); | |
| 253 | + | |
| 254 | + // 普通用户 token → 40301 | |
| 255 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 256 | + .header("Authorization", normalToken()) | |
| 257 | + .contentType("application/json") | |
| 258 | + .content(objectMapper.writeValueAsString(body))) | |
| 259 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 260 | + | |
| 261 | + // 无 token → 401(安全链拦截) | |
| 262 | + mockMvc.perform(put("/api/usr/users/" + id) | |
| 263 | + .contentType("application/json") | |
| 264 | + .content(objectMapper.writeValueAsString(body))) | |
| 265 | + .andExpect(status().isUnauthorized()); | |
| 266 | + | |
| 267 | + UsrUser after = usrUserMapper.selectById(id); | |
| 268 | + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); | |
| 269 | + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); | |
| 270 | + } | |
| 271 | + | |
| 272 | + // ---------------- AC8:密码不变且不出现在响应;iIsVoid 落库(AC4/AC5 落库层等价验证) ---------------- | |
| 273 | + | |
| 274 | + @Test | |
| 275 | + void ac8PasswordUnchangedAndAbsentFromResponse() throws Exception { | |
| 276 | + int id = createUser("it2_user_ac8"); | |
| 277 | + UsrUser before = usrUserMapper.selectById(id); | |
| 278 | + | |
| 279 | + Map<String, Object> body = new HashMap<>(); | |
| 280 | + body.put("sUserType", "超级管理员"); // AC5 角色变更落库层等价验证 | |
| 281 | + body.put("sLanguage", "中文"); | |
| 282 | + body.put("iIsVoid", 1); // AC4 禁用落库层等价验证 | |
| 283 | + | |
| 284 | + MvcResult result = mockMvc.perform(put("/api/usr/users/" + id) | |
| 285 | + .header("Authorization", adminToken()) | |
| 286 | + .contentType("application/json") | |
| 287 | + .content(objectMapper.writeValueAsString(body))) | |
| 288 | + .andExpect(jsonPath("$.code").value(0)) | |
| 289 | + .andExpect(jsonPath("$.data.id").value(id)) | |
| 290 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 291 | + .andExpect(jsonPath("$.data.password").doesNotExist()) | |
| 292 | + .andReturn(); | |
| 293 | + | |
| 294 | + String responseBody = result.getResponse().getContentAsString(); | |
| 295 | + assertThat(responseBody.toLowerCase()).doesNotContain("password"); | |
| 296 | + | |
| 297 | + UsrUser after = usrUserMapper.selectById(id); | |
| 298 | + // 密码列字节级不变。 | |
| 299 | + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); | |
| 300 | + // AC4 / AC5 落库层等价验证:禁用状态 / 角色变更读回库内为新值。 | |
| 301 | + assertThat(after.getIIsVoid()).isEqualTo(1); | |
| 302 | + assertThat(after.getSUserType()).isEqualTo("超级管理员"); | |
| 303 | + } | |
| 304 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import static org.mockito.ArgumentMatchers.any; | |
| 4 | +import static org.mockito.Mockito.never; | |
| 5 | +import static org.mockito.Mockito.verify; | |
| 6 | +import static org.mockito.Mockito.when; | |
| 7 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 8 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 9 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 10 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 11 | + | |
| 12 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 13 | +import com.xly.erp.common.exception.BusinessException; | |
| 14 | +import com.xly.erp.common.exception.GlobalExceptionHandler; | |
| 15 | +import com.xly.erp.common.response.ResultCode; | |
| 16 | +import com.xly.erp.modules.usr.dto.LoginDTO; | |
| 17 | +import com.xly.erp.modules.usr.service.UsrAuthService; | |
| 18 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | |
| 19 | +import com.xly.erp.modules.usr.vo.LoginVO; | |
| 20 | +import java.util.List; | |
| 21 | +import org.junit.jupiter.api.BeforeEach; | |
| 22 | +import org.junit.jupiter.api.Test; | |
| 23 | +import org.mockito.Mockito; | |
| 24 | +import org.springframework.test.web.servlet.MockMvc; | |
| 25 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * REQ-USR-004 T5:UsrAuthController(MockMvc standaloneSetup + 真实 GlobalExceptionHandler)。 | |
| 29 | + * | |
| 30 | + * <p>Service 用 Mockito mock;验证登录成功 code=0 + token + 嵌套 user、缺字段 40001、 | |
| 31 | + * 业务异常透传转码(40101/40302/42901)、公司列表 code=0。响应体不含密码。</p> | |
| 32 | + */ | |
| 33 | +class UsrAuthControllerTest { | |
| 34 | + | |
| 35 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 36 | + private final UsrAuthService usrAuthService = Mockito.mock(UsrAuthService.class); | |
| 37 | + private MockMvc mockMvc; | |
| 38 | + | |
| 39 | + @BeforeEach | |
| 40 | + void setUp() { | |
| 41 | + UsrAuthController controller = new UsrAuthController(usrAuthService); | |
| 42 | + mockMvc = MockMvcBuilders.standaloneSetup(controller) | |
| 43 | + .setControllerAdvice(new GlobalExceptionHandler()) | |
| 44 | + .build(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + private String validBody() throws Exception { | |
| 48 | + LoginDTO dto = new LoginDTO(); | |
| 49 | + dto.setSUserName("admin"); | |
| 50 | + dto.setPassword("666666"); | |
| 51 | + dto.setCompanyId(1); | |
| 52 | + return objectMapper.writeValueAsString(dto); | |
| 53 | + } | |
| 54 | + | |
| 55 | + private LoginVO sampleVO() { | |
| 56 | + LoginVO.UserInfo info = new LoginVO.UserInfo(); | |
| 57 | + info.setId(1); | |
| 58 | + info.setSUserName("admin"); | |
| 59 | + info.setSUserType("超级管理员"); | |
| 60 | + info.setSLanguage("中文"); | |
| 61 | + LoginVO vo = new LoginVO(); | |
| 62 | + vo.setToken("jwt.token.value"); | |
| 63 | + vo.setUser(info); | |
| 64 | + return vo; | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void loginReturnsCodeZeroWithTokenAndUser() throws Exception { | |
| 69 | + when(usrAuthService.login(any(LoginDTO.class))).thenReturn(sampleVO()); | |
| 70 | + | |
| 71 | + mockMvc.perform(post("/api/usr/login") | |
| 72 | + .contentType("application/json") | |
| 73 | + .content(validBody())) | |
| 74 | + .andExpect(status().isOk()) | |
| 75 | + .andExpect(jsonPath("$.code").value(0)) | |
| 76 | + .andExpect(jsonPath("$.data.token").value("jwt.token.value")) | |
| 77 | + .andExpect(jsonPath("$.data.user.id").value(1)) | |
| 78 | + .andExpect(jsonPath("$.data.user.sUserName").value("admin")) | |
| 79 | + .andExpect(jsonPath("$.data.user.sUserType").value("超级管理员")) | |
| 80 | + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) | |
| 81 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 82 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void loginMissingFieldReturns40001() throws Exception { | |
| 87 | + // 缺 companyId(@NotNull 失败) | |
| 88 | + String body = "{\"sUserName\":\"admin\",\"password\":\"666666\"}"; | |
| 89 | + mockMvc.perform(post("/api/usr/login") | |
| 90 | + .contentType("application/json") | |
| 91 | + .content(body)) | |
| 92 | + .andExpect(status().isOk()) | |
| 93 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 94 | + verify(usrAuthService, never()).login(any(LoginDTO.class)); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void loginAuthFailReturns40101() throws Exception { | |
| 99 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 100 | + .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED)); | |
| 101 | + mockMvc.perform(post("/api/usr/login") | |
| 102 | + .contentType("application/json") | |
| 103 | + .content(validBody())) | |
| 104 | + .andExpect(status().isOk()) | |
| 105 | + .andExpect(jsonPath("$.code").value(40101)); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + void loginDisabledReturns40302() throws Exception { | |
| 110 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 111 | + .thenThrow(new BusinessException(ResultCode.ACCOUNT_DISABLED)); | |
| 112 | + mockMvc.perform(post("/api/usr/login") | |
| 113 | + .contentType("application/json") | |
| 114 | + .content(validBody())) | |
| 115 | + .andExpect(status().isOk()) | |
| 116 | + .andExpect(jsonPath("$.code").value(40302)); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + void loginRateLimitedReturns42901() throws Exception { | |
| 121 | + when(usrAuthService.login(any(LoginDTO.class))) | |
| 122 | + .thenThrow(new BusinessException(ResultCode.LOGIN_RATE_LIMITED)); | |
| 123 | + mockMvc.perform(post("/api/usr/login") | |
| 124 | + .contentType("application/json") | |
| 125 | + .content(validBody())) | |
| 126 | + .andExpect(status().isOk()) | |
| 127 | + .andExpect(jsonPath("$.code").value(42901)); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + void companiesReturnsCodeZeroList() throws Exception { | |
| 132 | + CompanyOptionVO vo = new CompanyOptionVO(); | |
| 133 | + vo.setId(3); | |
| 134 | + vo.setSCompanyName("总部"); | |
| 135 | + vo.setSVersion("企业版"); | |
| 136 | + when(usrAuthService.listCompanies()).thenReturn(List.of(vo)); | |
| 137 | + | |
| 138 | + mockMvc.perform(get("/api/usr/companies")) | |
| 139 | + .andExpect(status().isOk()) | |
| 140 | + .andExpect(jsonPath("$.code").value(0)) | |
| 141 | + .andExpect(jsonPath("$.data[0].id").value(3)) | |
| 142 | + .andExpect(jsonPath("$.data[0].sCompanyName").value("总部")); | |
| 143 | + } | |
| 144 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.controller; | |
| 2 | + | |
| 3 | +import static org.mockito.ArgumentMatchers.any; | |
| 4 | +import static org.mockito.Mockito.never; | |
| 5 | +import static org.mockito.Mockito.verify; | |
| 6 | +import static org.mockito.Mockito.when; | |
| 7 | +import static org.mockito.ArgumentMatchers.eq; | |
| 8 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 9 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 10 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 11 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 12 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 13 | + | |
| 14 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 15 | +import com.xly.erp.common.exception.BusinessException; | |
| 16 | +import com.xly.erp.common.exception.GlobalExceptionHandler; | |
| 17 | +import com.xly.erp.common.response.PageResult; | |
| 18 | +import com.xly.erp.common.response.ResultCode; | |
| 19 | +import com.xly.erp.common.security.SecurityUtil; | |
| 20 | +import com.xly.erp.modules.usr.dto.CreateUserDTO; | |
| 21 | +import com.xly.erp.modules.usr.dto.UpdateUserDTO; | |
| 22 | +import com.xly.erp.modules.usr.dto.UserQueryDTO; | |
| 23 | +import com.xly.erp.modules.usr.service.UsrUserService; | |
| 24 | +import com.xly.erp.modules.usr.vo.UserVO; | |
| 25 | +import java.util.List; | |
| 26 | +import org.junit.jupiter.api.AfterEach; | |
| 27 | +import org.junit.jupiter.api.BeforeEach; | |
| 28 | +import org.junit.jupiter.api.Test; | |
| 29 | +import org.mockito.MockedStatic; | |
| 30 | +import org.mockito.Mockito; | |
| 31 | +import org.springframework.test.web.servlet.MockMvc; | |
| 32 | +import org.springframework.test.web.servlet.setup.MockMvcBuilders; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * REQ-USR-001 T7:UsrUserController 与管理员权限前置。 | |
| 36 | + * | |
| 37 | + * <p>采用 MockMvc standaloneSetup 做 Controller 层单元测试(不加载 Spring 上下文, | |
| 38 | + * 避免 @MapperScan / Security 全量装配的切片摩擦),挂上真实 {@link GlobalExceptionHandler} | |
| 39 | + * 以验证 @Valid 失败转 40001、BusinessException 转 40301;UsrUserService 用 Mockito mock, | |
| 40 | + * 管理员判定通过 mock SecurityUtil 静态方法注入。</p> | |
| 41 | + */ | |
| 42 | +class UsrUserControllerTest { | |
| 43 | + | |
| 44 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 45 | + private final UsrUserService usrUserService = Mockito.mock(UsrUserService.class); | |
| 46 | + private MockMvc mockMvc; | |
| 47 | + private MockedStatic<SecurityUtil> securityUtilMock; | |
| 48 | + | |
| 49 | + @BeforeEach | |
| 50 | + void setUp() { | |
| 51 | + UsrUserController controller = new UsrUserController(usrUserService); | |
| 52 | + mockMvc = MockMvcBuilders.standaloneSetup(controller) | |
| 53 | + .setControllerAdvice(new GlobalExceptionHandler()) | |
| 54 | + .build(); | |
| 55 | + securityUtilMock = Mockito.mockStatic(SecurityUtil.class); | |
| 56 | + } | |
| 57 | + | |
| 58 | + @AfterEach | |
| 59 | + void tearDown() { | |
| 60 | + securityUtilMock.close(); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private String validBody() throws Exception { | |
| 64 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 65 | + dto.setSUserName("good_user"); | |
| 66 | + dto.setSLanguage("中文"); | |
| 67 | + return objectMapper.writeValueAsString(dto); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void adminCreateReturnsCodeZeroWithId() throws Exception { | |
| 72 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 73 | + when(usrUserService.createUser(any(CreateUserDTO.class))).thenReturn(101); | |
| 74 | + | |
| 75 | + mockMvc.perform(post("/api/usr/users") | |
| 76 | + .contentType("application/json") | |
| 77 | + .content(validBody())) | |
| 78 | + .andExpect(status().isOk()) | |
| 79 | + .andExpect(jsonPath("$.code").value(0)) | |
| 80 | + .andExpect(jsonPath("$.data.id").value(101)) | |
| 81 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 82 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void nonAdminReturns40301() throws Exception { | |
| 87 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 88 | + | |
| 89 | + mockMvc.perform(post("/api/usr/users") | |
| 90 | + .contentType("application/json") | |
| 91 | + .content(validBody())) | |
| 92 | + .andExpect(status().isOk()) | |
| 93 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 94 | + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void invalidBodyReturns40001() throws Exception { | |
| 99 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 100 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 101 | + dto.setSUserName("ab"); | |
| 102 | + dto.setSLanguage("中文"); | |
| 103 | + String body = objectMapper.writeValueAsString(dto); | |
| 104 | + | |
| 105 | + mockMvc.perform(post("/api/usr/users") | |
| 106 | + .contentType("application/json") | |
| 107 | + .content(body)) | |
| 108 | + .andExpect(status().isOk()) | |
| 109 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 110 | + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + // ---------------- REQ-USR-002 T4:修改用户 Controller + 管理员前置 ---------------- | |
| 114 | + | |
| 115 | + private String validUpdateBody() throws Exception { | |
| 116 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 117 | + dto.setSUserType("普通用户"); | |
| 118 | + dto.setSLanguage("中文"); | |
| 119 | + return objectMapper.writeValueAsString(dto); | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test | |
| 123 | + void adminUpdateReturnsCodeZeroWithId() throws Exception { | |
| 124 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 125 | + when(usrUserService.updateUser(eq(55), any(UpdateUserDTO.class))).thenReturn(55); | |
| 126 | + | |
| 127 | + mockMvc.perform(put("/api/usr/users/55") | |
| 128 | + .contentType("application/json") | |
| 129 | + .content(validUpdateBody())) | |
| 130 | + .andExpect(status().isOk()) | |
| 131 | + .andExpect(jsonPath("$.code").value(0)) | |
| 132 | + .andExpect(jsonPath("$.data.id").value(55)) | |
| 133 | + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) | |
| 134 | + .andExpect(jsonPath("$.data.password").doesNotExist()); | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + void nonAdminUpdateReturns40301() throws Exception { | |
| 139 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 140 | + | |
| 141 | + mockMvc.perform(put("/api/usr/users/55") | |
| 142 | + .contentType("application/json") | |
| 143 | + .content(validUpdateBody())) | |
| 144 | + .andExpect(status().isOk()) | |
| 145 | + .andExpect(jsonPath("$.code").value(40301)); | |
| 146 | + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); | |
| 147 | + } | |
| 148 | + | |
| 149 | + @Test | |
| 150 | + void invalidBodyUpdateReturns40001() throws Exception { | |
| 151 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 152 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 153 | + dto.setSLanguage("中文"); // 缺 sUserType → @NotBlank 失败 | |
| 154 | + String body = objectMapper.writeValueAsString(dto); | |
| 155 | + | |
| 156 | + mockMvc.perform(put("/api/usr/users/55") | |
| 157 | + .contentType("application/json") | |
| 158 | + .content(body)) | |
| 159 | + .andExpect(status().isOk()) | |
| 160 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 161 | + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); | |
| 162 | + } | |
| 163 | + | |
| 164 | + @Test | |
| 165 | + void userNotFoundReturns40401() throws Exception { | |
| 166 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); | |
| 167 | + when(usrUserService.updateUser(eq(404), any(UpdateUserDTO.class))) | |
| 168 | + .thenThrow(new BusinessException(ResultCode.NOT_FOUND)); | |
| 169 | + | |
| 170 | + mockMvc.perform(put("/api/usr/users/404") | |
| 171 | + .contentType("application/json") | |
| 172 | + .content(validUpdateBody())) | |
| 173 | + .andExpect(status().isOk()) | |
| 174 | + .andExpect(jsonPath("$.code").value(40401)); | |
| 175 | + } | |
| 176 | + | |
| 177 | + // ---------------- REQ-USR-003 T5:查询用户 Controller GET /api/usr/users(无管理员前置)---------------- | |
| 178 | + | |
| 179 | + private PageResult<UserVO> onePageResult() { | |
| 180 | + UserVO vo = new UserVO(); | |
| 181 | + vo.setId(1); | |
| 182 | + vo.setSUserName("alice"); | |
| 183 | + vo.setSUserType("普通用户"); | |
| 184 | + vo.setSLanguage("中文"); | |
| 185 | + vo.setIIsVoid(0); | |
| 186 | + return PageResult.of(List.of(vo), 1L, 1L, 10L); | |
| 187 | + } | |
| 188 | + | |
| 189 | + @Test | |
| 190 | + void queryReturnsCodeZeroWithPageResult() throws Exception { | |
| 191 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); | |
| 192 | + | |
| 193 | + mockMvc.perform(get("/api/usr/users").param("pageNum", "1").param("pageSize", "10")) | |
| 194 | + .andExpect(status().isOk()) | |
| 195 | + .andExpect(jsonPath("$.code").value(0)) | |
| 196 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 197 | + .andExpect(jsonPath("$.data.pageNum").value(1)) | |
| 198 | + .andExpect(jsonPath("$.data.pageSize").value(10)) | |
| 199 | + .andExpect(jsonPath("$.data.records[0].sUserName").value("alice")) | |
| 200 | + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) | |
| 201 | + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); | |
| 202 | + } | |
| 203 | + | |
| 204 | + @Test | |
| 205 | + void queryAllowsNonAdmin() throws Exception { | |
| 206 | + // 无管理员前置(spec § 8 D5):普通用户即可调用,Service 被调用。 | |
| 207 | + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); | |
| 208 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); | |
| 209 | + | |
| 210 | + mockMvc.perform(get("/api/usr/users")) | |
| 211 | + .andExpect(status().isOk()) | |
| 212 | + .andExpect(jsonPath("$.code").value(0)); | |
| 213 | + verify(usrUserService).queryUsers(any(UserQueryDTO.class)); | |
| 214 | + } | |
| 215 | + | |
| 216 | + @Test | |
| 217 | + void queryIllegalEnumReturns40001() throws Exception { | |
| 218 | + mockMvc.perform(get("/api/usr/users").param("queryField", "身份证")) | |
| 219 | + .andExpect(status().isOk()) | |
| 220 | + .andExpect(jsonPath("$.code").value(40001)); | |
| 221 | + verify(usrUserService, never()).queryUsers(any(UserQueryDTO.class)); | |
| 222 | + } | |
| 223 | + | |
| 224 | + @Test | |
| 225 | + void queryPageParamInvalidReturns42201() throws Exception { | |
| 226 | + when(usrUserService.queryUsers(any(UserQueryDTO.class))) | |
| 227 | + .thenThrow(new BusinessException(ResultCode.PAGE_PARAM_INVALID)); | |
| 228 | + | |
| 229 | + mockMvc.perform(get("/api/usr/users").param("pageNum", "0")) | |
| 230 | + .andExpect(status().isOk()) | |
| 231 | + .andExpect(jsonPath("$.code").value(42201)); | |
| 232 | + } | |
| 233 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import jakarta.validation.Validation; | |
| 6 | +import jakarta.validation.Validator; | |
| 7 | +import jakarta.validation.ValidatorFactory; | |
| 8 | +import java.util.Set; | |
| 9 | +import jakarta.validation.ConstraintViolation; | |
| 10 | +import org.junit.jupiter.api.AfterAll; | |
| 11 | +import org.junit.jupiter.api.BeforeAll; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * REQ-USR-001 T4:CreateUserDTO Bean Validation 校验。 | |
| 16 | + */ | |
| 17 | +class CreateUserDTOValidationTest { | |
| 18 | + | |
| 19 | + private static ValidatorFactory factory; | |
| 20 | + private static Validator validator; | |
| 21 | + | |
| 22 | + @BeforeAll | |
| 23 | + static void setUp() { | |
| 24 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 25 | + validator = factory.getValidator(); | |
| 26 | + } | |
| 27 | + | |
| 28 | + @AfterAll | |
| 29 | + static void tearDown() { | |
| 30 | + if (factory != null) { | |
| 31 | + factory.close(); | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + void rejectsBadUserName() { | |
| 37 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 38 | + dto.setSUserName("ab"); | |
| 39 | + dto.setSLanguage("中文"); | |
| 40 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 41 | + assertThat(violations) | |
| 42 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sUserName")); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + void rejectsIllegalLanguage() { | |
| 47 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 48 | + dto.setSUserName("good_user"); | |
| 49 | + dto.setSLanguage("日文"); | |
| 50 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 51 | + assertThat(violations) | |
| 52 | + .anyMatch(v -> v.getPropertyPath().toString().equals("sLanguage")); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void acceptsMinimalValidBody() { | |
| 57 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 58 | + dto.setSUserName("good_user"); | |
| 59 | + dto.setSLanguage("中文"); | |
| 60 | + Set<ConstraintViolation<CreateUserDTO>> violations = validator.validate(dto); | |
| 61 | + assertThat(violations).isEmpty(); | |
| 62 | + } | |
| 63 | +} | ... | ... |
backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.dto; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 6 | +import jakarta.validation.ConstraintViolation; | |
| 7 | +import jakarta.validation.Validation; | |
| 8 | +import jakarta.validation.Validator; | |
| 9 | +import jakarta.validation.ValidatorFactory; | |
| 10 | +import java.util.Set; | |
| 11 | +import org.junit.jupiter.api.AfterAll; | |
| 12 | +import org.junit.jupiter.api.BeforeAll; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * REQ-USR-004 T2:LoginDTO Bean Validation 与 JSON 反序列化键名。 | |
| 17 | + * | |
| 18 | + * <p>校验 {@code sUserName}/{@code password} 必填、长度上限,{@code companyId} 必填; | |
| 19 | + * 并验证 {@code @JsonProperty("sUserName")} 锁定反序列化键名(与 CreateUserDTO/UserVO 同做法)。</p> | |
| 20 | + */ | |
| 21 | +class LoginDTOValidationTest { | |
| 22 | + | |
| 23 | + private static ValidatorFactory factory; | |
| 24 | + private static Validator validator; | |
| 25 | + private final ObjectMapper objectMapper = new ObjectMapper(); | |
| 26 | + | |
| 27 | + @BeforeAll | |
| 28 | + static void initValidator() { | |
| 29 | + factory = Validation.buildDefaultValidatorFactory(); | |
| 30 | + validator = factory.getValidator(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @AfterAll | |
| 34 | + static void closeValidator() { | |
| 35 | + factory.close(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + private LoginDTO valid() { | |
| 39 | + LoginDTO dto = new LoginDTO(); | |
| 40 | + dto.setSUserName("admin"); | |
| 41 | + dto.setPassword("666666"); | |
| 42 | + dto.setCompanyId(1); | |
| 43 | + return dto; | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void acceptsValidLogin() { | |
| 48 | + Set<ConstraintViolation<LoginDTO>> violations = validator.validate(valid()); | |
| 49 | + assertThat(violations).isEmpty(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + void rejectsBlankUserName() { | |
| 54 | + LoginDTO dto = valid(); | |
| 55 | + dto.setSUserName(""); | |
| 56 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 57 | + dto.setSUserName(null); | |
| 58 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void rejectsBlankPassword() { | |
| 63 | + LoginDTO dto = valid(); | |
| 64 | + dto.setPassword(""); | |
| 65 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 66 | + dto.setPassword(null); | |
| 67 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void rejectsNullCompanyId() { | |
| 72 | + LoginDTO dto = valid(); | |
| 73 | + dto.setCompanyId(null); | |
| 74 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + void rejectsTooLongUserName() { | |
| 79 | + LoginDTO dto = valid(); | |
| 80 | + dto.setSUserName("a".repeat(51)); | |
| 81 | + assertThat(validator.validate(dto)).isNotEmpty(); | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + void deserializesSUserNameJsonKey() throws Exception { | |
| 86 | + LoginDTO dto = objectMapper.readValue( | |
| 87 | + "{\"sUserName\":\"admin\",\"password\":\"x\",\"companyId\":1}", LoginDTO.class); | |
| 88 | + assertThat(dto.getSUserName()).isEqualTo("admin"); | |
| 89 | + assertThat(dto.getPassword()).isEqualTo("x"); | |
| 90 | + assertThat(dto.getCompanyId()).isEqualTo(1); | |
| 91 | + } | |
| 92 | +} | ... | ... |