Commit 98ab7454a264409d7cfba786ac53d1ff3078cf95

Authored by zichun
2 parents 2bc3429d d170f319

Merge branch 'module-module_usr'

Showing 103 changed files with 8198 additions and 632 deletions

Too many changes to show.

To preserve performance only 72 of 103 files are displayed.

CLAUDE.md
... ... @@ -132,7 +132,7 @@ B 阶段分两段,**全部固化到 skills**。入口:`/erp-workflow:coding-
132 132  
133 133 ### 你禁止做的 🚫
134 134  
135   -1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约
  135 +1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试 / A4 `db-init` 首次 apply V1 验证除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约。**A4 例外**:`db-init` 在 A 阶段 setup-test-db 后会一次性手工 `mysql < V1__initial_schema.sql` 把 V1 灌入测试库,并校验 `SHOW TABLES` 行数 = docs/03 表数量,用于 DDL 自检;B 阶段(Spring Boot 启动后)Flyway 会重建 schema 并 apply 全部 migration(包括 V1),手工 apply 不会污染 Flyway 历史。
136 136 2. **手动 Edit `docs/08 § 二/§ 三` 的 `MR:` / `整体 MR:` 字段**,必须要由 `mr-create` 自动回写
137 137  
138 138 ### Schema 演化规约(Flyway migration)
... ...
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.3.4</version>
  11 + <relativePath/>
  12 + </parent>
  13 +
  14 + <groupId>com.xly.erp</groupId>
  15 + <artifactId>xly-erp-backend</artifactId>
  16 + <version>0.0.1-SNAPSHOT</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.source>17</maven.compiler.source>
  24 + <maven.compiler.target>17</maven.compiler.target>
  25 + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  26 + <mybatis-plus.version>3.5.7</mybatis-plus.version>
  27 + <jjwt.version>0.12.5</jjwt.version>
  28 + <lombok.version>1.18.40</lombok.version>
  29 + </properties>
  30 +
  31 + <dependencies>
  32 + <dependency>
  33 + <groupId>org.springframework.boot</groupId>
  34 + <artifactId>spring-boot-starter-web</artifactId>
  35 + </dependency>
  36 + <dependency>
  37 + <groupId>org.springframework.boot</groupId>
  38 + <artifactId>spring-boot-starter-validation</artifactId>
  39 + </dependency>
  40 + <dependency>
  41 + <groupId>org.springframework.security</groupId>
  42 + <artifactId>spring-security-crypto</artifactId>
  43 + </dependency>
  44 +
  45 + <dependency>
  46 + <groupId>com.baomidou</groupId>
  47 + <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
  48 + <version>${mybatis-plus.version}</version>
  49 + </dependency>
  50 +
  51 + <dependency>
  52 + <groupId>com.mysql</groupId>
  53 + <artifactId>mysql-connector-j</artifactId>
  54 + <scope>runtime</scope>
  55 + </dependency>
  56 +
  57 + <dependency>
  58 + <groupId>org.flywaydb</groupId>
  59 + <artifactId>flyway-core</artifactId>
  60 + </dependency>
  61 + <dependency>
  62 + <groupId>org.flywaydb</groupId>
  63 + <artifactId>flyway-mysql</artifactId>
  64 + </dependency>
  65 +
  66 + <dependency>
  67 + <groupId>io.jsonwebtoken</groupId>
  68 + <artifactId>jjwt-api</artifactId>
  69 + <version>${jjwt.version}</version>
  70 + </dependency>
  71 + <dependency>
  72 + <groupId>io.jsonwebtoken</groupId>
  73 + <artifactId>jjwt-impl</artifactId>
  74 + <version>${jjwt.version}</version>
  75 + <scope>runtime</scope>
  76 + </dependency>
  77 + <dependency>
  78 + <groupId>io.jsonwebtoken</groupId>
  79 + <artifactId>jjwt-jackson</artifactId>
  80 + <version>${jjwt.version}</version>
  81 + <scope>runtime</scope>
  82 + </dependency>
  83 +
  84 + <dependency>
  85 + <groupId>org.projectlombok</groupId>
  86 + <artifactId>lombok</artifactId>
  87 + <version>${lombok.version}</version>
  88 + <optional>true</optional>
  89 + </dependency>
  90 +
  91 + <dependency>
  92 + <groupId>org.springframework.boot</groupId>
  93 + <artifactId>spring-boot-starter-test</artifactId>
  94 + <scope>test</scope>
  95 + </dependency>
  96 + </dependencies>
  97 +
  98 + <build>
  99 + <plugins>
  100 + <plugin>
  101 + <groupId>org.apache.maven.plugins</groupId>
  102 + <artifactId>maven-compiler-plugin</artifactId>
  103 + <configuration>
  104 + <annotationProcessorPaths>
  105 + <path>
  106 + <groupId>org.projectlombok</groupId>
  107 + <artifactId>lombok</artifactId>
  108 + <version>${lombok.version}</version>
  109 + </path>
  110 + </annotationProcessorPaths>
  111 + </configuration>
  112 + </plugin>
  113 + <plugin>
  114 + <groupId>org.springframework.boot</groupId>
  115 + <artifactId>spring-boot-maven-plugin</artifactId>
  116 + <configuration>
  117 + <excludes>
  118 + <exclude>
  119 + <groupId>org.projectlombok</groupId>
  120 + <artifactId>lombok</artifactId>
  121 + </exclude>
  122 + </excludes>
  123 + </configuration>
  124 + </plugin>
  125 + <plugin>
  126 + <groupId>org.apache.maven.plugins</groupId>
  127 + <artifactId>maven-surefire-plugin</artifactId>
  128 + <configuration>
  129 + <environmentVariables>
  130 + <DB_HOST>${env.DB_HOST}</DB_HOST>
  131 + <DB_PORT>${env.DB_PORT}</DB_PORT>
  132 + <DB_USER>${env.DB_USER}</DB_USER>
  133 + <DB_PASSWORD>${env.DB_PASSWORD}</DB_PASSWORD>
  134 + <DB_SCHEMA>${env.DB_SCHEMA}</DB_SCHEMA>
  135 + <JWT_SECRET>${env.JWT_SECRET}</JWT_SECRET>
  136 + </environmentVariables>
  137 + </configuration>
  138 + </plugin>
  139 + </plugins>
  140 + </build>
  141 +</project>
... ...
backend/src/main/java/com/xly/erp/Application.java 0 → 100644
  1 +package com.xly.erp;
  2 +
  3 +import org.mybatis.spring.annotation.MapperScan;
  4 +import org.springframework.boot.SpringApplication;
  5 +import org.springframework.boot.autoconfigure.SpringBootApplication;
  6 +
  7 +@SpringBootApplication
  8 +@MapperScan("com.xly.erp.module.*.mapper")
  9 +public class Application {
  10 + public static void main(String[] args) {
  11 + SpringApplication.run(Application.class, args);
  12 + }
  13 +}
... ...
backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java 0 → 100644
  1 +package com.xly.erp.common.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  6 +
  7 +/**
  8 + * BCrypt 密码编码器 Bean。strength=10(Spring Security 默认)。
  9 + * docs/03 sys_user.sPasswordHash + docs/04 § 1.6。
  10 + */
  11 +@Configuration
  12 +public class PasswordEncoderConfig {
  13 +
  14 + @Bean
  15 + public BCryptPasswordEncoder passwordEncoder() {
  16 + return new BCryptPasswordEncoder(10);
  17 + }
  18 +}
... ...
backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java 0 → 100644
  1 +package com.xly.erp.common.config;
  2 +
  3 +import com.xly.erp.common.security.JwtHandlerInterceptor;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.springframework.context.annotation.Configuration;
  6 +import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
  7 +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  8 +
  9 +@Configuration
  10 +@RequiredArgsConstructor
  11 +public class WebMvcConfig implements WebMvcConfigurer {
  12 +
  13 + private final JwtHandlerInterceptor jwtInterceptor;
  14 +
  15 + @Override
  16 + public void addInterceptors(InterceptorRegistry registry) {
  17 + registry.addInterceptor(jwtInterceptor)
  18 + .addPathPatterns("/api/v1/**")
  19 + .excludePathPatterns("/api/v1/auth/login");
  20 + }
  21 +}
... ...
backend/src/main/java/com/xly/erp/common/exception/BizException.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/**
  6 + * 业务异常 — 由 service 层抛出,由 GlobalExceptionHandler 统一转 Result.fail。
  7 + * docs/04 § 1.4。
  8 + */
  9 +@Getter
  10 +public class BizException extends RuntimeException {
  11 + private final int code;
  12 + /** 可选附带的响应数据(例如 42301 锁定返 lockUntil)。null 表示无 data 字段。 */
  13 + private final Object data;
  14 +
  15 + public BizException(int code, String message) {
  16 + this(code, message, (Object) null);
  17 + }
  18 +
  19 + public BizException(int code, String message, Object data) {
  20 + super(message);
  21 + this.code = code;
  22 + this.data = data;
  23 + }
  24 +
  25 + public BizException(int code, String message, Throwable cause) {
  26 + super(message, cause);
  27 + this.code = code;
  28 + this.data = null;
  29 + }
  30 +}
... ...
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.ErrorCode;
  4 +import com.xly.erp.common.response.Result;
  5 +import jakarta.validation.ConstraintViolationException;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.springframework.http.ResponseEntity;
  8 +import org.springframework.http.converter.HttpMessageNotReadableException;
  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 + * 全局异常处理器。
  15 + * 把 BizException / 参数校验异常 / 兜底异常转 Result.fail 统一响应。
  16 + * docs/04 § 1.4。
  17 + */
  18 +@RestControllerAdvice
  19 +@Slf4j
  20 +public class GlobalExceptionHandler {
  21 +
  22 + @ExceptionHandler(BizException.class)
  23 + public ResponseEntity<Result<Object>> handleBiz(BizException e) {
  24 + log.warn("[BizException] code={} message={} hasData={}", e.getCode(), e.getMessage(), e.getData() != null);
  25 + Result<Object> body = e.getData() != null
  26 + ? Result.fail(e.getCode(), e.getMessage(), e.getData())
  27 + : Result.fail(e.getCode(), e.getMessage());
  28 + return ResponseEntity
  29 + .status(ErrorCode.toHttpStatus(e.getCode()))
  30 + .body(body);
  31 + }
  32 +
  33 + @ExceptionHandler(MethodArgumentNotValidException.class)
  34 + public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException e) {
  35 + String msg = e.getBindingResult().getFieldErrors().stream()
  36 + .findFirst()
  37 + .map(fe -> fe.getField() + " " + fe.getDefaultMessage())
  38 + .orElse("参数校验失败");
  39 + return ResponseEntity
  40 + .status(400)
  41 + .body(Result.fail(ErrorCode.BAD_REQUEST, msg));
  42 + }
  43 +
  44 + @ExceptionHandler(ConstraintViolationException.class)
  45 + public ResponseEntity<Result<Void>> handleConstraint(ConstraintViolationException e) {
  46 + return ResponseEntity
  47 + .status(400)
  48 + .body(Result.fail(ErrorCode.BAD_REQUEST, e.getMessage()));
  49 + }
  50 +
  51 + @ExceptionHandler(HttpMessageNotReadableException.class)
  52 + public ResponseEntity<Result<Void>> handleNotReadable(HttpMessageNotReadableException e) {
  53 + log.warn("[HttpMessageNotReadable] {}", e.getMessage());
  54 + return ResponseEntity
  55 + .status(400)
  56 + .body(Result.fail(ErrorCode.BAD_REQUEST, "请求体格式不合法或包含未知字段"));
  57 + }
  58 +
  59 + @ExceptionHandler(Exception.class)
  60 + public ResponseEntity<Result<Void>> handleFallback(Exception e) {
  61 + log.error("[Unhandled] {}", e.getMessage(), e);
  62 + return ResponseEntity
  63 + .status(500)
  64 + .body(Result.fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
  65 + }
  66 +}
... ...
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +/**
  4 + * 全局错误码定义。
  5 + * 段位约定见 docs/04 § 1.3。
  6 + */
  7 +public final class ErrorCode {
  8 +
  9 + private ErrorCode() {}
  10 +
  11 + public static final int OK = 200;
  12 +
  13 + public static final int BAD_REQUEST = 40001;
  14 + public static final int INVALID_ENUM_PARAM = 40003;
  15 + public static final int COMPANY_NOT_FOUND = 40004;
  16 +
  17 + public static final int BAD_CREDENTIALS = 40101;
  18 + public static final int ACCOUNT_DELETED = 40103;
  19 +
  20 + public static final int FORBIDDEN = 40301;
  21 + public static final int USER_FORBIDDEN_SELF_DEACTIVATE = 40302;
  22 +
  23 + public static final int USER_NOT_FOUND = 40401;
  24 +
  25 + public static final int ACCOUNT_LOCKED = 42301;
  26 +
  27 + public static final int CONFLICT_USERNAME = 40901;
  28 + public static final int CONFLICT_USERCODE = 40902;
  29 +
  30 + public static final int INTERNAL_ERROR = 50000;
  31 +
  32 + /**
  33 + * 业务 code → HTTP 状态码映射。
  34 + */
  35 + public static int toHttpStatus(int code) {
  36 + if (code == OK) return 200;
  37 + if (code == ACCOUNT_LOCKED) return 423;
  38 + int hundreds = code / 100;
  39 + if (hundreds == 400) return 400;
  40 + if (hundreds == 401) return 401;
  41 + if (hundreds == 403) return 403;
  42 + if (hundreds == 404) return 404;
  43 + if (hundreds == 409) return 409;
  44 + if (hundreds == 423) return 423;
  45 + if (hundreds == 500) return 500;
  46 + return 500;
  47 + }
  48 +}
... ...
backend/src/main/java/com/xly/erp/common/response/PageResult.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +import lombok.Builder;
  4 +import lombok.Data;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * 通用分页响应包装。docs/04 § 3.2。
  10 + */
  11 +@Data
  12 +@Builder
  13 +public class PageResult<T> {
  14 + private List<T> records;
  15 + private long total;
  16 + private int page;
  17 + private int size;
  18 +}
... ...
backend/src/main/java/com/xly/erp/common/response/Result.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/**
  6 + * 统一响应包装。
  7 + * docs/04 § 1.3。
  8 + */
  9 +@Getter
  10 +public class Result<T> {
  11 + private final int code;
  12 + private final String message;
  13 + private final T data;
  14 + private final long timestamp;
  15 +
  16 + private Result(int code, String message, T data) {
  17 + this.code = code;
  18 + this.message = message;
  19 + this.data = data;
  20 + this.timestamp = System.currentTimeMillis();
  21 + }
  22 +
  23 + public static <T> Result<T> ok(T data) {
  24 + return new Result<>(ErrorCode.OK, "操作成功", data);
  25 + }
  26 +
  27 + public static Result<Void> ok() {
  28 + return new Result<>(ErrorCode.OK, "操作成功", null);
  29 + }
  30 +
  31 + public static <T> Result<T> fail(int code, String message) {
  32 + return new Result<>(code, message, null);
  33 + }
  34 +
  35 + @SuppressWarnings("unchecked")
  36 + public static <T> Result<T> fail(int code, String message, T data) {
  37 + return new Result<>(code, message, data);
  38 + }
  39 +}
... ...
backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.entity.SysUser;
  6 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  7 +import jakarta.servlet.http.HttpServletRequest;
  8 +import jakarta.servlet.http.HttpServletResponse;
  9 +import lombok.RequiredArgsConstructor;
  10 +import lombok.extern.slf4j.Slf4j;
  11 +import org.springframework.stereotype.Component;
  12 +import org.springframework.web.method.HandlerMethod;
  13 +import org.springframework.web.servlet.HandlerInterceptor;
  14 +
  15 +import java.time.LocalDateTime;
  16 +import java.util.Map;
  17 +
  18 +/**
  19 + * REQ-USR-002 鉴权与角色守卫拦截器。
  20 + * - 解析 Authorization Bearer → 校验 user 状态 → set LoginContext
  21 + * - 若 handler 标注 @RequireSuperAdmin,强制 userType == SUPER_ADMIN
  22 + * - afterCompletion 清理 ThreadLocal
  23 + */
  24 +@Component
  25 +@RequiredArgsConstructor
  26 +@Slf4j
  27 +public class JwtHandlerInterceptor implements HandlerInterceptor {
  28 +
  29 + private static final String BEARER_PREFIX = "Bearer ";
  30 +
  31 + private final JwtUtil jwtUtil;
  32 + private final SysUserMapper userMapper;
  33 +
  34 + @Override
  35 + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
  36 + String authHeader = request.getHeader("Authorization");
  37 + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
  38 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "未携带 token");
  39 + }
  40 + String token = authHeader.substring(BEARER_PREFIX.length());
  41 +
  42 + Map<String, Object> claims = jwtUtil.parse(token);
  43 + String username = (String) claims.get("username");
  44 + if (username == null || username.isBlank()) {
  45 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 缺 username claim");
  46 + }
  47 +
  48 + SysUser user = userMapper.selectByUsername(username);
  49 + if (user == null) {
  50 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户不存在");
  51 + }
  52 + if (Integer.valueOf(1).equals(user.getIIsDeleted())) {
  53 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已作废");
  54 + }
  55 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
  56 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已锁定");
  57 + }
  58 +
  59 + String companyCode = (String) claims.get("companyCode");
  60 + LoginContext.set(new LoginContext.LoginUser(
  61 + user.getIIncrement(),
  62 + user.getSUsername(),
  63 + user.getSUserType(),
  64 + companyCode));
  65 +
  66 + if (handler instanceof HandlerMethod hm) {
  67 + if (hm.getMethodAnnotation(RequireSuperAdmin.class) != null
  68 + && !"SUPER_ADMIN".equals(user.getSUserType())) {
  69 + throw new BizException(ErrorCode.FORBIDDEN, "权限不足,仅超级管理员可调用");
  70 + }
  71 + }
  72 + return true;
  73 + }
  74 +
  75 + @Override
  76 + public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
  77 + Object handler, Exception ex) {
  78 + LoginContext.clear();
  79 + }
  80 +}
... ...
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import io.jsonwebtoken.Claims;
  6 +import io.jsonwebtoken.JwtException;
  7 +import io.jsonwebtoken.Jwts;
  8 +import io.jsonwebtoken.security.Keys;
  9 +import jakarta.annotation.PostConstruct;
  10 +import org.springframework.beans.factory.annotation.Value;
  11 +import org.springframework.stereotype.Component;
  12 +
  13 +import javax.crypto.SecretKey;
  14 +import java.nio.charset.StandardCharsets;
  15 +import java.util.Date;
  16 +import java.util.HashMap;
  17 +import java.util.Map;
  18 +import java.util.UUID;
  19 +
  20 +/**
  21 + * JWT 签发与验证工具。HS256,密钥来自 ${JWT_SECRET}。
  22 + * docs/04 § 1.6。
  23 + */
  24 +@Component
  25 +public class JwtUtil {
  26 +
  27 + @Value("${jwt.secret}")
  28 + private String secret;
  29 +
  30 + private SecretKey key;
  31 +
  32 + @PostConstruct
  33 + void init() {
  34 + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
  35 + if (bytes.length < 32) {
  36 + throw new IllegalStateException(
  37 + "JWT_SECRET 长度不足 32 字节(HS256 要求),实际 " + bytes.length
  38 + + " 字节。请在 .env.local 配置至少 256 位的随机字符串。");
  39 + }
  40 + this.key = Keys.hmacShaKeyFor(bytes);
  41 + }
  42 +
  43 + public String issue(Map<String, Object> claims, long ttlSec) {
  44 + long now = System.currentTimeMillis();
  45 + Map<String, Object> all = new HashMap<>(claims);
  46 + String sub = String.valueOf(all.remove("sub"));
  47 + String jti = UUID.randomUUID().toString();
  48 + return Jwts.builder()
  49 + .subject(sub)
  50 + .claims(all)
  51 + .id(jti)
  52 + .issuedAt(new Date(now))
  53 + .expiration(new Date(now + ttlSec * 1000L))
  54 + .signWith(key)
  55 + .compact();
  56 + }
  57 +
  58 + public Map<String, Object> parse(String token) {
  59 + try {
  60 + Claims claims = Jwts.parser()
  61 + .verifyWith(key)
  62 + .build()
  63 + .parseSignedClaims(token)
  64 + .getPayload();
  65 + Map<String, Object> out = new HashMap<>(claims);
  66 + out.put("sub", claims.getSubject());
  67 + out.put("jti", claims.getId());
  68 + out.put("iat", claims.getIssuedAt() != null ? claims.getIssuedAt().getTime() / 1000 : null);
  69 + out.put("exp", claims.getExpiration() != null ? claims.getExpiration().getTime() / 1000 : null);
  70 + return out;
  71 + } catch (JwtException e) {
  72 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 无效或已过期");
  73 + }
  74 + }
  75 +}
... ...
backend/src/main/java/com/xly/erp/common/security/LoginContext.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +/**
  4 + * 请求级登录上下文 — JwtHandlerInterceptor 在 preHandle 时 set,afterCompletion 时 clear。
  5 + * 用普通 ThreadLocal(不用 InheritableThreadLocal)避免子线程意外继承。
  6 + */
  7 +public final class LoginContext {
  8 +
  9 + private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
  10 +
  11 + private LoginContext() {}
  12 +
  13 + public static void set(LoginUser user) {
  14 + HOLDER.set(user);
  15 + }
  16 +
  17 + public static LoginUser current() {
  18 + return HOLDER.get();
  19 + }
  20 +
  21 + public static void clear() {
  22 + HOLDER.remove();
  23 + }
  24 +
  25 + /** 当前登录用户上下文。userType 取值 NORMAL / SUPER_ADMIN。 */
  26 + public record LoginUser(Integer userId, String username, String userType, String companyCode) {}
  27 +}
... ...
backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import java.lang.annotation.ElementType;
  4 +import java.lang.annotation.Retention;
  5 +import java.lang.annotation.RetentionPolicy;
  6 +import java.lang.annotation.Target;
  7 +
  8 +/**
  9 + * 标注在 controller 方法上,要求当前登录用户 userType == SUPER_ADMIN。
  10 + * 由 JwtHandlerInterceptor 在 preHandle 时校验。
  11 + */
  12 +@Target(ElementType.METHOD)
  13 +@Retention(RetentionPolicy.RUNTIME)
  14 +public @interface RequireSuperAdmin {
  15 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.Result;
  4 +import com.xly.erp.module.usr.dto.LoginReq;
  5 +import com.xly.erp.module.usr.service.LoginService;
  6 +import com.xly.erp.module.usr.vo.LoginVo;
  7 +import jakarta.validation.Valid;
  8 +import lombok.RequiredArgsConstructor;
  9 +import org.springframework.web.bind.annotation.PostMapping;
  10 +import org.springframework.web.bind.annotation.RequestBody;
  11 +import org.springframework.web.bind.annotation.RequestMapping;
  12 +import org.springframework.web.bind.annotation.RestController;
  13 +
  14 +/**
  15 + * 认证入口。REQ-USR-001:POST /api/v1/auth/login。
  16 + */
  17 +@RestController
  18 +@RequestMapping("/api/v1/auth")
  19 +@RequiredArgsConstructor
  20 +public class AuthController {
  21 +
  22 + private final LoginService loginService;
  23 +
  24 + @PostMapping("/login")
  25 + public Result<LoginVo> login(@RequestBody @Valid LoginReq req) {
  26 + LoginVo vo = loginService.login(req.getUsername(), req.getPassword(), req.getCompanyCode());
  27 + return Result.ok(vo);
  28 + }
  29 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.PageResult;
  4 +import com.xly.erp.common.response.Result;
  5 +import com.xly.erp.common.security.LoginContext;
  6 +import com.xly.erp.common.security.RequireSuperAdmin;
  7 +import com.xly.erp.module.usr.dto.CreateUserReq;
  8 +import com.xly.erp.module.usr.dto.UpdateUserReq;
  9 +import com.xly.erp.module.usr.dto.UserQueryReq;
  10 +import com.xly.erp.module.usr.service.UserCreateService;
  11 +import com.xly.erp.module.usr.service.UserDetailService;
  12 +import com.xly.erp.module.usr.service.UserListService;
  13 +import com.xly.erp.module.usr.service.UserUpdateService;
  14 +import com.xly.erp.module.usr.vo.CreateUserVo;
  15 +import com.xly.erp.module.usr.vo.UserDetailVo;
  16 +import com.xly.erp.module.usr.vo.UserListItemVo;
  17 +import jakarta.validation.Valid;
  18 +import lombok.RequiredArgsConstructor;
  19 +import org.springframework.http.HttpStatus;
  20 +import org.springframework.http.ResponseEntity;
  21 +import org.springframework.web.bind.annotation.GetMapping;
  22 +import org.springframework.web.bind.annotation.PathVariable;
  23 +import org.springframework.web.bind.annotation.PostMapping;
  24 +import org.springframework.web.bind.annotation.PutMapping;
  25 +import org.springframework.web.bind.annotation.RequestBody;
  26 +import org.springframework.web.bind.annotation.RequestMapping;
  27 +import org.springframework.web.bind.annotation.RestController;
  28 +
  29 +@RestController
  30 +@RequestMapping("/api/v1/users")
  31 +@RequiredArgsConstructor
  32 +public class UserController {
  33 +
  34 + private final UserCreateService userCreateService;
  35 + private final UserDetailService userDetailService;
  36 + private final UserUpdateService userUpdateService;
  37 + private final UserListService userListService;
  38 +
  39 + @PostMapping
  40 + @RequireSuperAdmin
  41 + public ResponseEntity<Result<CreateUserVo>> create(@RequestBody @Valid CreateUserReq req) {
  42 + String operator = LoginContext.current().username();
  43 + CreateUserVo vo = userCreateService.create(req, operator);
  44 + return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo));
  45 + }
  46 +
  47 + @GetMapping
  48 + @RequireSuperAdmin
  49 + public Result<PageResult<UserListItemVo>> list(@Valid UserQueryReq req) {
  50 + return Result.ok(userListService.list(req));
  51 + }
  52 +
  53 + @GetMapping("/{userId}")
  54 + @RequireSuperAdmin
  55 + public Result<UserDetailVo> getById(@PathVariable Integer userId) {
  56 + return Result.ok(userDetailService.getById(userId));
  57 + }
  58 +
  59 + @PutMapping("/{userId}")
  60 + @RequireSuperAdmin
  61 + public Result<UserDetailVo> update(@PathVariable Integer userId,
  62 + @RequestBody @Valid UpdateUserReq req) {
  63 + LoginContext.LoginUser cur = LoginContext.current();
  64 + UserDetailVo vo = userUpdateService.update(userId, req, cur.userId(), cur.username());
  65 + return Result.ok(vo);
  66 + }
  67 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import jakarta.validation.constraints.Pattern;
  6 +import jakarta.validation.constraints.Size;
  7 +import lombok.Data;
  8 +
  9 +import java.util.List;
  10 +
  11 +@Data
  12 +public class CreateUserReq {
  13 +
  14 + @NotBlank
  15 + @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$",
  16 + message = "用户名必须为 3-20 位字母数字下划线")
  17 + private String username;
  18 +
  19 + @NotBlank
  20 + @Size(max = 50)
  21 + private String userCode;
  22 +
  23 + @NotBlank
  24 + @Pattern(regexp = "NORMAL|SUPER_ADMIN",
  25 + message = "userType 必须为 NORMAL 或 SUPER_ADMIN")
  26 + private String userType;
  27 +
  28 + @NotBlank
  29 + @Pattern(regexp = "zh-CN|en-US|zh-TW",
  30 + message = "language 必须为 zh-CN / en-US / zh-TW")
  31 + private String language;
  32 +
  33 + @NotNull
  34 + private Boolean canEditDocument;
  35 +
  36 + /** 可选;非空则必须命中 sys_employee.iIncrement 且 iIsDeleted=0 */
  37 + private Integer employeeId;
  38 +
  39 + /** 可选;空数组 / null 都允许;非空时每个 ID 必须命中 sys_permission_category */
  40 + private List<Integer> permissionCategoryIds;
  41 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Size;
  5 +import lombok.Data;
  6 +
  7 +@Data
  8 +public class LoginReq {
  9 +
  10 + @NotBlank
  11 + @Size(max = 50)
  12 + private String username;
  13 +
  14 + @NotBlank
  15 + @Size(max = 128)
  16 + private String password;
  17 +
  18 + @NotBlank
  19 + @Size(max = 50)
  20 + private String companyCode;
  21 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.Min;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import jakarta.validation.constraints.Size;
  6 +import lombok.Data;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * PATCH 语义:所有字段都可选;缺省 / 显式 null 视为不变。
  12 + * 特例:employeeId == 0 视为解除关联(DB 写 NULL)。
  13 + */
  14 +@Data
  15 +public class UpdateUserReq {
  16 +
  17 + @Size(max = 50)
  18 + @Pattern(regexp = "^\\S+$", message = "userCode 不可为空白")
  19 + private String userCode;
  20 +
  21 + @Pattern(regexp = "NORMAL|SUPER_ADMIN",
  22 + message = "userType 必须为 NORMAL 或 SUPER_ADMIN")
  23 + private String userType;
  24 +
  25 + @Pattern(regexp = "zh-CN|en-US|zh-TW",
  26 + message = "language 必须为 zh-CN / en-US / zh-TW")
  27 + private String language;
  28 +
  29 + private Boolean canEditDocument;
  30 +
  31 + @Min(value = 0, message = "employeeId 必须 >= 0;0 表示解除关联")
  32 + private Integer employeeId;
  33 +
  34 + private Boolean isDeleted;
  35 +
  36 + private List<Integer> permissionCategoryIds;
  37 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryReq.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.Max;
  4 +import jakarta.validation.constraints.Min;
  5 +import lombok.Data;
  6 +
  7 +/**
  8 + * 用户列表查询请求。所有字段可选;枚举值白名单由 service 层校验。
  9 + * REQ-USR-004。
  10 + */
  11 +@Data
  12 +public class UserQueryReq {
  13 +
  14 + @Min(value = 1, message = "page 必须 >= 1")
  15 + private Integer page;
  16 +
  17 + @Min(value = 1, message = "size 必须 >= 1")
  18 + @Max(value = 100, message = "size 不能超过 100")
  19 + private Integer size;
  20 +
  21 + private String sortField;
  22 + private String sortOrder;
  23 + private String queryField;
  24 + private String matchMode;
  25 + private String queryValue;
  26 + private String userType;
  27 + private Boolean isDeleted;
  28 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.Data;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +/**
  11 + * 公司表实体(只需登录用到的字段)。docs/03 § sys_company。
  12 + */
  13 +@Data
  14 +@TableName("sys_company")
  15 +public class SysCompany {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + private String sId;
  21 + private String sBrandsId;
  22 + private String sSubsidiaryId;
  23 + private LocalDateTime tCreateDate;
  24 +
  25 + private String sCompanyName;
  26 + private String sCompanyCode;
  27 + private Integer iSortOrder;
  28 + private Integer iIsDeleted;
  29 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.Data;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +/**
  11 + * 职员表实体(只读 join,含登录返回 employeeName 所需的最小字段)。
  12 + * docs/03 § sys_employee。
  13 + */
  14 +@Data
  15 +@TableName("sys_employee")
  16 +public class SysEmployee {
  17 +
  18 + @TableId(value = "iIncrement", type = IdType.AUTO)
  19 + private Integer iIncrement;
  20 +
  21 + private String sId;
  22 + private String sBrandsId;
  23 + private String sSubsidiaryId;
  24 + private LocalDateTime tCreateDate;
  25 +
  26 + private String sEmployeeName;
  27 + private String sEmployeeCode;
  28 + private Integer iDepartmentId;
  29 + private String sPhone;
  30 + private String sEmail;
  31 + private Integer iIsDeleted;
  32 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.Data;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +@Data
  11 +@TableName("sys_permission_category")
  12 +public class SysPermissionCategory {
  13 +
  14 + @TableId(value = "iIncrement", type = IdType.AUTO)
  15 + private Integer iIncrement;
  16 +
  17 + private String sId;
  18 + private String sBrandsId;
  19 + private String sSubsidiaryId;
  20 + private LocalDateTime tCreateDate;
  21 +
  22 + private String sCategoryName;
  23 + private String sCategoryCode;
  24 + private String sCategoryDesc;
  25 + private Integer iSortOrder;
  26 + private Integer iIsDeleted;
  27 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.Data;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +/**
  11 + * 用户表实体。docs/03 § sys_user。
  12 + */
  13 +@Data
  14 +@TableName("sys_user")
  15 +public class SysUser {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + private String sId;
  21 + private String sBrandsId;
  22 + private String sSubsidiaryId;
  23 + private LocalDateTime tCreateDate;
  24 +
  25 + private String sUsername;
  26 + private String sUserCode;
  27 + private String sPasswordHash;
  28 +
  29 + private Integer iEmployeeId;
  30 + private String sUserType;
  31 + private String sLanguage;
  32 + private Integer iCanEditDocument;
  33 + private Integer iIsDeleted;
  34 + private Integer iFailedLoginCount;
  35 + private LocalDateTime tLockUntil;
  36 + private LocalDateTime tLastLoginDate;
  37 + private String sCreatedBy;
  38 + private String sUpdatedBy;
  39 + private LocalDateTime tUpdatedDate;
  40 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java 0 → 100644
  1 +package com.xly.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableId;
  5 +import com.baomidou.mybatisplus.annotation.TableName;
  6 +import lombok.Data;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +@Data
  11 +@TableName("sys_user_permission_category")
  12 +public class SysUserPermissionCategory {
  13 +
  14 + @TableId(value = "iIncrement", type = IdType.AUTO)
  15 + private Integer iIncrement;
  16 +
  17 + private String sId;
  18 + private String sBrandsId;
  19 + private String sSubsidiaryId;
  20 + private LocalDateTime tCreateDate;
  21 +
  22 + private Integer iUserId;
  23 + private Integer iPermissionCategoryId;
  24 + private String sGrantedBy;
  25 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.SysCompany;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +import org.apache.ibatis.annotations.Select;
  7 +
  8 +@Mapper
  9 +public interface SysCompanyMapper extends BaseMapper<SysCompany> {
  10 +
  11 + @Select("SELECT iIncrement, sCompanyCode, sCompanyName, iIsDeleted " +
  12 + "FROM sys_company WHERE sCompanyCode = #{code} LIMIT 1")
  13 + SysCompany selectByCode(String code);
  14 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.SysEmployee;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface SysEmployeeMapper extends BaseMapper<SysEmployee> {
  9 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.SysPermissionCategory;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +import org.apache.ibatis.annotations.Param;
  7 +import org.apache.ibatis.annotations.Select;
  8 +
  9 +import java.util.List;
  10 +
  11 +@Mapper
  12 +public interface SysPermissionCategoryMapper extends BaseMapper<SysPermissionCategory> {
  13 +
  14 + /**
  15 + * 计算给定 ID 集合中有多少行未删除(iIsDeleted=0)。
  16 + * 用于 REQ-USR-002 批量校验 permissionCategoryIds 是否全部存在。
  17 + */
  18 + @Select({
  19 + "<script>",
  20 + "SELECT COUNT(*) FROM sys_permission_category ",
  21 + "WHERE iIsDeleted = 0 AND iIncrement IN ",
  22 + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>",
  23 + "</script>"
  24 + })
  25 + int countActiveByIds(@Param("ids") List<Integer> ids);
  26 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.SysUser;
  5 +import com.xly.erp.module.usr.vo.UserListItemVo;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +import org.apache.ibatis.annotations.Param;
  8 +import org.apache.ibatis.annotations.Select;
  9 +import org.apache.ibatis.annotations.Update;
  10 +
  11 +import java.util.List;
  12 +
  13 +@Mapper
  14 +public interface SysUserMapper extends BaseMapper<SysUser> {
  15 +
  16 + String LOGIN_COLUMNS = "iIncrement, sUsername, sUserCode, sPasswordHash, iEmployeeId, " +
  17 + "sUserType, sLanguage, iCanEditDocument, iIsDeleted, iFailedLoginCount, " +
  18 + "tLockUntil, tLastLoginDate";
  19 +
  20 + @Select("SELECT " + LOGIN_COLUMNS + " FROM sys_user WHERE sUsername = #{username} LIMIT 1")
  21 + SysUser selectByUsername(String username);
  22 +
  23 + /**
  24 + * 原子累加失败登录次数;达到阈值 maxCount 时同步写 tLockUntil = NOW() + lockMinutes 分钟。
  25 + * 单 SQL,DB 层保证并发安全。返回受影响行数(应为 1)。
  26 + * MySQL 按 SET 子句从左到右求值,所以放在 +1 之后的引用看到的是新值。
  27 + */
  28 + @Update("UPDATE sys_user " +
  29 + "SET iFailedLoginCount = iFailedLoginCount + 1, " +
  30 + " tLockUntil = IF(iFailedLoginCount >= #{maxCount}, " +
  31 + " DATE_ADD(NOW(), INTERVAL #{lockMinutes} MINUTE), " +
  32 + " tLockUntil) " +
  33 + "WHERE iIncrement = #{userId}")
  34 + int incrementFailedLoginCountAtomic(@Param("userId") Integer userId,
  35 + @Param("maxCount") int maxCount,
  36 + @Param("lockMinutes") long lockMinutes);
  37 +
  38 + /**
  39 + * 成功登录写入:清零计数 + 清空锁定 + 更新登录时间。一次 UPDATE。
  40 + */
  41 + @Update("UPDATE sys_user " +
  42 + "SET iFailedLoginCount = 0, tLockUntil = NULL, tLastLoginDate = NOW() " +
  43 + "WHERE iIncrement = #{userId}")
  44 + int markLoginSuccess(@Param("userId") Integer userId);
  45 +
  46 + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUsername = #{username})")
  47 + boolean existsByUsername(@Param("username") String username);
  48 +
  49 + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode})")
  50 + boolean existsByUserCode(@Param("userCode") String userCode);
  51 +
  52 + @Select("SELECT EXISTS(SELECT 1 FROM sys_user " +
  53 + "WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})")
  54 + boolean existsByUserCodeExcludingId(@Param("userCode") String userCode,
  55 + @Param("excludedUserId") Integer excludedUserId);
  56 +
  57 + /**
  58 + * REQ-USR-004 动态查询。SQL 在 SysUserMapper.xml 定义。
  59 + * QueryParams 必须已通过 service 层白名单校验。
  60 + */
  61 + List<UserListItemVo> selectByQuery(@Param("p") UserQueryParams p);
  62 +
  63 + long countByQuery(@Param("p") UserQueryParams p);
  64 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  5 +import org.apache.ibatis.annotations.Delete;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +import org.apache.ibatis.annotations.Param;
  8 +import org.apache.ibatis.annotations.Select;
  9 +
  10 +import java.util.List;
  11 +
  12 +@Mapper
  13 +public interface SysUserPermissionCategoryMapper extends BaseMapper<SysUserPermissionCategory> {
  14 +
  15 + @Select("SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}")
  16 + List<Integer> selectPermissionCategoryIdsByUserId(@Param("userId") Integer userId);
  17 +
  18 + @Delete({
  19 + "<script>",
  20 + "DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN ",
  21 + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>",
  22 + "</script>"
  23 + })
  24 + int deleteByUserAndCategoryIds(@Param("userId") Integer userId,
  25 + @Param("ids") List<Integer> categoryIds);
  26 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/mapper/UserQueryParams.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +/**
  4 + * SysUserMapper.selectByQuery / countByQuery 入参(service 层规范化白名单后填入)。
  5 + * sqlSortField / sqlSortOrder / sqlQueryColumn 必须已通过白名单校验;
  6 + * mapper XML 直接用 ${} 拼接到 SQL。
  7 + */
  8 +public class UserQueryParams {
  9 + public String sqlSortField;
  10 + public String sqlSortOrder;
  11 + public String sqlQueryColumn; // null 表示无 queryField 条件
  12 + public String matchMode; // contains / notContains / equals
  13 + public String queryValue; // null/"" 表示跳过该条件
  14 + public String userType; // null 表示不过滤
  15 + public Integer isDeleted; // null 不过滤;0 / 1 过滤
  16 + public Integer offset;
  17 + public Integer limit;
  18 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.vo.LoginVo;
  4 +
  5 +public interface LoginService {
  6 + /**
  7 + * 校验用户名 + 密码 + 公司编码并签发 access token。
  8 + * REQ-USR-001。
  9 + *
  10 + * @throws com.xly.erp.common.exception.BizException
  11 + * 40004 公司不存在 / 40101 凭据错误 / 40103 账号作废 / 42301 账号锁定
  12 + */
  13 + LoginVo login(String username, String password, String companyCode);
  14 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.dto.CreateUserReq;
  4 +import com.xly.erp.module.usr.vo.CreateUserVo;
  5 +
  6 +public interface UserCreateService {
  7 + /**
  8 + * 新建用户 + 权限分类授权。
  9 + * REQ-USR-002。
  10 + *
  11 + * @param req 已通过 jakarta 校验的请求体
  12 + * @param operatorUsername 当前登录用户(写入 sCreatedBy / sGrantedBy)
  13 + * @throws com.xly.erp.common.exception.BizException
  14 + * 40004 employee / permissionCategory 不存在 / 40901 用户名重复 / 40902 用户号重复
  15 + */
  16 + CreateUserVo create(CreateUserReq req, String operatorUsername);
  17 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.vo.UserDetailVo;
  4 +
  5 +public interface UserDetailService {
  6 + /**
  7 + * REQ-USR-003 GET /api/v1/users/{userId} 详情。
  8 + * 包含作废用户(不过滤 iIsDeleted)。
  9 + *
  10 + * @throws com.xly.erp.common.exception.BizException 40401 用户不存在
  11 + */
  12 + UserDetailVo getById(Integer userId);
  13 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.response.PageResult;
  4 +import com.xly.erp.module.usr.dto.UserQueryReq;
  5 +import com.xly.erp.module.usr.vo.UserListItemVo;
  6 +
  7 +public interface UserListService {
  8 + /**
  9 + * REQ-USR-004 GET /api/v1/users — 分页 + 多字段筛选 + 排序。
  10 + * 白名单校验、越界矫正均由实现层完成。
  11 + */
  12 + PageResult<UserListItemVo> list(UserQueryReq req);
  13 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.dto.UpdateUserReq;
  4 +import com.xly.erp.module.usr.vo.UserDetailVo;
  5 +
  6 +public interface UserUpdateService {
  7 + /**
  8 + * REQ-USR-003 PUT /api/v1/users/{userId}:部分字段更新 + 权限分类增量差集。
  9 + *
  10 + * @throws com.xly.erp.common.exception.BizException
  11 + * 40004 employee/permissionCategory 不存在 / 40302 自我停用 / 40401 用户不存在 / 40902 用户号冲突
  12 + */
  13 + UserDetailVo update(Integer userId, UpdateUserReq req,
  14 + Integer operatorUserId, String operatorUsername);
  15 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.security.JwtUtil;
  6 +import com.xly.erp.module.usr.entity.SysCompany;
  7 +import com.xly.erp.module.usr.entity.SysEmployee;
  8 +import com.xly.erp.module.usr.entity.SysUser;
  9 +import com.xly.erp.module.usr.mapper.SysCompanyMapper;
  10 +import com.xly.erp.module.usr.mapper.SysEmployeeMapper;
  11 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  12 +import com.xly.erp.module.usr.service.LoginService;
  13 +import com.xly.erp.module.usr.vo.LoginVo;
  14 +import com.xly.erp.module.usr.vo.UserInfoVo;
  15 +import lombok.RequiredArgsConstructor;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  18 +import org.springframework.stereotype.Service;
  19 +
  20 +import java.time.LocalDateTime;
  21 +import java.time.format.DateTimeFormatter;
  22 +import java.util.HashMap;
  23 +import java.util.Map;
  24 +
  25 +@Service
  26 +@RequiredArgsConstructor
  27 +@Slf4j
  28 +public class LoginServiceImpl implements LoginService {
  29 +
  30 + static final int MAX_FAILED_LOGIN_COUNT = 5;
  31 + static final long LOCK_DURATION_MINUTES = 30L;
  32 + static final long TOKEN_TTL_SEC = 7200L;
  33 +
  34 + private final SysUserMapper userMapper;
  35 + private final SysCompanyMapper companyMapper;
  36 + private final SysEmployeeMapper employeeMapper;
  37 + private final BCryptPasswordEncoder passwordEncoder;
  38 + private final JwtUtil jwtUtil;
  39 +
  40 + @Override
  41 + public LoginVo login(String username, String password, String companyCode) {
  42 + // 1. 公司校验(只读,不需事务)
  43 + SysCompany company = companyMapper.selectByCode(companyCode);
  44 + if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) {
  45 + log.warn("[login] companyCode={} 不存在或已删除", companyCode);
  46 + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除");
  47 + }
  48 +
  49 + // 2. 用户查找
  50 + SysUser user = userMapper.selectByUsername(username);
  51 + if (user == null) {
  52 + log.warn("[login] username={} 不存在(统一返 40101)", username);
  53 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误");
  54 + }
  55 +
  56 + // 3. 作废校验(不计入失败次数)
  57 + if (Integer.valueOf(1).equals(user.getIIsDeleted())) {
  58 + log.warn("[login] username={} 已作废", username);
  59 + throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录");
  60 + }
  61 +
  62 + // 4. 锁定校验(不计入失败次数;过期锁定视为已解锁)
  63 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
  64 + log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil());
  65 + Map<String, Object> data = new HashMap<>();
  66 + data.put("lockUntil", user.getTLockUntil()
  67 + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
  68 + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试", data);
  69 + }
  70 +
  71 + // 5. 密码校验
  72 + if (!passwordEncoder.matches(password, user.getSPasswordHash())) {
  73 + int rows = userMapper.incrementFailedLoginCountAtomic(
  74 + user.getIIncrement(), MAX_FAILED_LOGIN_COUNT, LOCK_DURATION_MINUTES);
  75 + log.warn("[login] username={} 密码错误,原子累加失败次数 rows={}", username, rows);
  76 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误");
  77 + }
  78 +
  79 + // 6. 成功路径
  80 + return loginSuccess(user, companyCode);
  81 + }
  82 +
  83 + private LoginVo loginSuccess(SysUser user, String companyCode) {
  84 + userMapper.markLoginSuccess(user.getIIncrement());
  85 +
  86 + String employeeName = null;
  87 + if (user.getIEmployeeId() != null) {
  88 + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId());
  89 + if (emp != null) {
  90 + employeeName = emp.getSEmployeeName();
  91 + }
  92 + }
  93 +
  94 + Map<String, Object> claims = new HashMap<>();
  95 + claims.put("sub", user.getIIncrement());
  96 + claims.put("username", user.getSUsername());
  97 + claims.put("userType", user.getSUserType());
  98 + claims.put("companyCode", companyCode);
  99 + claims.put("language", user.getSLanguage());
  100 +
  101 + String token = jwtUtil.issue(claims, TOKEN_TTL_SEC);
  102 +
  103 + log.info("[login] username={} 登录成功", user.getSUsername());
  104 +
  105 + return LoginVo.builder()
  106 + .accessToken(token)
  107 + .tokenType("Bearer")
  108 + .expiresInSec(TOKEN_TTL_SEC)
  109 + .userInfo(UserInfoVo.builder()
  110 + .userId(user.getIIncrement())
  111 + .username(user.getSUsername())
  112 + .userType(user.getSUserType())
  113 + .language(user.getSLanguage())
  114 + .companyCode(companyCode)
  115 + .employeeName(employeeName)
  116 + .build())
  117 + .build();
  118 + }
  119 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.dto.CreateUserReq;
  6 +import com.xly.erp.module.usr.entity.SysEmployee;
  7 +import com.xly.erp.module.usr.entity.SysUser;
  8 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  9 +import com.xly.erp.module.usr.mapper.SysEmployeeMapper;
  10 +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper;
  11 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  12 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  13 +import com.xly.erp.module.usr.service.UserCreateService;
  14 +import com.xly.erp.module.usr.vo.CreateUserVo;
  15 +import lombok.RequiredArgsConstructor;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.dao.DataIntegrityViolationException;
  18 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  19 +import org.springframework.stereotype.Service;
  20 +import org.springframework.transaction.annotation.Transactional;
  21 +
  22 +import java.util.List;
  23 +
  24 +@Service
  25 +@RequiredArgsConstructor
  26 +@Slf4j
  27 +public class UserCreateServiceImpl implements UserCreateService {
  28 +
  29 + static final String INITIAL_PASSWORD = "666666";
  30 +
  31 + private final SysUserMapper userMapper;
  32 + private final SysEmployeeMapper employeeMapper;
  33 + private final SysPermissionCategoryMapper permissionCategoryMapper;
  34 + private final SysUserPermissionCategoryMapper userPermissionCategoryMapper;
  35 + private final BCryptPasswordEncoder passwordEncoder;
  36 +
  37 + @Override
  38 + @Transactional
  39 + public CreateUserVo create(CreateUserReq req, String operatorUsername) {
  40 + // 1. 唯一性预检(返友好错误码;DB 唯一索引兜底并发场景)
  41 + if (userMapper.existsByUsername(req.getUsername())) {
  42 + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在");
  43 + }
  44 + if (userMapper.existsByUserCode(req.getUserCode())) {
  45 + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在");
  46 + }
  47 +
  48 + // 2. employee 外键校验
  49 + if (req.getEmployeeId() != null) {
  50 + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId());
  51 + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) {
  52 + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除");
  53 + }
  54 + }
  55 +
  56 + // 3. permissionCategory 外键校验(批量)
  57 + List<Integer> pcIds = req.getPermissionCategoryIds();
  58 + if (pcIds != null && !pcIds.isEmpty()) {
  59 + int active = permissionCategoryMapper.countActiveByIds(pcIds);
  60 + if (active != pcIds.size()) {
  61 + throw new BizException(ErrorCode.COMPANY_NOT_FOUND,
  62 + "指定的权限分类含不存在或已删除项");
  63 + }
  64 + }
  65 +
  66 + // 4. 写入 sys_user
  67 + SysUser user = new SysUser();
  68 + user.setSUsername(req.getUsername());
  69 + user.setSUserCode(req.getUserCode());
  70 + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD));
  71 + user.setIEmployeeId(req.getEmployeeId());
  72 + user.setSUserType(req.getUserType());
  73 + user.setSLanguage(req.getLanguage());
  74 + user.setICanEditDocument(Boolean.TRUE.equals(req.getCanEditDocument()) ? 1 : 0);
  75 + user.setIIsDeleted(0);
  76 + user.setIFailedLoginCount(0);
  77 + user.setSCreatedBy(operatorUsername);
  78 + try {
  79 + userMapper.insert(user);
  80 + } catch (DataIntegrityViolationException e) {
  81 + String msg = e.getMessage() == null ? "" : e.getMessage();
  82 + if (msg.contains("uk_sys_user_username")) {
  83 + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在");
  84 + }
  85 + if (msg.contains("uk_sys_user_code")) {
  86 + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在");
  87 + }
  88 + throw e;
  89 + }
  90 +
  91 + // 5. 写入 sys_user_permission_category(如有)
  92 + if (pcIds != null && !pcIds.isEmpty()) {
  93 + for (Integer pcId : pcIds) {
  94 + SysUserPermissionCategory link = new SysUserPermissionCategory();
  95 + link.setIUserId(user.getIIncrement());
  96 + link.setIPermissionCategoryId(pcId);
  97 + link.setSGrantedBy(operatorUsername);
  98 + userPermissionCategoryMapper.insert(link);
  99 + }
  100 + }
  101 +
  102 + log.info("[user-create] username={} userCode={} byOperator={} permissionCount={}",
  103 + user.getSUsername(), user.getSUserCode(), operatorUsername,
  104 + pcIds == null ? 0 : pcIds.size());
  105 +
  106 + return CreateUserVo.builder()
  107 + .userId(user.getIIncrement())
  108 + .username(user.getSUsername())
  109 + .userCode(user.getSUserCode())
  110 + .build();
  111 + }
  112 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.entity.SysEmployee;
  6 +import com.xly.erp.module.usr.entity.SysUser;
  7 +import com.xly.erp.module.usr.mapper.SysEmployeeMapper;
  8 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  9 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  10 +import com.xly.erp.module.usr.service.UserDetailService;
  11 +import com.xly.erp.module.usr.vo.UserDetailVo;
  12 +import lombok.RequiredArgsConstructor;
  13 +import org.springframework.stereotype.Service;
  14 +
  15 +import java.util.List;
  16 +
  17 +@Service
  18 +@RequiredArgsConstructor
  19 +public class UserDetailServiceImpl implements UserDetailService {
  20 +
  21 + private final SysUserMapper userMapper;
  22 + private final SysEmployeeMapper employeeMapper;
  23 + private final SysUserPermissionCategoryMapper upcMapper;
  24 +
  25 + @Override
  26 + public UserDetailVo getById(Integer userId) {
  27 + SysUser user = userMapper.selectById(userId);
  28 + if (user == null) {
  29 + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在");
  30 + }
  31 +
  32 + String employeeName = null;
  33 + if (user.getIEmployeeId() != null) {
  34 + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId());
  35 + if (emp != null) {
  36 + employeeName = emp.getSEmployeeName();
  37 + }
  38 + }
  39 +
  40 + List<Integer> pcIds = upcMapper.selectPermissionCategoryIdsByUserId(userId);
  41 +
  42 + return UserDetailVo.builder()
  43 + .userId(user.getIIncrement())
  44 + .username(user.getSUsername())
  45 + .userCode(user.getSUserCode())
  46 + .userType(user.getSUserType())
  47 + .language(user.getSLanguage())
  48 + .canEditDocument(Integer.valueOf(1).equals(user.getICanEditDocument()))
  49 + .isDeleted(Integer.valueOf(1).equals(user.getIIsDeleted()))
  50 + .employeeId(user.getIEmployeeId())
  51 + .employeeName(employeeName)
  52 + .permissionCategoryIds(pcIds)
  53 + .updatedBy(user.getSUpdatedBy())
  54 + .updatedDate(user.getTUpdatedDate())
  55 + .build();
  56 + }
  57 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.response.PageResult;
  6 +import com.xly.erp.module.usr.dto.UserQueryReq;
  7 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  8 +import com.xly.erp.module.usr.mapper.UserQueryParams;
  9 +import com.xly.erp.module.usr.service.UserListService;
  10 +import com.xly.erp.module.usr.vo.UserListItemVo;
  11 +import lombok.RequiredArgsConstructor;
  12 +import org.springframework.stereotype.Service;
  13 +
  14 +import java.time.LocalDateTime;
  15 +import java.time.format.DateTimeFormatter;
  16 +import java.time.format.DateTimeParseException;
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +import java.util.Set;
  20 +
  21 +@Service
  22 +@RequiredArgsConstructor
  23 +public class UserListServiceImpl implements UserListService {
  24 +
  25 + static final int DEFAULT_PAGE = 1;
  26 + static final int DEFAULT_SIZE = 20;
  27 + static final String DEFAULT_SORT_FIELD = "tCreateDate";
  28 + static final String DEFAULT_SORT_ORDER = "desc";
  29 + static final String DEFAULT_MATCH_MODE = "contains";
  30 +
  31 + static final Set<String> SORT_FIELDS = Set.of(
  32 + "tCreateDate", "tLastLoginDate", "sUsername", "sUserCode");
  33 +
  34 + static final Set<String> SORT_ORDERS = Set.of("asc", "desc");
  35 +
  36 + static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals");
  37 +
  38 + /** spec § 业务规则 3:非字符串列(int/datetime)一律按 equals 处理。 */
  39 + static final Set<String> EQUALS_ONLY_FIELDS = Set.of("isDeleted", "lastLoginDate");
  40 +
  41 + static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN");
  42 +
  43 + static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries(
  44 + Map.entry("username", "u.sUsername"),
  45 + Map.entry("employeeName", "e.sEmployeeName"),
  46 + Map.entry("userCode", "u.sUserCode"),
  47 + Map.entry("departmentName", "d.sDepartmentName"),
  48 + Map.entry("userType", "u.sUserType"),
  49 + Map.entry("isDeleted", "u.iIsDeleted"),
  50 + Map.entry("lastLoginDate", "u.tLastLoginDate"),
  51 + Map.entry("createdBy", "u.sCreatedBy"));
  52 +
  53 + private final SysUserMapper userMapper;
  54 +
  55 + @Override
  56 + public PageResult<UserListItemVo> list(UserQueryReq req) {
  57 + // 应用默认值
  58 + int page = req.getPage() == null ? DEFAULT_PAGE : req.getPage();
  59 + int size = req.getSize() == null ? DEFAULT_SIZE : req.getSize();
  60 + String sortField = req.getSortField() == null ? DEFAULT_SORT_FIELD : req.getSortField();
  61 + String sortOrder = req.getSortOrder() == null ? DEFAULT_SORT_ORDER : req.getSortOrder();
  62 + String matchMode = req.getMatchMode() == null ? DEFAULT_MATCH_MODE : req.getMatchMode();
  63 +
  64 + // 白名单校验
  65 + if (!SORT_FIELDS.contains(sortField)) {
  66 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "sortField 不在白名单");
  67 + }
  68 + if (!SORT_ORDERS.contains(sortOrder)) {
  69 + throw new BizException(ErrorCode.BAD_REQUEST, "sortOrder 必须为 asc 或 desc");
  70 + }
  71 + if (!MATCH_MODES.contains(matchMode)) {
  72 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "matchMode 不在白名单");
  73 + }
  74 +
  75 + String sqlQueryColumn = null;
  76 + String normalizedQueryValue = null;
  77 + if (req.getQueryField() != null && !req.getQueryField().isBlank()) {
  78 + sqlQueryColumn = QUERY_FIELD_TO_SQL.get(req.getQueryField());
  79 + if (sqlQueryColumn == null) {
  80 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单");
  81 + }
  82 + // 只有 queryField + queryValue 都提供且非空才应用条件
  83 + if (req.getQueryValue() != null && !req.getQueryValue().isBlank()) {
  84 + normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue());
  85 + // spec § 业务规则 3:非字符串列一律强制 equals
  86 + if (EQUALS_ONLY_FIELDS.contains(req.getQueryField())) {
  87 + matchMode = "equals";
  88 + }
  89 + } else {
  90 + sqlQueryColumn = null; // 缺 queryValue 跳过条件
  91 + }
  92 + }
  93 +
  94 + if (req.getUserType() != null && !USER_TYPES.contains(req.getUserType())) {
  95 + throw new BizException(ErrorCode.BAD_REQUEST, "userType 必须为 NORMAL 或 SUPER_ADMIN");
  96 + }
  97 +
  98 + UserQueryParams p = new UserQueryParams();
  99 + p.sqlSortField = sortField;
  100 + p.sqlSortOrder = sortOrder;
  101 + p.sqlQueryColumn = sqlQueryColumn;
  102 + p.matchMode = matchMode;
  103 + p.queryValue = normalizedQueryValue;
  104 + p.userType = req.getUserType();
  105 + p.isDeleted = req.getIsDeleted() == null ? null : (req.getIsDeleted() ? 1 : 0);
  106 + p.limit = size;
  107 + p.offset = (page - 1) * size;
  108 +
  109 + long total = userMapper.countByQuery(p);
  110 + List<UserListItemVo> records = userMapper.selectByQuery(p);
  111 +
  112 + // 越界矫正:当前页空但 total>0 → 重算最后一页
  113 + int actualPage = page;
  114 + if (records.isEmpty() && total > 0) {
  115 + int lastPage = (int) ((total + size - 1) / size);
  116 + p.offset = (lastPage - 1) * size;
  117 + records = userMapper.selectByQuery(p);
  118 + actualPage = lastPage;
  119 + }
  120 +
  121 + return PageResult.<UserListItemVo>builder()
  122 + .records(records)
  123 + .total(total)
  124 + .page(actualPage)
  125 + .size(size)
  126 + .build();
  127 + }
  128 +
  129 + /**
  130 + * 对非字符串列做规范化(spec § 业务规则 3):
  131 + * - isDeleted: true/1 → "1";false/0 → "0";其他抛 40001
  132 + * - lastLoginDate: 解析 ISO LOCAL_DATE_TIME 或 ISO LOCAL_DATE,统一格式为 'yyyy-MM-dd HH:mm:ss';非法抛 40001
  133 + * - 其他列返回原值
  134 + */
  135 + private String normalizeQueryValue(String queryField, String raw) {
  136 + if ("isDeleted".equals(queryField)) {
  137 + if ("true".equalsIgnoreCase(raw) || "1".equals(raw)) return "1";
  138 + if ("false".equalsIgnoreCase(raw) || "0".equals(raw)) return "0";
  139 + throw new BizException(ErrorCode.BAD_REQUEST,
  140 + "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1");
  141 + }
  142 + if ("lastLoginDate".equals(queryField)) {
  143 + try {
  144 + LocalDateTime dt;
  145 + if (raw.contains("T") || raw.contains(" ")) {
  146 + dt = LocalDateTime.parse(raw.replace(' ', 'T'));
  147 + } else {
  148 + dt = java.time.LocalDate.parse(raw).atStartOfDay();
  149 + }
  150 + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  151 + } catch (DateTimeParseException e) {
  152 + throw new BizException(ErrorCode.BAD_REQUEST,
  153 + "queryField=lastLoginDate 时 queryValue 必须为 ISO 日期或日期时间");
  154 + }
  155 + }
  156 + return raw;
  157 + }
  158 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
  4 +import com.xly.erp.common.exception.BizException;
  5 +import com.xly.erp.common.response.ErrorCode;
  6 +import com.xly.erp.module.usr.dto.UpdateUserReq;
  7 +import com.xly.erp.module.usr.entity.SysEmployee;
  8 +import com.xly.erp.module.usr.entity.SysUser;
  9 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  10 +import com.xly.erp.module.usr.mapper.SysEmployeeMapper;
  11 +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper;
  12 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  13 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  14 +import com.xly.erp.module.usr.service.UserDetailService;
  15 +import com.xly.erp.module.usr.service.UserUpdateService;
  16 +import com.xly.erp.module.usr.vo.UserDetailVo;
  17 +import lombok.RequiredArgsConstructor;
  18 +import lombok.extern.slf4j.Slf4j;
  19 +import org.springframework.stereotype.Service;
  20 +import org.springframework.transaction.annotation.Transactional;
  21 +
  22 +import java.time.LocalDateTime;
  23 +import java.util.HashSet;
  24 +import java.util.LinkedHashSet;
  25 +import java.util.List;
  26 +import java.util.Set;
  27 +
  28 +@Service
  29 +@RequiredArgsConstructor
  30 +@Slf4j
  31 +public class UserUpdateServiceImpl implements UserUpdateService {
  32 +
  33 + private final SysUserMapper userMapper;
  34 + private final SysEmployeeMapper employeeMapper;
  35 + private final SysPermissionCategoryMapper permissionCategoryMapper;
  36 + private final SysUserPermissionCategoryMapper upcMapper;
  37 + private final UserDetailService userDetailService;
  38 +
  39 + @Override
  40 + @Transactional
  41 + public UserDetailVo update(Integer userId, UpdateUserReq req,
  42 + Integer operatorUserId, String operatorUsername) {
  43 + // 1. 存在性
  44 + SysUser existing = userMapper.selectById(userId);
  45 + if (existing == null) {
  46 + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在");
  47 + }
  48 +
  49 + // 2. 自我停用守卫
  50 + if (Boolean.TRUE.equals(req.getIsDeleted()) && userId.equals(operatorUserId)) {
  51 + throw new BizException(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE,
  52 + "不允许停用当前登录用户自己");
  53 + }
  54 +
  55 + // 3. userCode 唯一(排除自身)
  56 + if (req.getUserCode() != null
  57 + && !req.getUserCode().equals(existing.getSUserCode())
  58 + && userMapper.existsByUserCodeExcludingId(req.getUserCode(), userId)) {
  59 + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已被占用");
  60 + }
  61 +
  62 + // 4. employeeId 外键(仅正整数才查)
  63 + if (req.getEmployeeId() != null && req.getEmployeeId() > 0) {
  64 + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId());
  65 + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) {
  66 + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除");
  67 + }
  68 + }
  69 +
  70 + // 5. permissionCategoryIds 外键(dedup 后校验)
  71 + Set<Integer> targetPcSet = null;
  72 + if (req.getPermissionCategoryIds() != null) {
  73 + targetPcSet = new LinkedHashSet<>(req.getPermissionCategoryIds());
  74 + if (!targetPcSet.isEmpty()) {
  75 + int active = permissionCategoryMapper.countActiveByIds(
  76 + new java.util.ArrayList<>(targetPcSet));
  77 + if (active != targetPcSet.size()) {
  78 + throw new BizException(ErrorCode.COMPANY_NOT_FOUND,
  79 + "指定的权限分类含不存在或已删除项");
  80 + }
  81 + }
  82 + }
  83 +
  84 + // 6. 写 sys_user 字段
  85 + UpdateWrapper<SysUser> uw = new UpdateWrapper<>();
  86 + uw.eq("iIncrement", userId);
  87 +
  88 + if (req.getUserCode() != null) uw.set("sUserCode", req.getUserCode());
  89 + if (req.getUserType() != null) uw.set("sUserType", req.getUserType());
  90 + if (req.getLanguage() != null) uw.set("sLanguage", req.getLanguage());
  91 + if (req.getCanEditDocument() != null)
  92 + uw.set("iCanEditDocument", req.getCanEditDocument() ? 1 : 0);
  93 + if (req.getIsDeleted() != null)
  94 + uw.set("iIsDeleted", req.getIsDeleted() ? 1 : 0);
  95 + if (req.getEmployeeId() != null) {
  96 + if (req.getEmployeeId() == 0) {
  97 + uw.set("iEmployeeId", null); // 解除关联
  98 + } else {
  99 + uw.set("iEmployeeId", req.getEmployeeId());
  100 + }
  101 + }
  102 + uw.set("sUpdatedBy", operatorUsername);
  103 + uw.set("tUpdatedDate", LocalDateTime.now());
  104 +
  105 + userMapper.update(null, uw);
  106 +
  107 + // 7. 权限分类增量差集
  108 + if (targetPcSet != null) {
  109 + List<Integer> currentList = upcMapper.selectPermissionCategoryIdsByUserId(userId);
  110 + Set<Integer> currentSet = new HashSet<>(currentList);
  111 +
  112 + Set<Integer> toRemove = new HashSet<>(currentSet);
  113 + toRemove.removeAll(targetPcSet);
  114 +
  115 + Set<Integer> toAdd = new LinkedHashSet<>(targetPcSet);
  116 + toAdd.removeAll(currentSet);
  117 +
  118 + if (!toRemove.isEmpty()) {
  119 + upcMapper.deleteByUserAndCategoryIds(userId, new java.util.ArrayList<>(toRemove));
  120 + }
  121 + for (Integer pcId : toAdd) {
  122 + SysUserPermissionCategory link = new SysUserPermissionCategory();
  123 + link.setIUserId(userId);
  124 + link.setIPermissionCategoryId(pcId);
  125 + link.setSGrantedBy(operatorUsername);
  126 + upcMapper.insert(link);
  127 + }
  128 + log.info("[user-update] userId={} pc.toRemove={} pc.toAdd={}",
  129 + userId, toRemove.size(), toAdd.size());
  130 + }
  131 +
  132 + log.info("[user-update] userId={} byOperator={} 完成", userId, operatorUsername);
  133 + return userDetailService.getById(userId);
  134 + }
  135 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.Builder;
  4 +import lombok.Data;
  5 +
  6 +@Data
  7 +@Builder
  8 +public class CreateUserVo {
  9 + private Integer userId;
  10 + private String username;
  11 + private String userCode;
  12 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import lombok.Builder;
  4 +import lombok.Data;
  5 +
  6 +@Data
  7 +@Builder
  8 +public class LoginVo {
  9 + private String accessToken;
  10 + private String tokenType;
  11 + private long expiresInSec;
  12 + private UserInfoVo userInfo;
  13 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonInclude;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +
  7 +import java.time.LocalDateTime;
  8 +import java.util.List;
  9 +
  10 +@Data
  11 +@Builder
  12 +@JsonInclude(JsonInclude.Include.NON_NULL)
  13 +public class UserDetailVo {
  14 + private Integer userId;
  15 + private String username;
  16 + private String userCode;
  17 + private String userType;
  18 + private String language;
  19 + private Boolean canEditDocument;
  20 + private Boolean isDeleted;
  21 + private Integer employeeId;
  22 + private String employeeName;
  23 + private List<Integer> permissionCategoryIds;
  24 + private String updatedBy;
  25 + private LocalDateTime updatedDate;
  26 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonInclude;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +
  7 +@Data
  8 +@Builder
  9 +@JsonInclude(JsonInclude.Include.NON_NULL)
  10 +public class UserInfoVo {
  11 + private Integer userId;
  12 + private String username;
  13 + private String userType;
  14 + private String language;
  15 + private String employeeName;
  16 + private String companyCode;
  17 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVo.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonInclude;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +import lombok.AllArgsConstructor;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +@Data
  12 +@Builder
  13 +@NoArgsConstructor
  14 +@AllArgsConstructor
  15 +@JsonInclude(JsonInclude.Include.ALWAYS)
  16 +public class UserListItemVo {
  17 + private Integer userId;
  18 + private String username;
  19 + private String employeeName;
  20 + private String userCode;
  21 + private String departmentName;
  22 + private String userType;
  23 + private String language;
  24 + private Boolean isDeleted;
  25 + private LocalDateTime lastLoginDate;
  26 + private String createdBy;
  27 + private LocalDateTime createdDate;
  28 +}
... ...
backend/src/main/resources/application-test.yml 0 → 100644
  1 +spring:
  2 + jackson:
  3 + deserialization:
  4 + fail-on-unknown-properties: true
  5 + datasource:
  6 + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
  7 + username: ${DB_USER}
  8 + password: ${DB_PASSWORD}
  9 + flyway:
  10 + enabled: true
  11 + locations: filesystem:../sql/migrations
  12 +
  13 +jwt:
  14 + secret: ${JWT_SECRET:test-secret-please-replace-with-256bit-random-string-xxxxxxx}
  15 + ttl-sec: 7200
  16 +
  17 +logging:
  18 + level:
  19 + root: WARN
  20 + com.xly.erp: DEBUG
  21 + com.zaxxer.hikari: WARN
... ...
backend/src/main/resources/application.yml 0 → 100644
  1 +server:
  2 + port: 9090
  3 +
  4 +spring:
  5 + application:
  6 + name: xly-erp-backend
  7 + jackson:
  8 + deserialization:
  9 + fail-on-unknown-properties: true
  10 + datasource:
  11 + driver-class-name: com.mysql.cj.jdbc.Driver
  12 + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
  13 + username: ${DB_USER}
  14 + password: ${DB_PASSWORD}
  15 + flyway:
  16 + enabled: true
  17 + locations: filesystem:../sql/migrations
  18 + baseline-on-migrate: true
  19 + baseline-version: 0
  20 + validate-on-migrate: true
  21 +
  22 +mybatis-plus:
  23 + mapper-locations: classpath*:/mapper/**/*.xml
  24 + configuration:
  25 + map-underscore-to-camel-case: false
  26 + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
  27 + global-config:
  28 + db-config:
  29 + id-type: auto
  30 +
  31 +jwt:
  32 + secret: ${JWT_SECRET}
  33 + ttl-sec: 7200
  34 +
  35 +logging:
  36 + level:
  37 + root: INFO
  38 + com.xly.erp: DEBUG
... ...
backend/src/main/resources/logback-spring.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<configuration>
  3 + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  4 + <encoder>
  5 + <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
  6 + </encoder>
  7 + </appender>
  8 +
  9 + <root level="INFO">
  10 + <appender-ref ref="CONSOLE"/>
  11 + </root>
  12 +
  13 + <logger name="com.xly.erp" level="DEBUG"/>
  14 +</configuration>
... ...
backend/src/main/resources/mapper/usr/SysUserMapper.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  3 +<mapper namespace="com.xly.erp.module.usr.mapper.SysUserMapper">
  4 +
  5 + <sql id="baseFrom">
  6 + FROM sys_user u
  7 + LEFT JOIN sys_employee e ON e.iIncrement = u.iEmployeeId
  8 + LEFT JOIN sys_department d ON d.iIncrement = e.iDepartmentId
  9 + </sql>
  10 +
  11 + <sql id="whereClause">
  12 + <where>
  13 + <if test="p.sqlQueryColumn != null and p.queryValue != null and p.queryValue != ''">
  14 + <choose>
  15 + <when test="'contains'.equals(p.matchMode)">
  16 + AND ${p.sqlQueryColumn} LIKE CONCAT('%', #{p.queryValue}, '%')
  17 + </when>
  18 + <when test="'notContains'.equals(p.matchMode)">
  19 + AND (${p.sqlQueryColumn} NOT LIKE CONCAT('%', #{p.queryValue}, '%')
  20 + OR ${p.sqlQueryColumn} IS NULL)
  21 + </when>
  22 + <otherwise>
  23 + AND ${p.sqlQueryColumn} = #{p.queryValue}
  24 + </otherwise>
  25 + </choose>
  26 + </if>
  27 + <if test="p.userType != null">
  28 + AND u.sUserType = #{p.userType}
  29 + </if>
  30 + <if test="p.isDeleted != null">
  31 + AND u.iIsDeleted = #{p.isDeleted}
  32 + </if>
  33 + </where>
  34 + </sql>
  35 +
  36 + <resultMap id="UserListItemVoMap" type="com.xly.erp.module.usr.vo.UserListItemVo">
  37 + <id property="userId" column="userId"/>
  38 + <result property="username" column="username"/>
  39 + <result property="employeeName" column="employeeName"/>
  40 + <result property="userCode" column="userCode"/>
  41 + <result property="departmentName" column="departmentName"/>
  42 + <result property="userType" column="userType"/>
  43 + <result property="language" column="language"/>
  44 + <result property="isDeleted" column="isDeleted" javaType="java.lang.Boolean"/>
  45 + <result property="lastLoginDate" column="lastLoginDate"/>
  46 + <result property="createdBy" column="createdBy"/>
  47 + <result property="createdDate" column="createdDate"/>
  48 + </resultMap>
  49 +
  50 + <select id="selectByQuery" resultMap="UserListItemVoMap">
  51 + SELECT u.iIncrement AS userId,
  52 + u.sUsername AS username,
  53 + e.sEmployeeName AS employeeName,
  54 + u.sUserCode AS userCode,
  55 + d.sDepartmentName AS departmentName,
  56 + u.sUserType AS userType,
  57 + u.sLanguage AS language,
  58 + u.iIsDeleted AS isDeleted,
  59 + u.tLastLoginDate AS lastLoginDate,
  60 + u.sCreatedBy AS createdBy,
  61 + u.tCreateDate AS createdDate
  62 + <include refid="baseFrom"/>
  63 + <include refid="whereClause"/>
  64 + ORDER BY u.${p.sqlSortField} ${p.sqlSortOrder}
  65 + LIMIT #{p.offset}, #{p.limit}
  66 + </select>
  67 +
  68 + <select id="countByQuery" resultType="long">
  69 + SELECT COUNT(*)
  70 + <include refid="baseFrom"/>
  71 + <include refid="whereClause"/>
  72 + </select>
  73 +
  74 +</mapper>
... ...
backend/src/test/java/com/xly/erp/ApplicationContextTest.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.context.ApplicationContext;
  6 +import org.springframework.beans.factory.annotation.Autowired;
  7 +
  8 +import static org.junit.jupiter.api.Assertions.assertNotNull;
  9 +
  10 +/**
  11 + * REQ-USR-001 — Boot smoke test:
  12 + * verifies Spring Boot context starts and Flyway has applied V1 against the
  13 + * MySQL schema configured via .env.local (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_SCHEMA).
  14 + */
  15 +@SpringBootTest
  16 +@org.springframework.test.context.ActiveProfiles("test")
  17 +class ApplicationContextTest {
  18 +
  19 + @Autowired
  20 + private ApplicationContext ctx;
  21 +
  22 + @Test
  23 + void contextLoads() {
  24 + assertNotNull(ctx, "Spring ApplicationContext should be initialised");
  25 + }
  26 +}
... ...
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import org.junit.jupiter.api.Test;
  5 +import org.springframework.beans.factory.annotation.Autowired;
  6 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  7 +import org.springframework.boot.test.context.SpringBootTest;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +import org.springframework.test.web.servlet.MockMvc;
  10 +import org.springframework.web.bind.annotation.GetMapping;
  11 +import org.springframework.web.bind.annotation.RestController;
  12 +
  13 +import static org.hamcrest.Matchers.containsString;
  14 +import static org.hamcrest.Matchers.not;
  15 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  16 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  17 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  18 +
  19 +@SpringBootTest
  20 +@AutoConfigureMockMvc
  21 +@ActiveProfiles("test")
  22 +@org.springframework.context.annotation.Import(GlobalExceptionHandlerTest.ThrowingTestController.class)
  23 +class GlobalExceptionHandlerTest {
  24 +
  25 + @Autowired
  26 + private MockMvc mockMvc;
  27 +
  28 + @Test
  29 + void bizException_locked_returns423_withCode42301() throws Exception {
  30 + mockMvc.perform(get("/_test/throw/locked"))
  31 + .andExpect(status().isLocked())
  32 + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED))
  33 + .andExpect(jsonPath("$.data").doesNotExist());
  34 + }
  35 +
  36 + @Test
  37 + void bizException_badCredentials_returns401_withCode40101() throws Exception {
  38 + mockMvc.perform(get("/_test/throw/bad-credentials"))
  39 + .andExpect(status().isUnauthorized())
  40 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  41 + }
  42 +
  43 + @Test
  44 + void unexpectedException_returns500_doesNotLeakStackTrace() throws Exception {
  45 + mockMvc.perform(get("/_test/throw/runtime"))
  46 + .andExpect(status().isInternalServerError())
  47 + .andExpect(jsonPath("$.code").value(ErrorCode.INTERNAL_ERROR))
  48 + .andExpect(jsonPath("$.message").value(not(containsString("java."))))
  49 + .andExpect(jsonPath("$.message").value(not(containsString("Exception"))));
  50 + }
  51 +
  52 + @RestController
  53 + static class ThrowingTestController {
  54 + @GetMapping("/_test/throw/locked")
  55 + public void locked() {
  56 + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试");
  57 + }
  58 +
  59 + @GetMapping("/_test/throw/bad-credentials")
  60 + public void badCredentials() {
  61 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误");
  62 + }
  63 +
  64 + @GetMapping("/_test/throw/runtime")
  65 + public void runtime() {
  66 + throw new RuntimeException("internal boom java.lang.NullPointerException");
  67 + }
  68 + }
  69 +}
... ...
backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +
  5 +import static org.junit.jupiter.api.Assertions.assertEquals;
  6 +
  7 +class ErrorCodeTest {
  8 +
  9 + @Test
  10 + void httpMappings_coverNewCodes() {
  11 + assertEquals(403, ErrorCode.toHttpStatus(ErrorCode.FORBIDDEN));
  12 + assertEquals(409, ErrorCode.toHttpStatus(ErrorCode.CONFLICT_USERNAME));
  13 + assertEquals(409, ErrorCode.toHttpStatus(ErrorCode.CONFLICT_USERCODE));
  14 + assertEquals(40301, ErrorCode.FORBIDDEN);
  15 + assertEquals(40901, ErrorCode.CONFLICT_USERNAME);
  16 + assertEquals(40902, ErrorCode.CONFLICT_USERCODE);
  17 + }
  18 +
  19 + @Test
  20 + void httpMappings_coverNewCodes_v004() {
  21 + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.INVALID_ENUM_PARAM));
  22 + assertEquals(40003, ErrorCode.INVALID_ENUM_PARAM);
  23 + }
  24 +
  25 + @Test
  26 + void httpMappings_coverNewCodes_v003() {
  27 + assertEquals(403, ErrorCode.toHttpStatus(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE));
  28 + assertEquals(404, ErrorCode.toHttpStatus(ErrorCode.USER_NOT_FOUND));
  29 + assertEquals(40302, ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE);
  30 + assertEquals(40401, ErrorCode.USER_NOT_FOUND);
  31 + }
  32 +
  33 + @Test
  34 + void httpMappings_existingCodes_unchanged() {
  35 + assertEquals(200, ErrorCode.toHttpStatus(ErrorCode.OK));
  36 + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.BAD_REQUEST));
  37 + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.COMPANY_NOT_FOUND));
  38 + assertEquals(401, ErrorCode.toHttpStatus(ErrorCode.BAD_CREDENTIALS));
  39 + assertEquals(401, ErrorCode.toHttpStatus(ErrorCode.ACCOUNT_DELETED));
  40 + assertEquals(423, ErrorCode.toHttpStatus(ErrorCode.ACCOUNT_LOCKED));
  41 + assertEquals(500, ErrorCode.toHttpStatus(ErrorCode.INTERNAL_ERROR));
  42 + }
  43 +}
... ...
backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.beans.factory.annotation.Autowired;
  8 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  9 +import org.springframework.boot.test.context.SpringBootTest;
  10 +import org.springframework.test.context.ActiveProfiles;
  11 +import org.springframework.test.web.servlet.MockMvc;
  12 +import org.springframework.web.bind.annotation.GetMapping;
  13 +import org.springframework.web.bind.annotation.RequestMapping;
  14 +import org.springframework.web.bind.annotation.RestController;
  15 +
  16 +import java.util.HashMap;
  17 +import java.util.Map;
  18 +
  19 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  20 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  21 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  22 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  23 +
  24 +@SpringBootTest
  25 +@AutoConfigureMockMvc
  26 +@ActiveProfiles("test")
  27 +@org.springframework.context.annotation.Import(JwtHandlerInterceptorTest.GuardedTestController.class)
  28 +class JwtHandlerInterceptorTest {
  29 +
  30 + @Autowired private MockMvc mvc;
  31 + @Autowired private JwtUtil jwtUtil;
  32 + @Autowired private LoginTestSeeder seeder;
  33 + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc;
  34 +
  35 + private LoginTestSeeder.Fixture fx;
  36 +
  37 + @BeforeEach
  38 + void setUp() {
  39 + fx = seeder.reset();
  40 + }
  41 +
  42 + private String issueToken(String username, String userType, String companyCode) {
  43 + Map<String, Object> claims = new HashMap<>();
  44 + claims.put("sub", 1);
  45 + claims.put("username", username);
  46 + claims.put("userType", userType);
  47 + claims.put("companyCode", companyCode);
  48 + claims.put("language", "zh-CN");
  49 + return jwtUtil.issue(claims, 7200);
  50 + }
  51 +
  52 + // ===== 鉴权基础 =====
  53 +
  54 + @Test
  55 + void noAuthHeader_returns401_40101() throws Exception {
  56 + mvc.perform(get("/api/v1/_test/any-auth"))
  57 + .andExpect(status().isUnauthorized())
  58 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  59 + }
  60 +
  61 + @Test
  62 + void invalidToken_returns401_40101() throws Exception {
  63 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer bogus.jwt.token"))
  64 + .andExpect(status().isUnauthorized())
  65 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  66 + }
  67 +
  68 + @Test
  69 + void tokenForUnknownUser_returns401_40101() throws Exception {
  70 + String token = issueToken("nobody", "NORMAL", "HQ");
  71 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  72 + .andExpect(status().isUnauthorized())
  73 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  74 + }
  75 +
  76 + @Test
  77 + void tokenForDeletedUser_returns401_40101() throws Exception {
  78 + String token = issueToken(LoginTestSeeder.USER_DELETED, "NORMAL", "HQ");
  79 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  80 + .andExpect(status().isUnauthorized())
  81 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  82 + }
  83 +
  84 + @Test
  85 + void tokenForLockedUser_returns401_40101() throws Exception {
  86 + jdbc.update("UPDATE sys_user SET tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?",
  87 + LoginTestSeeder.USER_OK);
  88 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  89 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  90 + .andExpect(status().isUnauthorized())
  91 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  92 + }
  93 +
  94 + @Test
  95 + void validToken_normalUser_canAccessAnyAuthEndpoint() throws Exception {
  96 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  97 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  98 + .andExpect(status().isOk())
  99 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  100 + .andExpect(jsonPath("$.data").value("ok-" + LoginTestSeeder.USER_OK));
  101 + }
  102 +
  103 + // ===== @RequireSuperAdmin =====
  104 +
  105 + @Test
  106 + void validToken_normalUser_cannotAccessAdminOnly_returns403_40301() throws Exception {
  107 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  108 + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token))
  109 + .andExpect(status().isForbidden())
  110 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  111 + }
  112 +
  113 + @Test
  114 + void validToken_superAdmin_canAccessAdminOnly() throws Exception {
  115 + // 把 alice 升级为 SUPER_ADMIN
  116 + jdbc.update("UPDATE sys_user SET sUserType='SUPER_ADMIN' WHERE sUsername=?", LoginTestSeeder.USER_OK);
  117 + String token = issueToken(LoginTestSeeder.USER_OK, "SUPER_ADMIN", "HQ");
  118 + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token))
  119 + .andExpect(status().isOk());
  120 + }
  121 +
  122 + // ===== 放行 /api/v1/auth/login =====
  123 +
  124 + @Test
  125 + void loginEndpointPath_skipsInterceptor() throws Exception {
  126 + // 不带 auth 头调登录接口;应进入 controller 并返参数校验错(不是 40101)
  127 + mvc.perform(post("/api/v1/auth/login").contentType("application/json").content("{}"))
  128 + .andExpect(status().isBadRequest())
  129 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  130 + }
  131 +
  132 + @RestController
  133 + @RequestMapping("/api/v1/_test")
  134 + static class GuardedTestController {
  135 +
  136 + @GetMapping("/any-auth")
  137 + public com.xly.erp.common.response.Result<String> anyAuth() {
  138 + LoginContext.LoginUser u = LoginContext.current();
  139 + return com.xly.erp.common.response.Result.ok("ok-" + (u == null ? "null" : u.username()));
  140 + }
  141 +
  142 + @GetMapping("/admin-only")
  143 + @RequireSuperAdmin
  144 + public com.xly.erp.common.response.Result<String> adminOnly() {
  145 + return com.xly.erp.common.response.Result.ok("admin");
  146 + }
  147 + }
  148 +}
... ...
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.springframework.beans.factory.annotation.Autowired;
  7 +import org.springframework.boot.test.context.SpringBootTest;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +
  10 +import java.util.HashMap;
  11 +import java.util.Map;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.*;
  14 +
  15 +@SpringBootTest
  16 +@ActiveProfiles("test")
  17 +class JwtUtilTest {
  18 +
  19 + @Autowired
  20 + private JwtUtil jwtUtil;
  21 +
  22 + private Map<String, Object> sampleClaims() {
  23 + Map<String, Object> claims = new HashMap<>();
  24 + claims.put("sub", "42");
  25 + claims.put("username", "alice");
  26 + claims.put("userType", "NORMAL");
  27 + claims.put("companyCode", "HQ");
  28 + claims.put("language", "zh-CN");
  29 + return claims;
  30 + }
  31 +
  32 + @Test
  33 + void issuedToken_canBeParsedBackToClaims() {
  34 + String token = jwtUtil.issue(sampleClaims(), 7200);
  35 + assertNotNull(token);
  36 + assertFalse(token.isEmpty());
  37 +
  38 + Map<String, Object> parsed = jwtUtil.parse(token);
  39 + assertEquals("42", parsed.get("sub"));
  40 + assertEquals("alice", parsed.get("username"));
  41 + assertEquals("NORMAL", parsed.get("userType"));
  42 + assertEquals("HQ", parsed.get("companyCode"));
  43 + assertEquals("zh-CN", parsed.get("language"));
  44 + assertNotNull(parsed.get("jti"));
  45 + assertNotNull(parsed.get("iat"));
  46 + assertNotNull(parsed.get("exp"));
  47 +
  48 + long iat = ((Number) parsed.get("iat")).longValue();
  49 + long exp = ((Number) parsed.get("exp")).longValue();
  50 + assertEquals(7200L, exp - iat, "exp - iat 必须严格等于 ttlSec(spec § 验收 § 2)");
  51 + }
  52 +
  53 + @Test
  54 + void tamperedToken_throwsBizException() {
  55 + String token = jwtUtil.issue(sampleClaims(), 7200);
  56 + String tampered = token.substring(0, token.length() - 4) + "XXXX";
  57 + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(tampered));
  58 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  59 + }
  60 +
  61 + @Test
  62 + void expiredToken_throwsBizException() {
  63 + String token = jwtUtil.issue(sampleClaims(), 0L);
  64 + try { Thread.sleep(1100); } catch (InterruptedException ignored) {}
  65 + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(token));
  66 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  67 + }
  68 +}
... ...
backend/src/test/java/com/xly/erp/common/security/LoginContextTest.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import org.junit.jupiter.api.AfterEach;
  4 +import org.junit.jupiter.api.Test;
  5 +
  6 +import java.util.concurrent.CountDownLatch;
  7 +import java.util.concurrent.atomic.AtomicReference;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.*;
  10 +
  11 +class LoginContextTest {
  12 +
  13 + @AfterEach
  14 + void tearDown() {
  15 + LoginContext.clear();
  16 + }
  17 +
  18 + @Test
  19 + void setAndCurrent_returnsSameUser() {
  20 + LoginContext.LoginUser u = new LoginContext.LoginUser(42, "alice", "NORMAL", "HQ");
  21 + LoginContext.set(u);
  22 + assertSame(u, LoginContext.current());
  23 + }
  24 +
  25 + @Test
  26 + void clear_returnsNullForCurrent() {
  27 + LoginContext.set(new LoginContext.LoginUser(1, "x", "NORMAL", "HQ"));
  28 + LoginContext.clear();
  29 + assertNull(LoginContext.current());
  30 + }
  31 +
  32 + @Test
  33 + void setAndCurrent_isolatedPerThread() throws InterruptedException {
  34 + LoginContext.set(new LoginContext.LoginUser(1, "main", "NORMAL", "HQ"));
  35 +
  36 + AtomicReference<LoginContext.LoginUser> seen = new AtomicReference<>();
  37 + CountDownLatch latch = new CountDownLatch(1);
  38 + Thread t = new Thread(() -> {
  39 + seen.set(LoginContext.current());
  40 + LoginContext.set(new LoginContext.LoginUser(2, "child", "SUPER_ADMIN", "HQ"));
  41 + latch.countDown();
  42 + });
  43 + t.start();
  44 + latch.await();
  45 + t.join();
  46 +
  47 + assertNull(seen.get(), "子线程不应继承父线程 ThreadLocal");
  48 + assertEquals("main", LoginContext.current().username(),
  49 + "父线程上下文不应被子线程改动影响");
  50 + }
  51 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.dto.LoginReq;
  6 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  7 +import org.junit.jupiter.api.BeforeEach;
  8 +import org.junit.jupiter.api.Test;
  9 +import org.springframework.beans.factory.annotation.Autowired;
  10 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  11 +import org.springframework.boot.test.context.SpringBootTest;
  12 +import org.springframework.http.MediaType;
  13 +import org.springframework.jdbc.core.JdbcTemplate;
  14 +import org.springframework.test.context.ActiveProfiles;
  15 +import org.springframework.test.web.servlet.MockMvc;
  16 +
  17 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  18 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  19 +
  20 +@SpringBootTest
  21 +@AutoConfigureMockMvc
  22 +@ActiveProfiles("test")
  23 +class AuthControllerTest {
  24 +
  25 + @Autowired private MockMvc mvc;
  26 + @Autowired private ObjectMapper json;
  27 + @Autowired private LoginTestSeeder seeder;
  28 + @Autowired private JdbcTemplate jdbc;
  29 +
  30 + @BeforeEach
  31 + void setUp() {
  32 + seeder.reset();
  33 + }
  34 +
  35 + private String body(String username, String password, String companyCode) throws Exception {
  36 + LoginReq r = new LoginReq();
  37 + r.setUsername(username);
  38 + r.setPassword(password);
  39 + r.setCompanyCode(companyCode);
  40 + return json.writeValueAsString(r);
  41 + }
  42 +
  43 + @Test
  44 + void post_login_success_returns200_andLoginVo() throws Exception {
  45 + mvc.perform(post("/api/v1/auth/login")
  46 + .contentType(MediaType.APPLICATION_JSON)
  47 + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  48 + LoginTestSeeder.COMPANY_OK)))
  49 + .andExpect(status().isOk())
  50 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  51 + .andExpect(jsonPath("$.data.accessToken").isNotEmpty())
  52 + .andExpect(jsonPath("$.data.tokenType").value("Bearer"))
  53 + .andExpect(jsonPath("$.data.expiresInSec").value(7200))
  54 + .andExpect(jsonPath("$.data.userInfo.username").value(LoginTestSeeder.USER_OK))
  55 + .andExpect(jsonPath("$.data.userInfo.companyCode").value(LoginTestSeeder.COMPANY_OK));
  56 + }
  57 +
  58 + @Test
  59 + void post_login_badCredentials_returns401_code40101() throws Exception {
  60 + mvc.perform(post("/api/v1/auth/login")
  61 + .contentType(MediaType.APPLICATION_JSON)
  62 + .content(body(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)))
  63 + .andExpect(status().isUnauthorized())
  64 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  65 + }
  66 +
  67 + @Test
  68 + void post_login_unknownUser_returns401_code40101() throws Exception {
  69 + mvc.perform(post("/api/v1/auth/login")
  70 + .contentType(MediaType.APPLICATION_JSON)
  71 + .content(body("nobody", "any", LoginTestSeeder.COMPANY_OK)))
  72 + .andExpect(status().isUnauthorized())
  73 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  74 + }
  75 +
  76 + @Test
  77 + void post_login_lockedAccount_returns423_code42301_withLockUntil() throws Exception {
  78 + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?",
  79 + LoginTestSeeder.USER_OK);
  80 + mvc.perform(post("/api/v1/auth/login")
  81 + .contentType(MediaType.APPLICATION_JSON)
  82 + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  83 + LoginTestSeeder.COMPANY_OK)))
  84 + .andExpect(status().isLocked())
  85 + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED))
  86 + .andExpect(jsonPath("$.data.lockUntil").isNotEmpty());
  87 + }
  88 +
  89 + @Test
  90 + void post_login_deletedAccount_returns401_code40103() throws Exception {
  91 + mvc.perform(post("/api/v1/auth/login")
  92 + .contentType(MediaType.APPLICATION_JSON)
  93 + .content(body(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD,
  94 + LoginTestSeeder.COMPANY_OK)))
  95 + .andExpect(status().isUnauthorized())
  96 + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_DELETED));
  97 + }
  98 +
  99 + @Test
  100 + void post_login_unknownCompany_returns400_code40004() throws Exception {
  101 + mvc.perform(post("/api/v1/auth/login")
  102 + .contentType(MediaType.APPLICATION_JSON)
  103 + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE")))
  104 + .andExpect(status().isBadRequest())
  105 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  106 + }
  107 +
  108 + @Test
  109 + void post_login_blankUsername_returns400_code40001() throws Exception {
  110 + mvc.perform(post("/api/v1/auth/login")
  111 + .contentType(MediaType.APPLICATION_JSON)
  112 + .content(body("", LoginTestSeeder.DEFAULT_PASSWORD, LoginTestSeeder.COMPANY_OK)))
  113 + .andExpect(status().isBadRequest())
  114 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  115 + }
  116 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import com.xly.erp.common.security.JwtUtil;
  5 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  6 +import org.junit.jupiter.api.BeforeEach;
  7 +import org.junit.jupiter.api.Test;
  8 +import org.springframework.beans.factory.annotation.Autowired;
  9 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  10 +import org.springframework.boot.test.context.SpringBootTest;
  11 +import org.springframework.jdbc.core.JdbcTemplate;
  12 +import org.springframework.test.context.ActiveProfiles;
  13 +import org.springframework.test.web.servlet.MockMvc;
  14 +
  15 +import java.util.HashMap;
  16 +import java.util.Map;
  17 +
  18 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  19 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  20 +
  21 +@SpringBootTest
  22 +@AutoConfigureMockMvc
  23 +@ActiveProfiles("test")
  24 +class UserControllerListTest {
  25 +
  26 + @Autowired private MockMvc mvc;
  27 + @Autowired private LoginTestSeeder seeder;
  28 + @Autowired private JwtUtil jwtUtil;
  29 + @Autowired private JdbcTemplate jdbc;
  30 +
  31 + private LoginTestSeeder.Fixture fx;
  32 + private String adminToken;
  33 + private String normalToken;
  34 +
  35 + @BeforeEach
  36 + void setUp() {
  37 + fx = seeder.reset();
  38 + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId());
  39 + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId());
  40 + }
  41 +
  42 + private String issue(String username, String userType, Integer userId) {
  43 + Map<String, Object> c = new HashMap<>();
  44 + c.put("sub", userId);
  45 + c.put("username", username);
  46 + c.put("userType", userType);
  47 + c.put("companyCode", LoginTestSeeder.COMPANY_OK);
  48 + c.put("language", "zh-CN");
  49 + return jwtUtil.issue(c, 7200);
  50 + }
  51 +
  52 + @Test
  53 + void list_default_returnsAllUsers() throws Exception {
  54 + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken))
  55 + .andExpect(status().isOk())
  56 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  57 + .andExpect(jsonPath("$.data.total").value(3))
  58 + .andExpect(jsonPath("$.data.records.length()").value(3))
  59 + .andExpect(jsonPath("$.data.page").value(1))
  60 + .andExpect(jsonPath("$.data.size").value(20));
  61 + }
  62 +
  63 + @Test
  64 + void list_sizeOver100_returns400_40001() throws Exception {
  65 + mvc.perform(get("/api/v1/users")
  66 + .param("size", "200")
  67 + .header("Authorization", "Bearer " + adminToken))
  68 + .andExpect(status().isBadRequest())
  69 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  70 + }
  71 +
  72 + @Test
  73 + void list_pageZero_returns400_40001() throws Exception {
  74 + mvc.perform(get("/api/v1/users")
  75 + .param("page", "0")
  76 + .header("Authorization", "Bearer " + adminToken))
  77 + .andExpect(status().isBadRequest())
  78 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  79 + }
  80 +
  81 + @Test
  82 + void list_sortByUsernameAsc() throws Exception {
  83 + mvc.perform(get("/api/v1/users")
  84 + .param("sortField", "sUsername")
  85 + .param("sortOrder", "asc")
  86 + .header("Authorization", "Bearer " + adminToken))
  87 + .andExpect(status().isOk())
  88 + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_ADMIN));
  89 + }
  90 +
  91 + @Test
  92 + void list_sortFieldInvalid_returns400_40003() throws Exception {
  93 + mvc.perform(get("/api/v1/users")
  94 + .param("sortField", "badField")
  95 + .header("Authorization", "Bearer " + adminToken))
  96 + .andExpect(status().isBadRequest())
  97 + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM));
  98 + }
  99 +
  100 + @Test
  101 + void list_sortOrderInvalid_returns400_40001() throws Exception {
  102 + mvc.perform(get("/api/v1/users")
  103 + .param("sortOrder", "foo")
  104 + .header("Authorization", "Bearer " + adminToken))
  105 + .andExpect(status().isBadRequest())
  106 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  107 + }
  108 +
  109 + @Test
  110 + void list_queryByUsernameContains() throws Exception {
  111 + mvc.perform(get("/api/v1/users")
  112 + .param("queryField", "username")
  113 + .param("matchMode", "contains")
  114 + .param("queryValue", "ali")
  115 + .header("Authorization", "Bearer " + adminToken))
  116 + .andExpect(status().isOk())
  117 + .andExpect(jsonPath("$.data.total").value(1))
  118 + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK));
  119 + }
  120 +
  121 + @Test
  122 + void list_queryByDepartmentName_multiJoin() throws Exception {
  123 + mvc.perform(get("/api/v1/users")
  124 + .param("queryField", "departmentName")
  125 + .param("matchMode", "equals")
  126 + .param("queryValue", "技术部")
  127 + .header("Authorization", "Bearer " + adminToken))
  128 + .andExpect(status().isOk())
  129 + .andExpect(jsonPath("$.data.total").value(1))
  130 + .andExpect(jsonPath("$.data.records[0].departmentName").value("技术部"));
  131 + }
  132 +
  133 + @Test
  134 + void list_queryByIsDeletedTrue() throws Exception {
  135 + mvc.perform(get("/api/v1/users")
  136 + .param("queryField", "isDeleted")
  137 + .param("matchMode", "equals")
  138 + .param("queryValue", "true")
  139 + .header("Authorization", "Bearer " + adminToken))
  140 + .andExpect(status().isOk())
  141 + .andExpect(jsonPath("$.data.total").value(1))
  142 + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_DELETED));
  143 + }
  144 +
  145 + @Test
  146 + void list_queryFieldInvalid_returns400_40003() throws Exception {
  147 + mvc.perform(get("/api/v1/users")
  148 + .param("queryField", "badField")
  149 + .param("queryValue", "x")
  150 + .header("Authorization", "Bearer " + adminToken))
  151 + .andExpect(status().isBadRequest())
  152 + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM));
  153 + }
  154 +
  155 + @Test
  156 + void list_matchModeInvalid_returns400_40003() throws Exception {
  157 + mvc.perform(get("/api/v1/users")
  158 + .param("matchMode", "startsWith")
  159 + .header("Authorization", "Bearer " + adminToken))
  160 + .andExpect(status().isBadRequest())
  161 + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM));
  162 + }
  163 +
  164 + @Test
  165 + void list_explicitUserTypeFilter() throws Exception {
  166 + mvc.perform(get("/api/v1/users")
  167 + .param("userType", "NORMAL")
  168 + .header("Authorization", "Bearer " + adminToken))
  169 + .andExpect(status().isOk())
  170 + .andExpect(jsonPath("$.data.total").value(2)); // alice + bob_deleted
  171 + }
  172 +
  173 + @Test
  174 + void list_explicitUserTypeInvalid_returns400_40001() throws Exception {
  175 + mvc.perform(get("/api/v1/users")
  176 + .param("userType", "HACKER")
  177 + .header("Authorization", "Bearer " + adminToken))
  178 + .andExpect(status().isBadRequest())
  179 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  180 + }
  181 +
  182 + @Test
  183 + void list_explicitIsDeletedFalse_filtersActive() throws Exception {
  184 + mvc.perform(get("/api/v1/users")
  185 + .param("isDeleted", "false")
  186 + .header("Authorization", "Bearer " + adminToken))
  187 + .andExpect(status().isOk())
  188 + .andExpect(jsonPath("$.data.total").value(2)); // alice + admin
  189 + }
  190 +
  191 + @Test
  192 + void list_composedFilters_andSemantics() throws Exception {
  193 + mvc.perform(get("/api/v1/users")
  194 + .param("queryField", "username")
  195 + .param("queryValue", "a")
  196 + .param("userType", "NORMAL")
  197 + .param("isDeleted", "false")
  198 + .header("Authorization", "Bearer " + adminToken))
  199 + .andExpect(status().isOk())
  200 + .andExpect(jsonPath("$.data.total").value(1))
  201 + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK));
  202 + }
  203 +
  204 + @Test
  205 + void list_pageBeyondTotal_returnsLastPage() throws Exception {
  206 + mvc.perform(get("/api/v1/users")
  207 + .param("page", "999")
  208 + .param("size", "10")
  209 + .header("Authorization", "Bearer " + adminToken))
  210 + .andExpect(status().isOk())
  211 + .andExpect(jsonPath("$.data.total").value(3))
  212 + .andExpect(jsonPath("$.data.page").value(1))
  213 + .andExpect(jsonPath("$.data.records.length()").value(3));
  214 + }
  215 +
  216 + @Test
  217 + void list_normalUserToken_returns403_40301() throws Exception {
  218 + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + normalToken))
  219 + .andExpect(status().isForbidden())
  220 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  221 + }
  222 +
  223 + @Test
  224 + void list_noAuthHeader_returns401_40101() throws Exception {
  225 + mvc.perform(get("/api/v1/users"))
  226 + .andExpect(status().isUnauthorized())
  227 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  228 + }
  229 +
  230 + @Test
  231 + void list_responseRecordDoesNotIncludePasswordField() throws Exception {
  232 + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken))
  233 + .andExpect(status().isOk())
  234 + .andExpect(jsonPath("$.data.records[0].password").doesNotExist())
  235 + .andExpect(jsonPath("$.data.records[0].passwordHash").doesNotExist())
  236 + .andExpect(jsonPath("$.data.records[0].sPasswordHash").doesNotExist());
  237 + }
  238 +
  239 + @Test
  240 + void list_emptyTable_returnsZeroTotal() throws Exception {
  241 + jdbc.update("DELETE FROM sys_user_permission_category");
  242 + jdbc.update("DELETE FROM sys_user");
  243 + // 但鉴权需要 token 关联用户存在,所以保留 admin 用户即可
  244 + // 重做:只删 alice / bob
  245 + // 重置后用一个独立 seed
  246 + seeder.reset();
  247 + jdbc.update("DELETE FROM sys_user WHERE sUsername IN (?, ?)",
  248 + LoginTestSeeder.USER_OK, LoginTestSeeder.USER_DELETED);
  249 +
  250 + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken))
  251 + .andExpect(status().isOk())
  252 + .andExpect(jsonPath("$.data.total").value(1)); // 只剩 admin
  253 + }
  254 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.node.ObjectNode;
  5 +import com.xly.erp.common.response.ErrorCode;
  6 +import com.xly.erp.common.security.JwtUtil;
  7 +import com.xly.erp.module.usr.dto.CreateUserReq;
  8 +import com.xly.erp.module.usr.dto.LoginReq;
  9 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.beans.factory.annotation.Autowired;
  13 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  14 +import org.springframework.boot.test.context.SpringBootTest;
  15 +import org.springframework.http.MediaType;
  16 +import org.springframework.test.context.ActiveProfiles;
  17 +import org.springframework.test.web.servlet.MockMvc;
  18 +
  19 +import java.util.HashMap;
  20 +import java.util.List;
  21 +import java.util.Map;
  22 +
  23 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  24 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  25 +
  26 +@SpringBootTest
  27 +@AutoConfigureMockMvc
  28 +@ActiveProfiles("test")
  29 +class UserControllerTest {
  30 +
  31 + @Autowired private MockMvc mvc;
  32 + @Autowired private ObjectMapper json;
  33 + @Autowired private LoginTestSeeder seeder;
  34 + @Autowired private JwtUtil jwtUtil;
  35 +
  36 + private LoginTestSeeder.Fixture fx;
  37 + private String adminToken;
  38 + private String normalToken;
  39 + private String deletedToken;
  40 +
  41 + @BeforeEach
  42 + void setUp() {
  43 + fx = seeder.reset();
  44 + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN");
  45 + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL");
  46 + deletedToken = issue(LoginTestSeeder.USER_DELETED, "NORMAL");
  47 + }
  48 +
  49 + private String issue(String username, String userType) {
  50 + Map<String, Object> claims = new HashMap<>();
  51 + claims.put("sub", 1);
  52 + claims.put("username", username);
  53 + claims.put("userType", userType);
  54 + claims.put("companyCode", LoginTestSeeder.COMPANY_OK);
  55 + claims.put("language", "zh-CN");
  56 + return jwtUtil.issue(claims, 7200);
  57 + }
  58 +
  59 + private CreateUserReq buildReq() {
  60 + CreateUserReq r = new CreateUserReq();
  61 + r.setUsername("newbie");
  62 + r.setUserCode("U010");
  63 + r.setUserType("NORMAL");
  64 + r.setLanguage("zh-CN");
  65 + r.setCanEditDocument(false);
  66 + return r;
  67 + }
  68 +
  69 + private String body(Object o) throws Exception {
  70 + return json.writeValueAsString(o);
  71 + }
  72 +
  73 + @Test
  74 + void post_users_success_returns201_andCreatedVo() throws Exception {
  75 + mvc.perform(post("/api/v1/users")
  76 + .header("Authorization", "Bearer " + adminToken)
  77 + .contentType(MediaType.APPLICATION_JSON)
  78 + .content(body(buildReq())))
  79 + .andExpect(status().isCreated())
  80 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  81 + .andExpect(jsonPath("$.data.userId").isNumber())
  82 + .andExpect(jsonPath("$.data.username").value("newbie"))
  83 + .andExpect(jsonPath("$.data.userCode").value("U010"));
  84 + }
  85 +
  86 + @Test
  87 + void post_users_blankUsername_returns400_40001() throws Exception {
  88 + CreateUserReq r = buildReq();
  89 + r.setUsername("");
  90 + mvc.perform(post("/api/v1/users")
  91 + .header("Authorization", "Bearer " + adminToken)
  92 + .contentType(MediaType.APPLICATION_JSON)
  93 + .content(body(r)))
  94 + .andExpect(status().isBadRequest())
  95 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  96 + }
  97 +
  98 + @Test
  99 + void post_users_invalidUserType_returns400_40001() throws Exception {
  100 + CreateUserReq r = buildReq();
  101 + r.setUserType("ROOT");
  102 + mvc.perform(post("/api/v1/users")
  103 + .header("Authorization", "Bearer " + adminToken)
  104 + .contentType(MediaType.APPLICATION_JSON)
  105 + .content(body(r)))
  106 + .andExpect(status().isBadRequest())
  107 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  108 + }
  109 +
  110 + @Test
  111 + void post_users_unknownPropertyPassword_returns400_40001() throws Exception {
  112 + ObjectNode body = json.valueToTree(buildReq());
  113 + body.put("password", "Password1!");
  114 + mvc.perform(post("/api/v1/users")
  115 + .header("Authorization", "Bearer " + adminToken)
  116 + .contentType(MediaType.APPLICATION_JSON)
  117 + .content(body.toString()))
  118 + .andExpect(status().isBadRequest())
  119 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  120 + }
  121 +
  122 + @Test
  123 + void post_users_noAuthHeader_returns401_40101() throws Exception {
  124 + mvc.perform(post("/api/v1/users")
  125 + .contentType(MediaType.APPLICATION_JSON)
  126 + .content(body(buildReq())))
  127 + .andExpect(status().isUnauthorized())
  128 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  129 + }
  130 +
  131 + @Test
  132 + void post_users_normalUserToken_returns403_40301() throws Exception {
  133 + mvc.perform(post("/api/v1/users")
  134 + .header("Authorization", "Bearer " + normalToken)
  135 + .contentType(MediaType.APPLICATION_JSON)
  136 + .content(body(buildReq())))
  137 + .andExpect(status().isForbidden())
  138 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  139 + }
  140 +
  141 + @Test
  142 + void post_users_deletedUserToken_returns401_40101() throws Exception {
  143 + mvc.perform(post("/api/v1/users")
  144 + .header("Authorization", "Bearer " + deletedToken)
  145 + .contentType(MediaType.APPLICATION_JSON)
  146 + .content(body(buildReq())))
  147 + .andExpect(status().isUnauthorized())
  148 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  149 + }
  150 +
  151 + @Test
  152 + void post_users_duplicateUsername_returns409_40901() throws Exception {
  153 + CreateUserReq r = buildReq();
  154 + r.setUsername(LoginTestSeeder.USER_OK);
  155 + mvc.perform(post("/api/v1/users")
  156 + .header("Authorization", "Bearer " + adminToken)
  157 + .contentType(MediaType.APPLICATION_JSON)
  158 + .content(body(r)))
  159 + .andExpect(status().isConflict())
  160 + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERNAME));
  161 + }
  162 +
  163 + @Test
  164 + void post_users_duplicateUserCode_returns409_40902() throws Exception {
  165 + CreateUserReq r = buildReq();
  166 + r.setUserCode("U001");
  167 + mvc.perform(post("/api/v1/users")
  168 + .header("Authorization", "Bearer " + adminToken)
  169 + .contentType(MediaType.APPLICATION_JSON)
  170 + .content(body(r)))
  171 + .andExpect(status().isConflict())
  172 + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE));
  173 + }
  174 +
  175 + @Test
  176 + void post_users_unknownEmployee_returns400_40004() throws Exception {
  177 + CreateUserReq r = buildReq();
  178 + r.setEmployeeId(99999);
  179 + mvc.perform(post("/api/v1/users")
  180 + .header("Authorization", "Bearer " + adminToken)
  181 + .contentType(MediaType.APPLICATION_JSON)
  182 + .content(body(r)))
  183 + .andExpect(status().isBadRequest())
  184 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  185 + }
  186 +
  187 + @Test
  188 + void post_users_unknownPermissionCategory_returns400_40004() throws Exception {
  189 + CreateUserReq r = buildReq();
  190 + r.setPermissionCategoryIds(List.of(99999));
  191 + mvc.perform(post("/api/v1/users")
  192 + .header("Authorization", "Bearer " + adminToken)
  193 + .contentType(MediaType.APPLICATION_JSON)
  194 + .content(body(r)))
  195 + .andExpect(status().isBadRequest())
  196 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  197 + }
  198 +
  199 + @Test
  200 + void post_users_success_canLoginWithInitialPassword() throws Exception {
  201 + // 1) admin 创建用户
  202 + mvc.perform(post("/api/v1/users")
  203 + .header("Authorization", "Bearer " + adminToken)
  204 + .contentType(MediaType.APPLICATION_JSON)
  205 + .content(body(buildReq())))
  206 + .andExpect(status().isCreated());
  207 +
  208 + // 2) 新用户用初始密码 666666 登录应成功
  209 + LoginReq login = new LoginReq();
  210 + login.setUsername("newbie");
  211 + login.setPassword("666666");
  212 + login.setCompanyCode(LoginTestSeeder.COMPANY_OK);
  213 + mvc.perform(post("/api/v1/auth/login")
  214 + .contentType(MediaType.APPLICATION_JSON)
  215 + .content(body(login)))
  216 + .andExpect(status().isOk())
  217 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  218 + .andExpect(jsonPath("$.data.accessToken").isNotEmpty());
  219 + }
  220 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.node.ObjectNode;
  5 +import com.xly.erp.common.response.ErrorCode;
  6 +import com.xly.erp.common.security.JwtUtil;
  7 +import com.xly.erp.module.usr.dto.UpdateUserReq;
  8 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  9 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  10 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  11 +import org.junit.jupiter.api.BeforeEach;
  12 +import org.junit.jupiter.api.Test;
  13 +import org.springframework.beans.factory.annotation.Autowired;
  14 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  15 +import org.springframework.boot.test.context.SpringBootTest;
  16 +import org.springframework.http.MediaType;
  17 +import org.springframework.test.context.ActiveProfiles;
  18 +import org.springframework.test.web.servlet.MockMvc;
  19 +
  20 +import java.util.HashMap;
  21 +import java.util.List;
  22 +import java.util.Map;
  23 +
  24 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  25 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
  26 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  27 +
  28 +@SpringBootTest
  29 +@AutoConfigureMockMvc
  30 +@ActiveProfiles("test")
  31 +class UserControllerUpdateTest {
  32 +
  33 + @Autowired private MockMvc mvc;
  34 + @Autowired private ObjectMapper json;
  35 + @Autowired private LoginTestSeeder seeder;
  36 + @Autowired private JwtUtil jwtUtil;
  37 + @Autowired private SysUserPermissionCategoryMapper upcMapper;
  38 + @Autowired private com.xly.erp.module.usr.mapper.SysUserMapper userMapper;
  39 +
  40 + private LoginTestSeeder.Fixture fx;
  41 + private String adminToken;
  42 + private String normalToken;
  43 +
  44 + @BeforeEach
  45 + void setUp() {
  46 + fx = seeder.reset();
  47 + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId());
  48 + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId());
  49 + }
  50 +
  51 + private String issue(String username, String userType, Integer userId) {
  52 + Map<String, Object> c = new HashMap<>();
  53 + c.put("sub", userId);
  54 + c.put("username", username);
  55 + c.put("userType", userType);
  56 + c.put("companyCode", LoginTestSeeder.COMPANY_OK);
  57 + c.put("language", "zh-CN");
  58 + return jwtUtil.issue(c, 7200);
  59 + }
  60 +
  61 + // ===== GET =====
  62 +
  63 + @Test
  64 + void get_existingUser_returns200_andFullVo() throws Exception {
  65 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  66 + .header("Authorization", "Bearer " + adminToken))
  67 + .andExpect(status().isOk())
  68 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  69 + .andExpect(jsonPath("$.data.userId").value(fx.aliceId()))
  70 + .andExpect(jsonPath("$.data.username").value(LoginTestSeeder.USER_OK))
  71 + .andExpect(jsonPath("$.data.employeeName").value("张三"));
  72 + }
  73 +
  74 + @Test
  75 + void get_unknownUser_returns404_40401() throws Exception {
  76 + mvc.perform(get("/api/v1/users/99999")
  77 + .header("Authorization", "Bearer " + adminToken))
  78 + .andExpect(status().isNotFound())
  79 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND));
  80 + }
  81 +
  82 + @Test
  83 + void get_normalUser_returns403_40301() throws Exception {
  84 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  85 + .header("Authorization", "Bearer " + normalToken))
  86 + .andExpect(status().isForbidden())
  87 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  88 + }
  89 +
  90 + @Test
  91 + void get_noAuthHeader_returns401_40101() throws Exception {
  92 + mvc.perform(get("/api/v1/users/" + fx.aliceId()))
  93 + .andExpect(status().isUnauthorized())
  94 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  95 + }
  96 +
  97 + @Test
  98 + void get_deletedUser_stillReturns200() throws Exception {
  99 + mvc.perform(get("/api/v1/users/" + fx.bobDeletedId())
  100 + .header("Authorization", "Bearer " + adminToken))
  101 + .andExpect(status().isOk())
  102 + .andExpect(jsonPath("$.data.isDeleted").value(true));
  103 + }
  104 +
  105 + // ===== PUT =====
  106 +
  107 + private String body(Object o) throws Exception {
  108 + return json.writeValueAsString(o);
  109 + }
  110 +
  111 + private UpdateUserReq req() {
  112 + return new UpdateUserReq();
  113 + }
  114 +
  115 + @Test
  116 + void put_updateUserCodeAndType_returns200() throws Exception {
  117 + UpdateUserReq r = req();
  118 + r.setUserCode("U_NEW");
  119 + r.setUserType("SUPER_ADMIN");
  120 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  121 + .header("Authorization", "Bearer " + adminToken)
  122 + .contentType(MediaType.APPLICATION_JSON)
  123 + .content(body(r)))
  124 + .andExpect(status().isOk())
  125 + .andExpect(jsonPath("$.data.userCode").value("U_NEW"))
  126 + .andExpect(jsonPath("$.data.userType").value("SUPER_ADMIN"));
  127 + }
  128 +
  129 + @Test
  130 + void put_updateEmployeeId_toAnotherEmployee_setsValue() throws Exception {
  131 + UpdateUserReq r = req();
  132 + r.setEmployeeId(fx.employeeId());
  133 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  134 + .header("Authorization", "Bearer " + adminToken)
  135 + .contentType(MediaType.APPLICATION_JSON)
  136 + .content(body(r)))
  137 + .andExpect(status().isOk())
  138 + .andExpect(jsonPath("$.data.employeeId").value(fx.employeeId()));
  139 + }
  140 +
  141 + @Test
  142 + void put_updateEmployeeId_zero_clearsRelation() throws Exception {
  143 + UpdateUserReq r = req();
  144 + r.setEmployeeId(0);
  145 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  146 + .header("Authorization", "Bearer " + adminToken)
  147 + .contentType(MediaType.APPLICATION_JSON)
  148 + .content(body(r)))
  149 + .andExpect(status().isOk())
  150 + .andExpect(jsonPath("$.data.employeeId").doesNotExist());
  151 + }
  152 +
  153 + @Test
  154 + void put_updateEmployeeId_unknown_returns400_40004() throws Exception {
  155 + UpdateUserReq r = req();
  156 + r.setEmployeeId(99999);
  157 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  158 + .header("Authorization", "Bearer " + adminToken)
  159 + .contentType(MediaType.APPLICATION_JSON)
  160 + .content(body(r)))
  161 + .andExpect(status().isBadRequest())
  162 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  163 + }
  164 +
  165 + @Test
  166 + void put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall() throws Exception {
  167 + // 把 alice 设为作废
  168 + UpdateUserReq r = req();
  169 + r.setIsDeleted(true);
  170 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  171 + .header("Authorization", "Bearer " + adminToken)
  172 + .contentType(MediaType.APPLICATION_JSON)
  173 + .content(body(r)))
  174 + .andExpect(status().isOk());
  175 +
  176 + // 用 alice 的 token 调任何 /api/v1/** 接口应 40101
  177 + // 但 alice 是 NORMAL,连 admin 路径都会先 401(用户已作废),不是 403
  178 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  179 + .header("Authorization", "Bearer " + normalToken))
  180 + .andExpect(status().isUnauthorized())
  181 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  182 + }
  183 +
  184 + @Test
  185 + void put_permissionCategories_subsetDelta() throws Exception {
  186 + Integer pur = fx.activePermissionCategoryIds().get(0);
  187 + Integer sal = fx.activePermissionCategoryIds().get(1);
  188 + // 预置 alice 有 {pur, sal}
  189 + for (Integer pcId : List.of(pur, sal)) {
  190 + SysUserPermissionCategory l = new SysUserPermissionCategory();
  191 + l.setIUserId(fx.aliceId());
  192 + l.setIPermissionCategoryId(pcId);
  193 + l.setSGrantedBy("system");
  194 + upcMapper.insert(l);
  195 + }
  196 +
  197 + UpdateUserReq r = req();
  198 + r.setPermissionCategoryIds(List.of(sal)); // 只保留 sal
  199 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  200 + .header("Authorization", "Bearer " + adminToken)
  201 + .contentType(MediaType.APPLICATION_JSON)
  202 + .content(body(r)))
  203 + .andExpect(status().isOk())
  204 + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(1))
  205 + .andExpect(jsonPath("$.data.permissionCategoryIds[0]").value(sal));
  206 + }
  207 +
  208 + @Test
  209 + void put_permissionCategories_emptyArray_clearsAll() throws Exception {
  210 + Integer pur = fx.activePermissionCategoryIds().get(0);
  211 + SysUserPermissionCategory l = new SysUserPermissionCategory();
  212 + l.setIUserId(fx.aliceId());
  213 + l.setIPermissionCategoryId(pur);
  214 + l.setSGrantedBy("system");
  215 + upcMapper.insert(l);
  216 +
  217 + UpdateUserReq r = req();
  218 + r.setPermissionCategoryIds(List.of());
  219 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  220 + .header("Authorization", "Bearer " + adminToken)
  221 + .contentType(MediaType.APPLICATION_JSON)
  222 + .content(body(r)))
  223 + .andExpect(status().isOk())
  224 + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0));
  225 + }
  226 +
  227 + @Test
  228 + void put_permissionCategories_unknownId_returns400_40004_andRollsBack() throws Exception {
  229 + UpdateUserReq r = req();
  230 + r.setUserCode("U_NEW");
  231 + r.setPermissionCategoryIds(List.of(99999));
  232 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  233 + .header("Authorization", "Bearer " + adminToken)
  234 + .contentType(MediaType.APPLICATION_JSON)
  235 + .content(body(r)))
  236 + .andExpect(status().isBadRequest())
  237 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  238 +
  239 + // 验证回滚:alice 的 userCode 仍是 U001
  240 + com.xly.erp.module.usr.entity.SysUser db = userMapper.selectById(fx.aliceId());
  241 + org.junit.jupiter.api.Assertions.assertEquals("U001", db.getSUserCode());
  242 + }
  243 +
  244 + @Test
  245 + void put_duplicateUserCode_returns409_40902() throws Exception {
  246 + UpdateUserReq r = req();
  247 + r.setUserCode("U001"); // alice 的 userCode
  248 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  249 + .header("Authorization", "Bearer " + adminToken)
  250 + .contentType(MediaType.APPLICATION_JSON)
  251 + .content(body(r)))
  252 + .andExpect(status().isConflict())
  253 + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE));
  254 + }
  255 +
  256 + @Test
  257 + void put_userCodeUnchangedSameAsSelf_returns200() throws Exception {
  258 + UpdateUserReq r = req();
  259 + r.setUserCode("U001"); // alice 的当前 userCode
  260 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  261 + .header("Authorization", "Bearer " + adminToken)
  262 + .contentType(MediaType.APPLICATION_JSON)
  263 + .content(body(r)))
  264 + .andExpect(status().isOk());
  265 + }
  266 +
  267 + @Test
  268 + void put_selfDeactivate_returns403_40302() throws Exception {
  269 + UpdateUserReq r = req();
  270 + r.setIsDeleted(true);
  271 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  272 + .header("Authorization", "Bearer " + adminToken)
  273 + .contentType(MediaType.APPLICATION_JSON)
  274 + .content(body(r)))
  275 + .andExpect(status().isForbidden())
  276 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE));
  277 + }
  278 +
  279 + @Test
  280 + void put_unknownProperty_username_returns400_40001() throws Exception {
  281 + ObjectNode b = json.createObjectNode();
  282 + b.put("username", "hacker");
  283 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  284 + .header("Authorization", "Bearer " + adminToken)
  285 + .contentType(MediaType.APPLICATION_JSON)
  286 + .content(b.toString()))
  287 + .andExpect(status().isBadRequest())
  288 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  289 + }
  290 +
  291 + @Test
  292 + void put_unknownProperty_password_returns400_40001() throws Exception {
  293 + ObjectNode b = json.createObjectNode();
  294 + b.put("password", "newpass");
  295 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  296 + .header("Authorization", "Bearer " + adminToken)
  297 + .contentType(MediaType.APPLICATION_JSON)
  298 + .content(b.toString()))
  299 + .andExpect(status().isBadRequest())
  300 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  301 + }
  302 +
  303 + @Test
  304 + void put_unknownUserId_returns404_40401() throws Exception {
  305 + mvc.perform(put("/api/v1/users/99999")
  306 + .header("Authorization", "Bearer " + adminToken)
  307 + .contentType(MediaType.APPLICATION_JSON)
  308 + .content(body(req())))
  309 + .andExpect(status().isNotFound())
  310 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND));
  311 + }
  312 +
  313 + @Test
  314 + void put_normalUser_returns403_40301() throws Exception {
  315 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  316 + .header("Authorization", "Bearer " + normalToken)
  317 + .contentType(MediaType.APPLICATION_JSON)
  318 + .content(body(req())))
  319 + .andExpect(status().isForbidden())
  320 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  321 + }
  322 +
  323 + @Test
  324 + void put_emptyBody_only_updates_audit_fields() throws Exception {
  325 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  326 + .header("Authorization", "Bearer " + adminToken)
  327 + .contentType(MediaType.APPLICATION_JSON)
  328 + .content("{}"))
  329 + .andExpect(status().isOk())
  330 + .andExpect(jsonPath("$.data.updatedBy").value(LoginTestSeeder.USER_ADMIN));
  331 + }
  332 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/dto/CreateUserReqValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import org.junit.jupiter.api.Test;
  7 +
  8 +import java.util.List;
  9 +import java.util.Set;
  10 +
  11 +import static org.junit.jupiter.api.Assertions.*;
  12 +
  13 +class CreateUserReqValidationTest {
  14 +
  15 + private static final Validator VALIDATOR =
  16 + Validation.buildDefaultValidatorFactory().getValidator();
  17 +
  18 + private CreateUserReq build() {
  19 + CreateUserReq r = new CreateUserReq();
  20 + r.setUsername("alice2");
  21 + r.setUserCode("U010");
  22 + r.setUserType("NORMAL");
  23 + r.setLanguage("zh-CN");
  24 + r.setCanEditDocument(false);
  25 + return r;
  26 + }
  27 +
  28 + @Test
  29 + void allRequired_passes() {
  30 + Set<ConstraintViolation<CreateUserReq>> v = VALIDATOR.validate(build());
  31 + assertTrue(v.isEmpty());
  32 + }
  33 +
  34 + @Test
  35 + void blankUsername_fails() {
  36 + CreateUserReq r = build();
  37 + r.setUsername("");
  38 + assertFalse(VALIDATOR.validate(r).isEmpty());
  39 + }
  40 +
  41 + @Test
  42 + void usernameTooShort_fails() {
  43 + CreateUserReq r = build();
  44 + r.setUsername("ab");
  45 + assertFalse(VALIDATOR.validate(r).isEmpty());
  46 + }
  47 +
  48 + @Test
  49 + void usernameWithIllegalChar_fails() {
  50 + CreateUserReq r = build();
  51 + r.setUsername("al ice");
  52 + assertFalse(VALIDATOR.validate(r).isEmpty());
  53 + }
  54 +
  55 + @Test
  56 + void userCodeTooLong_fails() {
  57 + CreateUserReq r = build();
  58 + r.setUserCode("X".repeat(51));
  59 + assertFalse(VALIDATOR.validate(r).isEmpty());
  60 + }
  61 +
  62 + @Test
  63 + void userTypeNotEnum_fails() {
  64 + CreateUserReq r = build();
  65 + r.setUserType("ROOT");
  66 + assertFalse(VALIDATOR.validate(r).isEmpty());
  67 + }
  68 +
  69 + @Test
  70 + void languageNotEnum_fails() {
  71 + CreateUserReq r = build();
  72 + r.setLanguage("ja-JP");
  73 + assertFalse(VALIDATOR.validate(r).isEmpty());
  74 + }
  75 +
  76 + @Test
  77 + void canEditDocumentMissing_fails() {
  78 + CreateUserReq r = build();
  79 + r.setCanEditDocument(null);
  80 + assertFalse(VALIDATOR.validate(r).isEmpty());
  81 + }
  82 +
  83 + @Test
  84 + void employeeIdNull_isAllowed() {
  85 + CreateUserReq r = build();
  86 + r.setEmployeeId(null);
  87 + assertTrue(VALIDATOR.validate(r).isEmpty());
  88 + }
  89 +
  90 + @Test
  91 + void permissionCategoryIdsEmpty_isAllowed() {
  92 + CreateUserReq r = build();
  93 + r.setPermissionCategoryIds(List.of());
  94 + assertTrue(VALIDATOR.validate(r).isEmpty());
  95 + }
  96 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import jakarta.validation.ValidatorFactory;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.Set;
  10 +
  11 +import static org.junit.jupiter.api.Assertions.*;
  12 +
  13 +class LoginReqValidationTest {
  14 +
  15 + private static final Validator VALIDATOR =
  16 + Validation.buildDefaultValidatorFactory().getValidator();
  17 +
  18 + private LoginReq build(String u, String p, String c) {
  19 + LoginReq r = new LoginReq();
  20 + r.setUsername(u);
  21 + r.setPassword(p);
  22 + r.setCompanyCode(c);
  23 + return r;
  24 + }
  25 +
  26 + @Test
  27 + void blankUsername_fails() {
  28 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("", "Password1!", "HQ"));
  29 + assertFalse(v.isEmpty());
  30 + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("username")));
  31 + }
  32 +
  33 + @Test
  34 + void blankPassword_fails() {
  35 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "", "HQ"));
  36 + assertFalse(v.isEmpty());
  37 + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("password")));
  38 + }
  39 +
  40 + @Test
  41 + void blankCompanyCode_fails() {
  42 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "Password1!", ""));
  43 + assertFalse(v.isEmpty());
  44 + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("companyCode")));
  45 + }
  46 +
  47 + @Test
  48 + void tooLongUsername_fails() {
  49 + String tooLong = "a".repeat(51);
  50 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build(tooLong, "Password1!", "HQ"));
  51 + assertFalse(v.isEmpty());
  52 + }
  53 +
  54 + @Test
  55 + void tooLongPassword_fails() {
  56 + String tooLong = "a".repeat(129);
  57 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", tooLong, "HQ"));
  58 + assertFalse(v.isEmpty());
  59 + }
  60 +
  61 + @Test
  62 + void allFieldsPresent_passes() {
  63 + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "Password1!", "HQ"));
  64 + assertTrue(v.isEmpty());
  65 + }
  66 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.ConstraintViolation;
  4 +import jakarta.validation.Validation;
  5 +import jakarta.validation.Validator;
  6 +import org.junit.jupiter.api.Test;
  7 +
  8 +import java.util.Set;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.*;
  11 +
  12 +class UpdateUserReqValidationTest {
  13 +
  14 + private static final Validator V =
  15 + Validation.buildDefaultValidatorFactory().getValidator();
  16 +
  17 + @Test
  18 + void emptyBody_isValid() {
  19 + assertTrue(V.validate(new UpdateUserReq()).isEmpty());
  20 + }
  21 +
  22 + @Test
  23 + void invalidUserType_fails() {
  24 + UpdateUserReq r = new UpdateUserReq();
  25 + r.setUserType("ROOT");
  26 + assertFalse(V.validate(r).isEmpty());
  27 + }
  28 +
  29 + @Test
  30 + void invalidLanguage_fails() {
  31 + UpdateUserReq r = new UpdateUserReq();
  32 + r.setLanguage("ja-JP");
  33 + assertFalse(V.validate(r).isEmpty());
  34 + }
  35 +
  36 + @Test
  37 + void userCodeTooLong_fails() {
  38 + UpdateUserReq r = new UpdateUserReq();
  39 + r.setUserCode("X".repeat(51));
  40 + assertFalse(V.validate(r).isEmpty());
  41 + }
  42 +
  43 + @Test
  44 + void userCodeBlank_fails() {
  45 + UpdateUserReq r = new UpdateUserReq();
  46 + r.setUserCode(" ");
  47 + assertFalse(V.validate(r).isEmpty());
  48 + }
  49 +
  50 + @Test
  51 + void employeeIdNegative_fails() {
  52 + UpdateUserReq r = new UpdateUserReq();
  53 + r.setEmployeeId(-1);
  54 + assertFalse(V.validate(r).isEmpty());
  55 + }
  56 +
  57 + @Test
  58 + void employeeIdZero_isValid_meansUnsetRelation() {
  59 + UpdateUserReq r = new UpdateUserReq();
  60 + r.setEmployeeId(0);
  61 + assertTrue(V.validate(r).isEmpty());
  62 + }
  63 +
  64 + @Test
  65 + void allValidFields_passes() {
  66 + UpdateUserReq r = new UpdateUserReq();
  67 + r.setUserCode("U999");
  68 + r.setUserType("NORMAL");
  69 + r.setLanguage("zh-CN");
  70 + r.setCanEditDocument(true);
  71 + r.setEmployeeId(7);
  72 + r.setIsDeleted(false);
  73 + r.setPermissionCategoryIds(java.util.List.of(1, 2));
  74 + Set<ConstraintViolation<UpdateUserReq>> v = V.validate(r);
  75 + assertTrue(v.isEmpty(), () -> "should be empty but got: " + v);
  76 + }
  77 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryReqValidationTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.Validation;
  4 +import jakarta.validation.Validator;
  5 +import org.junit.jupiter.api.Test;
  6 +
  7 +import static org.junit.jupiter.api.Assertions.assertFalse;
  8 +import static org.junit.jupiter.api.Assertions.assertTrue;
  9 +
  10 +class UserQueryReqValidationTest {
  11 +
  12 + private static final Validator V =
  13 + Validation.buildDefaultValidatorFactory().getValidator();
  14 +
  15 + @Test
  16 + void emptyReq_isValid() {
  17 + assertTrue(V.validate(new UserQueryReq()).isEmpty());
  18 + }
  19 +
  20 + @Test
  21 + void pageZero_fails() {
  22 + UserQueryReq r = new UserQueryReq();
  23 + r.setPage(0);
  24 + assertFalse(V.validate(r).isEmpty());
  25 + }
  26 +
  27 + @Test
  28 + void sizeOver100_fails() {
  29 + UserQueryReq r = new UserQueryReq();
  30 + r.setSize(101);
  31 + assertFalse(V.validate(r).isEmpty());
  32 + }
  33 +
  34 + @Test
  35 + void sizeZero_fails() {
  36 + UserQueryReq r = new UserQueryReq();
  37 + r.setSize(0);
  38 + assertFalse(V.validate(r).isEmpty());
  39 + }
  40 +
  41 + @Test
  42 + void allValidFields_passes() {
  43 + UserQueryReq r = new UserQueryReq();
  44 + r.setPage(2);
  45 + r.setSize(50);
  46 + r.setSortField("tCreateDate");
  47 + r.setSortOrder("asc");
  48 + r.setQueryField("username");
  49 + r.setMatchMode("contains");
  50 + r.setQueryValue("ali");
  51 + r.setUserType("NORMAL");
  52 + r.setIsDeleted(false);
  53 + assertTrue(V.validate(r).isEmpty());
  54 + }
  55 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapperTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  4 +import org.junit.jupiter.api.BeforeEach;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.springframework.beans.factory.annotation.Autowired;
  7 +import org.springframework.boot.test.context.SpringBootTest;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +
  10 +import java.util.ArrayList;
  11 +import java.util.List;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.assertEquals;
  14 +
  15 +@SpringBootTest
  16 +@ActiveProfiles("test")
  17 +class SysPermissionCategoryMapperTest {
  18 +
  19 + @Autowired private SysPermissionCategoryMapper mapper;
  20 + @Autowired private LoginTestSeeder seeder;
  21 +
  22 + private LoginTestSeeder.Fixture fx;
  23 +
  24 + @BeforeEach
  25 + void setUp() {
  26 + fx = seeder.reset();
  27 + }
  28 +
  29 + @Test
  30 + void countActiveByIds_returnsAllActive() {
  31 + int n = mapper.countActiveByIds(fx.activePermissionCategoryIds());
  32 + assertEquals(fx.activePermissionCategoryIds().size(), n);
  33 + }
  34 +
  35 + @Test
  36 + void countActiveByIds_excludesDeleted() {
  37 + List<Integer> mixed = new ArrayList<>(fx.activePermissionCategoryIds());
  38 + mixed.add(fx.deletedPermissionCategoryId());
  39 + int n = mapper.countActiveByIds(mixed);
  40 + assertEquals(fx.activePermissionCategoryIds().size(), n,
  41 + "已软删的分类不应计入");
  42 + }
  43 +
  44 + @Test
  45 + void countActiveByIds_excludesUnknown() {
  46 + List<Integer> mixed = new ArrayList<>(fx.activePermissionCategoryIds());
  47 + mixed.add(99999);
  48 + int n = mapper.countActiveByIds(mixed);
  49 + assertEquals(fx.activePermissionCategoryIds().size(), n);
  50 + }
  51 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperQueryTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  4 +import com.xly.erp.module.usr.vo.UserListItemVo;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.beans.factory.annotation.Autowired;
  8 +import org.springframework.boot.test.context.SpringBootTest;
  9 +import org.springframework.test.context.ActiveProfiles;
  10 +
  11 +import java.util.List;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.*;
  14 +
  15 +@SpringBootTest
  16 +@ActiveProfiles("test")
  17 +class SysUserMapperQueryTest {
  18 +
  19 + @Autowired private SysUserMapper mapper;
  20 + @Autowired private LoginTestSeeder seeder;
  21 +
  22 + @BeforeEach
  23 + void setUp() {
  24 + seeder.reset();
  25 + }
  26 +
  27 + private UserQueryParams baseParams() {
  28 + UserQueryParams p = new UserQueryParams();
  29 + p.sqlSortField = "tCreateDate";
  30 + p.sqlSortOrder = "desc";
  31 + p.matchMode = "contains";
  32 + p.offset = 0;
  33 + p.limit = 100;
  34 + return p;
  35 + }
  36 +
  37 + @Test
  38 + void count_noFilters_returnsAllRows() {
  39 + long total = mapper.countByQuery(baseParams());
  40 + // seeder 插入 alice + admin + bob_deleted = 3 行
  41 + assertEquals(3, total);
  42 + }
  43 +
  44 + @Test
  45 + void select_withSortByUsername_ascending() {
  46 + UserQueryParams p = baseParams();
  47 + p.sqlSortField = "sUsername";
  48 + p.sqlSortOrder = "asc";
  49 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  50 + assertEquals(3, rows.size());
  51 + // 期望升序:admin / alice / bob_deleted
  52 + assertEquals(LoginTestSeeder.USER_ADMIN, rows.get(0).getUsername());
  53 + assertEquals(LoginTestSeeder.USER_OK, rows.get(1).getUsername());
  54 + assertEquals(LoginTestSeeder.USER_DELETED, rows.get(2).getUsername());
  55 + }
  56 +
  57 + @Test
  58 + void select_withQueryFieldUsername_contains() {
  59 + UserQueryParams p = baseParams();
  60 + p.sqlQueryColumn = "u.sUsername";
  61 + p.matchMode = "contains";
  62 + p.queryValue = "ali";
  63 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  64 + assertEquals(1, rows.size());
  65 + assertEquals(LoginTestSeeder.USER_OK, rows.get(0).getUsername());
  66 + }
  67 +
  68 + @Test
  69 + void select_joinsEmployeeAndDepartment_returnsBothNames() {
  70 + UserQueryParams p = baseParams();
  71 + p.sqlQueryColumn = "u.sUsername";
  72 + p.matchMode = "equals";
  73 + p.queryValue = LoginTestSeeder.USER_OK;
  74 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  75 + assertEquals(1, rows.size());
  76 + assertEquals("张三", rows.get(0).getEmployeeName());
  77 + assertEquals("技术部", rows.get(0).getDepartmentName());
  78 + }
  79 +
  80 + @Test
  81 + void select_withIsDeletedFilter_returnsOnlyMatching() {
  82 + UserQueryParams p = baseParams();
  83 + p.isDeleted = 1;
  84 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  85 + assertEquals(1, rows.size());
  86 + assertEquals(LoginTestSeeder.USER_DELETED, rows.get(0).getUsername());
  87 + }
  88 +
  89 + @Test
  90 + void select_withUserTypeFilter_returnsOnlyAdmin() {
  91 + UserQueryParams p = baseParams();
  92 + p.userType = "SUPER_ADMIN";
  93 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  94 + assertEquals(1, rows.size());
  95 + assertEquals(LoginTestSeeder.USER_ADMIN, rows.get(0).getUsername());
  96 + }
  97 +
  98 + @Test
  99 + void select_pagination_limitsResults() {
  100 + UserQueryParams p = baseParams();
  101 + p.limit = 2;
  102 + List<UserListItemVo> rows = mapper.selectByQuery(p);
  103 + assertEquals(2, rows.size());
  104 + }
  105 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.xly.erp.module.usr.entity.SysUser;
  4 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.beans.factory.annotation.Autowired;
  8 +import org.springframework.boot.test.context.SpringBootTest;
  9 +import org.springframework.test.context.ActiveProfiles;
  10 +
  11 +import static org.junit.jupiter.api.Assertions.*;
  12 +
  13 +@SpringBootTest
  14 +@ActiveProfiles("test")
  15 +class SysUserMapperTest {
  16 +
  17 + @Autowired
  18 + private SysUserMapper userMapper;
  19 +
  20 + @Autowired
  21 + private LoginTestSeeder seeder;
  22 +
  23 + @BeforeEach
  24 + void setUp() {
  25 + seeder.reset();
  26 + }
  27 +
  28 + @Test
  29 + void selectByUsername_returnsUserWithAllFields() {
  30 + SysUser user = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  31 + assertNotNull(user);
  32 + assertEquals(LoginTestSeeder.USER_OK, user.getSUsername());
  33 + assertEquals("U001", user.getSUserCode());
  34 + assertNotNull(user.getSPasswordHash());
  35 + assertEquals("NORMAL", user.getSUserType());
  36 + assertEquals("zh-CN", user.getSLanguage());
  37 + assertEquals(0, user.getIIsDeleted());
  38 + assertEquals(0, user.getIFailedLoginCount());
  39 + }
  40 +
  41 + @Test
  42 + void selectByUsername_returnsNullWhenNotFound() {
  43 + SysUser user = userMapper.selectByUsername("nobody");
  44 + assertNull(user);
  45 + }
  46 +
  47 + @Test
  48 + void existsByUsername_trueForExisting() {
  49 + assertTrue(userMapper.existsByUsername(LoginTestSeeder.USER_OK));
  50 + }
  51 +
  52 + @Test
  53 + void existsByUsername_falseForUnknown() {
  54 + assertFalse(userMapper.existsByUsername("nobody"));
  55 + }
  56 +
  57 + @Test
  58 + void existsByUserCode_trueForExisting() {
  59 + assertTrue(userMapper.existsByUserCode("U001"));
  60 + }
  61 +
  62 + @Test
  63 + void existsByUserCode_falseForUnknown() {
  64 + assertFalse(userMapper.existsByUserCode("UXXX"));
  65 + }
  66 +
  67 + @Test
  68 + void existsByUserCodeExcludingId_otherUserHasCode_returnsTrue() {
  69 + // alice has U001 / admin has U000;查 U001 排除 admin → 找到 alice → true
  70 + assertTrue(userMapper.existsByUserCodeExcludingId("U001",
  71 + seeder.reset().adminId()));
  72 + }
  73 +
  74 + @Test
  75 + void existsByUserCodeExcludingId_selfHasCode_returnsFalse() {
  76 + LoginTestSeeder.Fixture f = seeder.reset();
  77 + // 查 alice 的 userCode 排除 alice 本身 → false
  78 + assertFalse(userMapper.existsByUserCodeExcludingId("U001", f.aliceId()));
  79 + }
  80 +
  81 + @Test
  82 + void existsByUserCodeExcludingId_unknownCode_returnsFalse() {
  83 + assertFalse(userMapper.existsByUserCodeExcludingId("UXXX", 1));
  84 + }
  85 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.mapper;
  2 +
  3 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  4 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.beans.factory.annotation.Autowired;
  8 +import org.springframework.boot.test.context.SpringBootTest;
  9 +import org.springframework.test.context.ActiveProfiles;
  10 +
  11 +import java.util.List;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.*;
  14 +
  15 +@SpringBootTest
  16 +@ActiveProfiles("test")
  17 +class SysUserPermissionCategoryMapperTest {
  18 +
  19 + @Autowired private SysUserPermissionCategoryMapper mapper;
  20 + @Autowired private LoginTestSeeder seeder;
  21 +
  22 + private LoginTestSeeder.Fixture fx;
  23 +
  24 + @BeforeEach
  25 + void setUp() {
  26 + fx = seeder.reset();
  27 + // 给 alice 授权两个活跃分类
  28 + for (Integer pcId : fx.activePermissionCategoryIds()) {
  29 + SysUserPermissionCategory link = new SysUserPermissionCategory();
  30 + link.setIUserId(fx.aliceId());
  31 + link.setIPermissionCategoryId(pcId);
  32 + link.setSGrantedBy("system");
  33 + mapper.insert(link);
  34 + }
  35 + }
  36 +
  37 + @Test
  38 + void selectPermissionCategoryIdsByUserId_returnsAllCurrent() {
  39 + List<Integer> ids = mapper.selectPermissionCategoryIdsByUserId(fx.aliceId());
  40 + assertEquals(fx.activePermissionCategoryIds().size(), ids.size());
  41 + assertTrue(ids.containsAll(fx.activePermissionCategoryIds()));
  42 + }
  43 +
  44 + @Test
  45 + void selectPermissionCategoryIdsByUserId_emptyForNoGrants() {
  46 + List<Integer> ids = mapper.selectPermissionCategoryIdsByUserId(fx.adminId());
  47 + assertTrue(ids.isEmpty());
  48 + }
  49 +
  50 + @Test
  51 + void deleteByUserAndCategoryIds_onlyDeletesGivenSubset() {
  52 + Integer pur = fx.activePermissionCategoryIds().get(0);
  53 + int rows = mapper.deleteByUserAndCategoryIds(fx.aliceId(), List.of(pur));
  54 + assertEquals(1, rows);
  55 +
  56 + List<Integer> remaining = mapper.selectPermissionCategoryIdsByUserId(fx.aliceId());
  57 + assertEquals(fx.activePermissionCategoryIds().size() - 1, remaining.size());
  58 + assertFalse(remaining.contains(pur));
  59 + }
  60 +
  61 + @Test
  62 + void deleteByUserAndCategoryIds_nonMatchingIds_returns0() {
  63 + int rows = mapper.deleteByUserAndCategoryIds(fx.aliceId(), List.of(99999));
  64 + assertEquals(0, rows);
  65 + }
  66 +
  67 + @Test
  68 + void deleteByUserAndCategoryIds_doesNotAffectOtherUser() {
  69 + // 给 admin 也授权一条
  70 + SysUserPermissionCategory link = new SysUserPermissionCategory();
  71 + link.setIUserId(fx.adminId());
  72 + link.setIPermissionCategoryId(fx.activePermissionCategoryIds().get(0));
  73 + link.setSGrantedBy("system");
  74 + mapper.insert(link);
  75 +
  76 + // 删 alice 的某个分类不应影响 admin
  77 + mapper.deleteByUserAndCategoryIds(fx.aliceId(),
  78 + List.of(fx.activePermissionCategoryIds().get(0)));
  79 +
  80 + assertEquals(1, mapper.selectPermissionCategoryIdsByUserId(fx.adminId()).size());
  81 + }
  82 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.security.JwtUtil;
  6 +import com.xly.erp.module.usr.entity.SysUser;
  7 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  8 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  9 +import com.xly.erp.module.usr.vo.LoginVo;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.beans.factory.annotation.Autowired;
  13 +import org.springframework.boot.test.context.SpringBootTest;
  14 +import org.springframework.jdbc.core.JdbcTemplate;
  15 +import org.springframework.test.context.ActiveProfiles;
  16 +
  17 +import java.time.LocalDateTime;
  18 +import java.util.Map;
  19 +
  20 +import static org.junit.jupiter.api.Assertions.*;
  21 +
  22 +@SpringBootTest
  23 +@ActiveProfiles("test")
  24 +class LoginServiceImplTest {
  25 +
  26 + @Autowired private LoginService loginService;
  27 + @Autowired private SysUserMapper userMapper;
  28 + @Autowired private JdbcTemplate jdbc;
  29 + @Autowired private LoginTestSeeder seeder;
  30 + @Autowired private JwtUtil jwtUtil;
  31 +
  32 + private LoginTestSeeder.Fixture fx;
  33 +
  34 + @BeforeEach
  35 + void setUp() {
  36 + fx = seeder.reset();
  37 + }
  38 +
  39 + @Test
  40 + void contextLoads_loginServiceBean() {
  41 + assertNotNull(loginService);
  42 + }
  43 +
  44 + // ===== Task 7: company validation =====
  45 +
  46 + @Test
  47 + void login_unknownCompany_throws40004() {
  48 + BizException e = assertThrows(BizException.class,
  49 + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE"));
  50 + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode());
  51 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  52 + assertEquals(0, u.getIFailedLoginCount(), "公司校验失败不应累加用户失败次数");
  53 + }
  54 +
  55 + @Test
  56 + void login_softDeletedCompany_throws40004() {
  57 + BizException e = assertThrows(BizException.class,
  58 + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  59 + LoginTestSeeder.COMPANY_DELETED));
  60 + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode());
  61 + }
  62 +
  63 + // ===== Task 8: bad credentials =====
  64 +
  65 + @Test
  66 + void login_unknownUser_throws40101_noDbWrite() {
  67 + BizException e = assertThrows(BizException.class,
  68 + () -> loginService.login("nobody", "any", LoginTestSeeder.COMPANY_OK));
  69 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  70 + }
  71 +
  72 + @Test
  73 + void login_badPassword_throws40101_andIncrementsFailCount() {
  74 + BizException e = assertThrows(BizException.class,
  75 + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK));
  76 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  77 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  78 + assertEquals(1, u.getIFailedLoginCount());
  79 + }
  80 +
  81 + // ===== Task 9: locking =====
  82 +
  83 + @Test
  84 + void login_5thBadPassword_setsLockUntil_andStillReturns40101() {
  85 + for (int i = 0; i < 4; i++) {
  86 + assertThrows(BizException.class,
  87 + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK));
  88 + }
  89 + SysUser before5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  90 + assertEquals(4, before5.getIFailedLoginCount());
  91 + assertNull(before5.getTLockUntil());
  92 +
  93 + BizException e = assertThrows(BizException.class,
  94 + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK));
  95 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode(),
  96 + "第 5 次错误仍返 40101(不暴露阈值)");
  97 +
  98 + SysUser after5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  99 + assertEquals(5, after5.getIFailedLoginCount());
  100 + assertNotNull(after5.getTLockUntil(), "第 5 次错误应设置锁定截止");
  101 + assertTrue(after5.getTLockUntil().isAfter(LocalDateTime.now().plusMinutes(29)),
  102 + "锁定时长应 ~30 分钟");
  103 + }
  104 +
  105 + @Test
  106 + void login_duringLockWindow_throws42301_noCountIncrement() {
  107 + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?",
  108 + LoginTestSeeder.USER_OK);
  109 +
  110 + BizException e = assertThrows(BizException.class,
  111 + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  112 + LoginTestSeeder.COMPANY_OK));
  113 + assertEquals(ErrorCode.ACCOUNT_LOCKED, e.getCode());
  114 +
  115 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  116 + assertEquals(5, u.getIFailedLoginCount(), "锁定期间任何登录尝试不应改变计数");
  117 + }
  118 +
  119 + @Test
  120 + void login_afterLockExpired_allowsNewAttempt() {
  121 + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_SUB(NOW(), INTERVAL 1 MINUTE) WHERE sUsername=?",
  122 + LoginTestSeeder.USER_OK);
  123 +
  124 + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  125 + LoginTestSeeder.COMPANY_OK);
  126 + assertNotNull(vo.getAccessToken());
  127 +
  128 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  129 + assertEquals(0, u.getIFailedLoginCount(), "锁定过期 + 成功登录应清零");
  130 + assertNull(u.getTLockUntil(), "成功登录应清空 tLockUntil");
  131 + }
  132 +
  133 + // ===== Task 10: deleted + success =====
  134 +
  135 + @Test
  136 + void login_deletedUser_throws40103_noCountIncrement() {
  137 + BizException e = assertThrows(BizException.class,
  138 + () -> loginService.login(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD,
  139 + LoginTestSeeder.COMPANY_OK));
  140 + assertEquals(ErrorCode.ACCOUNT_DELETED, e.getCode());
  141 +
  142 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_DELETED);
  143 + assertEquals(0, u.getIFailedLoginCount());
  144 + }
  145 +
  146 + @Test
  147 + void login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin() {
  148 + jdbc.update("UPDATE sys_user SET iFailedLoginCount=2 WHERE sUsername=?", LoginTestSeeder.USER_OK);
  149 +
  150 + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  151 + LoginTestSeeder.COMPANY_OK);
  152 +
  153 + assertNotNull(vo);
  154 + assertNotNull(vo.getAccessToken());
  155 + assertEquals("Bearer", vo.getTokenType());
  156 + assertEquals(7200L, vo.getExpiresInSec());
  157 + assertNotNull(vo.getUserInfo());
  158 + assertEquals(LoginTestSeeder.USER_OK, vo.getUserInfo().getUsername());
  159 + assertEquals("NORMAL", vo.getUserInfo().getUserType());
  160 + assertEquals("zh-CN", vo.getUserInfo().getLanguage());
  161 + assertEquals(LoginTestSeeder.COMPANY_OK, vo.getUserInfo().getCompanyCode());
  162 + assertEquals("张三", vo.getUserInfo().getEmployeeName());
  163 +
  164 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  165 + assertEquals(0, u.getIFailedLoginCount());
  166 + assertNull(u.getTLockUntil());
  167 + assertNotNull(u.getTLastLoginDate());
  168 + }
  169 +
  170 + @Test
  171 + void login_concurrentBadPassword_atomicallyIncrementsCount() throws Exception {
  172 + // 2 线程并发各跑 2 次错误密码 → 计数累加必须 == 4(低于 5 不触发锁定,
  173 + // 专注验证原子性。锁定路径在 login_5thBadPassword_* 单线程测试中验证)
  174 + int perThread = 2;
  175 + int threadCount = 2;
  176 + Thread t1 = new Thread(() -> {
  177 + for (int i = 0; i < perThread; i++) {
  178 + try {
  179 + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK);
  180 + } catch (BizException ignored) {}
  181 + }
  182 + });
  183 + Thread t2 = new Thread(() -> {
  184 + for (int i = 0; i < perThread; i++) {
  185 + try {
  186 + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK);
  187 + } catch (BizException ignored) {}
  188 + }
  189 + });
  190 + t1.start(); t2.start();
  191 + t1.join(); t2.join();
  192 +
  193 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  194 + assertEquals(perThread * threadCount, u.getIFailedLoginCount(),
  195 + "并发失败累加必须 == 总次数(原子 UPDATE 保证;非原子实现会丢失累加)");
  196 + assertNull(u.getTLockUntil(), "总次数低于阈值不应触发锁定");
  197 + }
  198 +
  199 + @Test
  200 + void login_success_jwtParsesBack_with_sub_username_companyCode() {
  201 + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
  202 + LoginTestSeeder.COMPANY_OK);
  203 + Map<String, Object> claims = jwtUtil.parse(vo.getAccessToken());
  204 + assertEquals(String.valueOf(fx.aliceId()), claims.get("sub"));
  205 + assertEquals(LoginTestSeeder.USER_OK, claims.get("username"));
  206 + assertEquals(LoginTestSeeder.COMPANY_OK, claims.get("companyCode"));
  207 + assertEquals("NORMAL", claims.get("userType"));
  208 + assertEquals("zh-CN", claims.get("language"));
  209 + assertNotNull(claims.get("jti"));
  210 + long iat = ((Number) claims.get("iat")).longValue();
  211 + long exp = ((Number) claims.get("exp")).longValue();
  212 + assertEquals(7200L, exp - iat, "exp - iat 必须 == TOKEN_TTL_SEC");
  213 + }
  214 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.dto.CreateUserReq;
  6 +import com.xly.erp.module.usr.entity.SysUser;
  7 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  8 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  9 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  10 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  11 +import com.xly.erp.module.usr.vo.CreateUserVo;
  12 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  13 +import org.junit.jupiter.api.BeforeEach;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.springframework.beans.factory.annotation.Autowired;
  16 +import org.springframework.boot.test.context.SpringBootTest;
  17 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  18 +import org.springframework.test.context.ActiveProfiles;
  19 +
  20 +import java.util.List;
  21 +
  22 +import static org.junit.jupiter.api.Assertions.*;
  23 +
  24 +@SpringBootTest
  25 +@ActiveProfiles("test")
  26 +class UserCreateServiceImplTest {
  27 +
  28 + @Autowired private UserCreateService service;
  29 + @Autowired private SysUserMapper userMapper;
  30 + @Autowired private SysUserPermissionCategoryMapper upcMapper;
  31 + @Autowired private BCryptPasswordEncoder encoder;
  32 + @Autowired private LoginTestSeeder seeder;
  33 +
  34 + private LoginTestSeeder.Fixture fx;
  35 +
  36 + @BeforeEach
  37 + void setUp() {
  38 + fx = seeder.reset();
  39 + }
  40 +
  41 + private CreateUserReq buildReq(String username, String userCode) {
  42 + CreateUserReq r = new CreateUserReq();
  43 + r.setUsername(username);
  44 + r.setUserCode(userCode);
  45 + r.setUserType("NORMAL");
  46 + r.setLanguage("zh-CN");
  47 + r.setCanEditDocument(false);
  48 + return r;
  49 + }
  50 +
  51 + // ===== 唯一性 / 外键校验(Task 7) =====
  52 +
  53 + @Test
  54 + void create_usernameExists_throws40901() {
  55 + CreateUserReq r = buildReq(LoginTestSeeder.USER_OK, "U999");
  56 + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin"));
  57 + assertEquals(ErrorCode.CONFLICT_USERNAME, e.getCode());
  58 + }
  59 +
  60 + @Test
  61 + void create_userCodeExists_throws40902() {
  62 + CreateUserReq r = buildReq("brandnew", "U001");
  63 + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin"));
  64 + assertEquals(ErrorCode.CONFLICT_USERCODE, e.getCode());
  65 + }
  66 +
  67 + @Test
  68 + void create_employeeIdNotFound_throws40004() {
  69 + CreateUserReq r = buildReq("brandnew", "U999");
  70 + r.setEmployeeId(99999);
  71 + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin"));
  72 + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode());
  73 + }
  74 +
  75 + @Test
  76 + void create_permissionCategoryNotFound_throws40004() {
  77 + CreateUserReq r = buildReq("brandnew", "U999");
  78 + List<Integer> bad = new java.util.ArrayList<>(fx.activePermissionCategoryIds());
  79 + bad.add(99999);
  80 + r.setPermissionCategoryIds(bad);
  81 + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin"));
  82 + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode());
  83 + }
  84 +
  85 + @Test
  86 + void create_permissionCategorySoftDeleted_throws40004() {
  87 + CreateUserReq r = buildReq("brandnew", "U999");
  88 + r.setPermissionCategoryIds(List.of(fx.deletedPermissionCategoryId()));
  89 + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin"));
  90 + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode());
  91 + }
  92 +
  93 + // ===== 写入路径(Task 8) =====
  94 +
  95 + @Test
  96 + void create_minimalFields_persistsUserWithInitialPassword() {
  97 + CreateUserReq r = buildReq("newbie", "U010");
  98 + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN);
  99 +
  100 + assertNotNull(vo.getUserId());
  101 + assertEquals("newbie", vo.getUsername());
  102 + assertEquals("U010", vo.getUserCode());
  103 +
  104 + SysUser db = userMapper.selectByUsername("newbie");
  105 + assertNotNull(db);
  106 + assertEquals("NORMAL", db.getSUserType());
  107 + assertEquals("zh-CN", db.getSLanguage());
  108 + assertEquals(0, db.getICanEditDocument());
  109 + assertEquals(0, db.getIIsDeleted());
  110 + assertEquals(0, db.getIFailedLoginCount());
  111 + assertTrue(encoder.matches("666666", db.getSPasswordHash()),
  112 + "初始密码必须 BCrypt 哈希为 666666");
  113 + }
  114 +
  115 + @Test
  116 + void create_fullFields_persistsUserAndPermissionMappings() {
  117 + CreateUserReq r = buildReq("manager", "U020");
  118 + r.setUserType("SUPER_ADMIN");
  119 + r.setLanguage("en-US");
  120 + r.setCanEditDocument(true);
  121 + r.setEmployeeId(fx.employeeId());
  122 + r.setPermissionCategoryIds(fx.activePermissionCategoryIds());
  123 +
  124 + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN);
  125 +
  126 + SysUser db = userMapper.selectByUsername("manager");
  127 + assertNotNull(db);
  128 + assertEquals("SUPER_ADMIN", db.getSUserType());
  129 + assertEquals("en-US", db.getSLanguage());
  130 + assertEquals(1, db.getICanEditDocument());
  131 + assertEquals(fx.employeeId(), db.getIEmployeeId());
  132 +
  133 + List<SysUserPermissionCategory> links = upcMapper.selectList(
  134 + new LambdaQueryWrapper<SysUserPermissionCategory>()
  135 + .eq(SysUserPermissionCategory::getIUserId, vo.getUserId()));
  136 + assertEquals(fx.activePermissionCategoryIds().size(), links.size());
  137 + for (SysUserPermissionCategory link : links) {
  138 + assertEquals(LoginTestSeeder.USER_ADMIN, link.getSGrantedBy());
  139 + }
  140 + }
  141 +
  142 + @Test
  143 + void create_emptyPermissionCategories_persistsUserOnly() {
  144 + CreateUserReq r = buildReq("solo", "U030");
  145 + r.setPermissionCategoryIds(List.of());
  146 + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN);
  147 +
  148 + SysUser db = userMapper.selectByUsername("solo");
  149 + assertNotNull(db);
  150 +
  151 + List<SysUserPermissionCategory> links = upcMapper.selectList(
  152 + new LambdaQueryWrapper<SysUserPermissionCategory>()
  153 + .eq(SysUserPermissionCategory::getIUserId, vo.getUserId()));
  154 + assertTrue(links.isEmpty());
  155 + }
  156 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  6 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  7 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  8 +import com.xly.erp.module.usr.vo.UserDetailVo;
  9 +import org.junit.jupiter.api.BeforeEach;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.springframework.beans.factory.annotation.Autowired;
  12 +import org.springframework.boot.test.context.SpringBootTest;
  13 +import org.springframework.test.context.ActiveProfiles;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +@SpringBootTest
  18 +@ActiveProfiles("test")
  19 +class UserDetailServiceImplTest {
  20 +
  21 + @Autowired private UserDetailService service;
  22 + @Autowired private LoginTestSeeder seeder;
  23 + @Autowired private SysUserPermissionCategoryMapper upcMapper;
  24 +
  25 + private LoginTestSeeder.Fixture fx;
  26 +
  27 + @BeforeEach
  28 + void setUp() {
  29 + fx = seeder.reset();
  30 + }
  31 +
  32 + @Test
  33 + void getById_existingActiveUser_returnsFullVo() {
  34 + // 给 alice 加 2 个权限分类授权
  35 + for (Integer pcId : fx.activePermissionCategoryIds()) {
  36 + SysUserPermissionCategory link = new SysUserPermissionCategory();
  37 + link.setIUserId(fx.aliceId());
  38 + link.setIPermissionCategoryId(pcId);
  39 + link.setSGrantedBy("system");
  40 + upcMapper.insert(link);
  41 + }
  42 +
  43 + UserDetailVo vo = service.getById(fx.aliceId());
  44 + assertEquals(fx.aliceId(), vo.getUserId());
  45 + assertEquals(LoginTestSeeder.USER_OK, vo.getUsername());
  46 + assertEquals("U001", vo.getUserCode());
  47 + assertEquals("NORMAL", vo.getUserType());
  48 + assertEquals("zh-CN", vo.getLanguage());
  49 + assertEquals(false, vo.getCanEditDocument());
  50 + assertEquals(false, vo.getIsDeleted());
  51 + assertEquals(fx.employeeId(), vo.getEmployeeId());
  52 + assertEquals("张三", vo.getEmployeeName());
  53 + assertEquals(2, vo.getPermissionCategoryIds().size());
  54 + }
  55 +
  56 + @Test
  57 + void getById_userWithoutEmployee_employeeNameIsNull() {
  58 + UserDetailVo vo = service.getById(fx.adminId());
  59 + assertNull(vo.getEmployeeId());
  60 + assertNull(vo.getEmployeeName());
  61 + }
  62 +
  63 + @Test
  64 + void getById_userWithoutPermissions_emptyList() {
  65 + UserDetailVo vo = service.getById(fx.aliceId());
  66 + assertNotNull(vo.getPermissionCategoryIds());
  67 + assertTrue(vo.getPermissionCategoryIds().isEmpty());
  68 + }
  69 +
  70 + @Test
  71 + void getById_deletedUser_stillReturned() {
  72 + UserDetailVo vo = service.getById(fx.bobDeletedId());
  73 + assertEquals(LoginTestSeeder.USER_DELETED, vo.getUsername());
  74 + assertEquals(true, vo.getIsDeleted());
  75 + }
  76 +
  77 + @Test
  78 + void getById_unknownId_throws40401() {
  79 + BizException e = assertThrows(BizException.class, () -> service.getById(99999));
  80 + assertEquals(ErrorCode.USER_NOT_FOUND, e.getCode());
  81 + }
  82 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.response.PageResult;
  6 +import com.xly.erp.module.usr.dto.UserQueryReq;
  7 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  8 +import com.xly.erp.module.usr.vo.UserListItemVo;
  9 +import org.junit.jupiter.api.BeforeEach;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.springframework.beans.factory.annotation.Autowired;
  12 +import org.springframework.boot.test.context.SpringBootTest;
  13 +import org.springframework.test.context.ActiveProfiles;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +@SpringBootTest
  18 +@ActiveProfiles("test")
  19 +class UserListServiceImplTest {
  20 +
  21 + @Autowired private UserListService service;
  22 + @Autowired private LoginTestSeeder seeder;
  23 +
  24 + @BeforeEach
  25 + void setUp() {
  26 + seeder.reset();
  27 + }
  28 +
  29 + private UserQueryReq req() {
  30 + return new UserQueryReq();
  31 + }
  32 +
  33 + @Test
  34 + void list_default_returnsAllUsers() {
  35 + PageResult<UserListItemVo> result = service.list(req());
  36 + assertEquals(3, result.getTotal());
  37 + assertEquals(3, result.getRecords().size());
  38 + assertEquals(1, result.getPage());
  39 + assertEquals(20, result.getSize());
  40 + }
  41 +
  42 + @Test
  43 + void list_sortByUsernameAsc() {
  44 + UserQueryReq r = req();
  45 + r.setSortField("sUsername");
  46 + r.setSortOrder("asc");
  47 + PageResult<UserListItemVo> result = service.list(r);
  48 + assertEquals(LoginTestSeeder.USER_ADMIN, result.getRecords().get(0).getUsername());
  49 + }
  50 +
  51 + @Test
  52 + void list_sortFieldInvalid_throws40003() {
  53 + UserQueryReq r = req();
  54 + r.setSortField("badField");
  55 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  56 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  57 + }
  58 +
  59 + @Test
  60 + void list_sortOrderInvalid_throws40001() {
  61 + UserQueryReq r = req();
  62 + r.setSortOrder("foo");
  63 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  64 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  65 + }
  66 +
  67 + @Test
  68 + void list_queryFieldInvalid_throws40003() {
  69 + UserQueryReq r = req();
  70 + r.setQueryField("badField");
  71 + r.setQueryValue("x");
  72 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  73 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  74 + }
  75 +
  76 + @Test
  77 + void list_matchModeInvalid_throws40003() {
  78 + UserQueryReq r = req();
  79 + r.setMatchMode("startsWith");
  80 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  81 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  82 + }
  83 +
  84 + @Test
  85 + void list_queryByUsernameContains() {
  86 + UserQueryReq r = req();
  87 + r.setQueryField("username");
  88 + r.setMatchMode("contains");
  89 + r.setQueryValue("ali");
  90 + PageResult<UserListItemVo> result = service.list(r);
  91 + assertEquals(1, result.getTotal());
  92 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  93 + }
  94 +
  95 + @Test
  96 + void list_queryByEmployeeName_joinsCorrectly() {
  97 + UserQueryReq r = req();
  98 + r.setQueryField("employeeName");
  99 + r.setMatchMode("contains");
  100 + r.setQueryValue("张");
  101 + PageResult<UserListItemVo> result = service.list(r);
  102 + assertEquals(1, result.getTotal());
  103 + assertEquals("张三", result.getRecords().get(0).getEmployeeName());
  104 + }
  105 +
  106 + @Test
  107 + void list_queryByDepartmentName_multiLevelJoin() {
  108 + UserQueryReq r = req();
  109 + r.setQueryField("departmentName");
  110 + r.setMatchMode("equals");
  111 + r.setQueryValue("技术部");
  112 + PageResult<UserListItemVo> result = service.list(r);
  113 + assertEquals(1, result.getTotal());
  114 + assertEquals("技术部", result.getRecords().get(0).getDepartmentName());
  115 + }
  116 +
  117 + @Test
  118 + void list_queryByIsDeleted_true_returnsDeleted() {
  119 + UserQueryReq r = req();
  120 + r.setQueryField("isDeleted");
  121 + r.setMatchMode("equals");
  122 + r.setQueryValue("true");
  123 + PageResult<UserListItemVo> result = service.list(r);
  124 + assertEquals(1, result.getTotal());
  125 + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername());
  126 + }
  127 +
  128 + @Test
  129 + void list_queryByIsDeleted_invalidValue_throws40001() {
  130 + UserQueryReq r = req();
  131 + r.setQueryField("isDeleted");
  132 + r.setQueryValue("maybe");
  133 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  134 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  135 + }
  136 +
  137 + @Test
  138 + void list_queryFieldWithoutValue_skipsCondition() {
  139 + UserQueryReq r = req();
  140 + r.setQueryField("username");
  141 + // no queryValue
  142 + PageResult<UserListItemVo> result = service.list(r);
  143 + assertEquals(3, result.getTotal());
  144 + }
  145 +
  146 + @Test
  147 + void list_explicitUserTypeFilter() {
  148 + UserQueryReq r = req();
  149 + r.setUserType("NORMAL");
  150 + PageResult<UserListItemVo> result = service.list(r);
  151 + // alice (NORMAL active) + bob_deleted (NORMAL deleted) = 2
  152 + assertEquals(2, result.getTotal());
  153 + }
  154 +
  155 + @Test
  156 + void list_explicitUserTypeInvalid_throws40001() {
  157 + UserQueryReq r = req();
  158 + r.setUserType("HACKER");
  159 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  160 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  161 + }
  162 +
  163 + @Test
  164 + void list_explicitIsDeletedFalse_filtersActive() {
  165 + UserQueryReq r = req();
  166 + r.setIsDeleted(false);
  167 + PageResult<UserListItemVo> result = service.list(r);
  168 + // alice + admin
  169 + assertEquals(2, result.getTotal());
  170 + }
  171 +
  172 + @Test
  173 + void list_composedFilters_andSemantics() {
  174 + UserQueryReq r = req();
  175 + r.setQueryField("username");
  176 + r.setQueryValue("a");
  177 + r.setUserType("NORMAL");
  178 + r.setIsDeleted(false);
  179 + PageResult<UserListItemVo> result = service.list(r);
  180 + // alice 是唯一 NORMAL + 启用 + 名字含 a 的
  181 + assertEquals(1, result.getTotal());
  182 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  183 + }
  184 +
  185 + @Test
  186 + void list_pageBeyondTotal_returnsLastPage() {
  187 + UserQueryReq r = req();
  188 + r.setPage(999);
  189 + r.setSize(10);
  190 + PageResult<UserListItemVo> result = service.list(r);
  191 + assertEquals(3, result.getTotal());
  192 + assertFalse(result.getRecords().isEmpty(), "越界应返回最后一页数据");
  193 + assertEquals(1, result.getPage(), "actualPage 应矫正为最后一页 (3/10 → 1)");
  194 + }
  195 +
  196 + @Test
  197 + void list_queryByIsDeleted_matchModeContains_isForcedToEquals() {
  198 + // spec § 业务规则 3:isDeleted matchMode 强制 equals
  199 + UserQueryReq r = req();
  200 + r.setQueryField("isDeleted");
  201 + r.setMatchMode("contains");
  202 + r.setQueryValue("true");
  203 + PageResult<UserListItemVo> result = service.list(r);
  204 + // 应该等同于 equals true → 仅 bob_deleted
  205 + assertEquals(1, result.getTotal());
  206 + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername());
  207 + }
  208 +
  209 + @Test
  210 + void list_queryByLastLoginDate_matchModeContains_isForcedToEquals_andDateNormalized() {
  211 + // 给 alice 写一个明确的 lastLoginDate
  212 + jdbc.update("UPDATE sys_user SET tLastLoginDate='2026-05-15 10:00:00' WHERE sUsername=?",
  213 + LoginTestSeeder.USER_OK);
  214 +
  215 + UserQueryReq r = req();
  216 + r.setQueryField("lastLoginDate");
  217 + r.setMatchMode("contains");
  218 + r.setQueryValue("2026-05-15 10:00:00");
  219 + PageResult<UserListItemVo> result = service.list(r);
  220 + assertEquals(1, result.getTotal());
  221 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  222 + }
  223 +
  224 + @Test
  225 + void list_queryByLastLoginDate_invalidValue_throws40001() {
  226 + UserQueryReq r = req();
  227 + r.setQueryField("lastLoginDate");
  228 + r.setQueryValue("not-a-date");
  229 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  230 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  231 + }
  232 +
  233 + @org.springframework.beans.factory.annotation.Autowired
  234 + private org.springframework.jdbc.core.JdbcTemplate jdbc;
  235 +
  236 + @Test
  237 + void list_responseDoesNotIncludePasswordField() {
  238 + PageResult<UserListItemVo> result = service.list(req());
  239 + UserListItemVo vo = result.getRecords().get(0);
  240 + // UserListItemVo 不应有 password 相关字段——通过反射验证
  241 + for (java.lang.reflect.Field f : vo.getClass().getDeclaredFields()) {
  242 + assertFalse(f.getName().toLowerCase().contains("password"),
  243 + "VO 字段不应含 password: " + f.getName());
  244 + }
  245 + }
  246 +}
... ...