Commit 910a5ba7c504f2acf205aea6c01b310e774f78f5

Authored by zichun
2 parents 62aeb80f 07c91f41

Merge branch 'module-module_usr'

Showing 102 changed files with 11131 additions and 11 deletions

Too many changes to show.

To preserve performance only 72 of 102 files are displayed.

backend/.mvn/jvm.config 0 → 100644
  1 +-XX:+EnableDynamicAgentLoading
backend/checkstyle.xml 0 → 100644
  1 +<?xml version="1.0"?>
  2 +<!DOCTYPE module PUBLIC
  3 + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
  4 + "https://checkstyle.org/dtds/configuration_1_3.dtd">
  5 +<!--
  6 + 项目 checkstyle 规则(Spring Boot + Lombok 友好,不使用 sun_checks.xml 的过严规则)
  7 + 规则原则:检查明显的代码问题,不干预 Lombok 注解生成类、不要求 final 参数、不限制行长。
  8 +-->
  9 +<module name="Checker">
  10 + <property name="charset" value="UTF-8"/>
  11 + <property name="severity" value="error"/>
  12 + <property name="fileExtensions" value="java"/>
  13 +
  14 + <module name="TreeWalker">
  15 + <!-- 未使用的 import -->
  16 + <module name="UnusedImports"/>
  17 + <!-- 重复 import -->
  18 + <module name="RedundantImport"/>
  19 + <!-- 通配符 import(java.util.* 等)-->
  20 + <module name="AvoidStarImport"/>
  21 +
  22 + <!-- 左大括号位置 -->
  23 + <module name="LeftCurly"/>
  24 + <!-- 空代码块(空 catch 需有注释)-->
  25 + <module name="EmptyBlock">
  26 + <property name="option" value="text"/>
  27 + </module>
  28 +
  29 + <!-- switch 缺少 default -->
  30 + <module name="MissingSwitchDefault"/>
  31 +
  32 + <!-- == 比较 String 字面量 -->
  33 + <module name="StringLiteralEquality"/>
  34 +
  35 + <!-- 多余的分号 -->
  36 + <module name="EmptyStatement"/>
  37 +
  38 + <!-- 修饰符顺序 (public static final ...) -->
  39 + <module name="ModifierOrder"/>
  40 +
  41 + <!-- 不允许 System.out.println(生产代码用 logger)-->
  42 + <module name="Regexp">
  43 + <property name="format" value="System\.(out|err)\.print"/>
  44 + <property name="illegalPattern" value="true"/>
  45 + <property name="message" value="请使用 logger 而非 System.out/err.print"/>
  46 + </module>
  47 + </module>
  48 +</module>
backend/pom.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  5 + <modelVersion>4.0.0</modelVersion>
  6 +
  7 + <parent>
  8 + <groupId>org.springframework.boot</groupId>
  9 + <artifactId>spring-boot-starter-parent</artifactId>
  10 + <version>3.3.5</version>
  11 + <relativePath/>
  12 + </parent>
  13 +
  14 + <groupId>com.example</groupId>
  15 + <artifactId>erp</artifactId>
  16 + <version>0.0.1-SNAPSHOT</version>
  17 + <name>erp</name>
  18 + <description>小羚羊 ERP 后端</description>
  19 + <packaging>jar</packaging>
  20 +
  21 + <properties>
  22 + <java.version>21</java.version>
  23 + <lombok.version>1.18.36</lombok.version>
  24 + <mybatis-plus.version>3.5.7</mybatis-plus.version>
  25 + <jjwt.version>0.12.6</jjwt.version>
  26 + <hutool.version>5.8.28</hutool.version>
  27 + <flyway.version>10.17.0</flyway.version>
  28 + </properties>
  29 +
  30 + <dependencies>
  31 + <!-- Web -->
  32 + <dependency>
  33 + <groupId>org.springframework.boot</groupId>
  34 + <artifactId>spring-boot-starter-web</artifactId>
  35 + </dependency>
  36 +
  37 + <!-- Security -->
  38 + <dependency>
  39 + <groupId>org.springframework.boot</groupId>
  40 + <artifactId>spring-boot-starter-security</artifactId>
  41 + </dependency>
  42 +
  43 + <!-- Validation -->
  44 + <dependency>
  45 + <groupId>org.springframework.boot</groupId>
  46 + <artifactId>spring-boot-starter-validation</artifactId>
  47 + </dependency>
  48 +
  49 + <!-- MyBatis-Plus (Spring Boot 3) -->
  50 + <dependency>
  51 + <groupId>com.baomidou</groupId>
  52 + <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
  53 + <version>${mybatis-plus.version}</version>
  54 + </dependency>
  55 +
  56 + <!-- MySQL -->
  57 + <dependency>
  58 + <groupId>com.mysql</groupId>
  59 + <artifactId>mysql-connector-j</artifactId>
  60 + <scope>runtime</scope>
  61 + </dependency>
  62 +
  63 + <!-- Flyway -->
  64 + <dependency>
  65 + <groupId>org.flywaydb</groupId>
  66 + <artifactId>flyway-core</artifactId>
  67 + <version>${flyway.version}</version>
  68 + </dependency>
  69 + <dependency>
  70 + <groupId>org.flywaydb</groupId>
  71 + <artifactId>flyway-mysql</artifactId>
  72 + <version>${flyway.version}</version>
  73 + </dependency>
  74 +
  75 + <!-- JWT (JJWT 0.12.x) -->
  76 + <dependency>
  77 + <groupId>io.jsonwebtoken</groupId>
  78 + <artifactId>jjwt-api</artifactId>
  79 + <version>${jjwt.version}</version>
  80 + </dependency>
  81 + <dependency>
  82 + <groupId>io.jsonwebtoken</groupId>
  83 + <artifactId>jjwt-impl</artifactId>
  84 + <version>${jjwt.version}</version>
  85 + <scope>runtime</scope>
  86 + </dependency>
  87 + <dependency>
  88 + <groupId>io.jsonwebtoken</groupId>
  89 + <artifactId>jjwt-jackson</artifactId>
  90 + <version>${jjwt.version}</version>
  91 + <scope>runtime</scope>
  92 + </dependency>
  93 +
  94 + <!-- Lombok -->
  95 + <dependency>
  96 + <groupId>org.projectlombok</groupId>
  97 + <artifactId>lombok</artifactId>
  98 + <optional>true</optional>
  99 + </dependency>
  100 +
  101 + <!-- Hutool -->
  102 + <dependency>
  103 + <groupId>cn.hutool</groupId>
  104 + <artifactId>hutool-all</artifactId>
  105 + <version>${hutool.version}</version>
  106 + </dependency>
  107 +
  108 + <!-- Test -->
  109 + <dependency>
  110 + <groupId>org.springframework.boot</groupId>
  111 + <artifactId>spring-boot-starter-test</artifactId>
  112 + <scope>test</scope>
  113 + </dependency>
  114 + <dependency>
  115 + <groupId>org.springframework.security</groupId>
  116 + <artifactId>spring-security-test</artifactId>
  117 + <scope>test</scope>
  118 + </dependency>
  119 + </dependencies>
  120 +
  121 + <build>
  122 + <plugins>
  123 + <plugin>
  124 + <groupId>org.apache.maven.plugins</groupId>
  125 + <artifactId>maven-checkstyle-plugin</artifactId>
  126 + <configuration>
  127 + <configLocation>checkstyle.xml</configLocation>
  128 + <consoleOutput>true</consoleOutput>
  129 + <failsOnError>true</failsOnError>
  130 + <includeTestSourceDirectory>false</includeTestSourceDirectory>
  131 + </configuration>
  132 + </plugin>
  133 + <plugin>
  134 + <groupId>org.apache.maven.plugins</groupId>
  135 + <artifactId>maven-surefire-plugin</artifactId>
  136 + <configuration>
  137 + <argLine>
  138 + -XX:+EnableDynamicAgentLoading
  139 + -Dnet.bytebuddy.experimental=true
  140 + --add-opens java.base/java.lang=ALL-UNNAMED
  141 + --add-opens java.base/java.lang.invoke=ALL-UNNAMED
  142 + --add-opens java.base/java.util=ALL-UNNAMED
  143 + </argLine>
  144 + </configuration>
  145 + </plugin>
  146 + <plugin>
  147 + <groupId>org.springframework.boot</groupId>
  148 + <artifactId>spring-boot-maven-plugin</artifactId>
  149 + <configuration>
  150 + <excludes>
  151 + <exclude>
  152 + <groupId>org.projectlombok</groupId>
  153 + <artifactId>lombok</artifactId>
  154 + </exclude>
  155 + </excludes>
  156 + </configuration>
  157 + </plugin>
  158 + </plugins>
  159 + </build>
  160 +</project>
backend/src/main/java/com/example/erp/Application.java 0 → 100644
  1 +package com.example.erp;
  2 +
  3 +import org.springframework.boot.SpringApplication;
  4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
  5 +
  6 +@SpringBootApplication
  7 +public class Application {
  8 + public static void main(String[] args) {
  9 + SpringApplication.run(Application.class, args);
  10 + }
  11 +}
backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java 0 → 100644
  1 +package com.example.erp.common.constants;
  2 +
  3 +public final class AuthErrorCode {
  4 +
  5 + public static final int USERNAME_OR_PASSWORD_ERROR = 40100;
  6 + public static final int ACCOUNT_DISABLED = 40101;
  7 + public static final int ACCOUNT_LOCKED = 40102;
  8 + public static final int REFRESH_TOKEN_INVALID = 40103;
  9 +
  10 + private AuthErrorCode() {}
  11 +}
backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java 0 → 100644
  1 +package com.example.erp.common.constants;
  2 +
  3 +public final class UsrErrorCode {
  4 +
  5 + public static final int PERMISSION_DENIED = 40300;
  6 + public static final int SELF_ADMIN_CHANGE = 40301;
  7 + public static final int USERNAME_EXISTS = 40901;
  8 + public static final int USER_CODE_EXISTS = 40902;
  9 + public static final int EMPLOYEE_NOT_FOUND = 40001;
  10 + public static final int USER_NOT_FOUND = 40400;
  11 +
  12 + private UsrErrorCode() {}
  13 +}
backend/src/main/java/com/example/erp/common/exception/BizException.java 0 → 100644
  1 +package com.example.erp.common.exception;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +@Getter
  6 +public class BizException extends RuntimeException {
  7 +
  8 + private final int code;
  9 +
  10 + public BizException(int code, String message) {
  11 + super(message);
  12 + this.code = code;
  13 + }
  14 +}
backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java 0 → 100644
  1 +package com.example.erp.common.exception;
  2 +
  3 +import com.example.erp.common.response.Result;
  4 +import lombok.extern.slf4j.Slf4j;
  5 +import org.springframework.validation.FieldError;
  6 +import org.springframework.web.bind.MethodArgumentNotValidException;
  7 +import org.springframework.web.bind.annotation.ExceptionHandler;
  8 +import org.springframework.web.bind.annotation.RestControllerAdvice;
  9 +
  10 +@Slf4j
  11 +@RestControllerAdvice
  12 +public class GlobalExceptionHandler {
  13 +
  14 + @ExceptionHandler(BizException.class)
  15 + public Result<Void> handleBizException(BizException e) {
  16 + return Result.fail(e.getCode(), e.getMessage());
  17 + }
  18 +
  19 + @ExceptionHandler(MethodArgumentNotValidException.class)
  20 + public Result<Void> handleValidation(MethodArgumentNotValidException e) {
  21 + FieldError fe = e.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);
  22 + String msg = fe != null ? fe.getDefaultMessage() : "参数校验失败";
  23 + return Result.fail(40001, msg);
  24 + }
  25 +
  26 + @ExceptionHandler(Exception.class)
  27 + public Result<Void> handleException(Exception e) {
  28 + log.error("未处理异常", e);
  29 + return Result.fail(99000, "系统内部错误");
  30 + }
  31 +}
backend/src/main/java/com/example/erp/common/response/Result.java 0 → 100644
  1 +package com.example.erp.common.response;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +@Getter
  6 +public class Result<T> {
  7 +
  8 + private final int code;
  9 + private final String message;
  10 + private final T data;
  11 + private final long timestamp;
  12 +
  13 + private Result(int code, String message, T data) {
  14 + this.code = code;
  15 + this.message = message;
  16 + this.data = data;
  17 + this.timestamp = System.currentTimeMillis();
  18 + }
  19 +
  20 + public static <T> Result<T> ok(T data) {
  21 + return new Result<>(200, "操作成功", data);
  22 + }
  23 +
  24 + public static <T> Result<T> fail(int code, String message) {
  25 + return new Result<>(code, message, null);
  26 + }
  27 +}
backend/src/main/java/com/example/erp/common/util/JwtUtil.java 0 → 100644
  1 +package com.example.erp.common.util;
  2 +
  3 +import com.example.erp.common.constants.AuthErrorCode;
  4 +import com.example.erp.common.exception.BizException;
  5 +import com.example.erp.config.JwtProperties;
  6 +import io.jsonwebtoken.Claims;
  7 +import io.jsonwebtoken.JwtException;
  8 +import io.jsonwebtoken.Jwts;
  9 +import io.jsonwebtoken.security.Keys;
  10 +import lombok.RequiredArgsConstructor;
  11 +import org.springframework.stereotype.Component;
  12 +
  13 +import javax.crypto.SecretKey;
  14 +import java.nio.charset.StandardCharsets;
  15 +import java.util.Date;
  16 +
  17 +@Component
  18 +@RequiredArgsConstructor
  19 +public class JwtUtil {
  20 +
  21 + private final JwtProperties properties;
  22 +
  23 + private SecretKey key() {
  24 + return Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
  25 + }
  26 +
  27 + public String generateAccessToken(String userId, String username, String userType, String brandId) {
  28 + long now = System.currentTimeMillis();
  29 + return Jwts.builder()
  30 + .subject(userId)
  31 + .claim("username", username)
  32 + .claim("userType", userType)
  33 + .claim("brandId", brandId)
  34 + .issuedAt(new Date(now))
  35 + .expiration(new Date(now + properties.getAccessTokenExpiry() * 1000))
  36 + .signWith(key(), Jwts.SIG.HS256)
  37 + .compact();
  38 + }
  39 +
  40 + public String generateRefreshToken(String userId, String brandId) {
  41 + long now = System.currentTimeMillis();
  42 + return Jwts.builder()
  43 + .subject(userId)
  44 + .claim("brandId", brandId)
  45 + .claim("type", "refresh")
  46 + .issuedAt(new Date(now))
  47 + .expiration(new Date(now + properties.getRefreshTokenExpiry() * 1000))
  48 + .signWith(key(), Jwts.SIG.HS256)
  49 + .compact();
  50 + }
  51 +
  52 + public Claims parseAccessToken(String token) {
  53 + return doParse(token);
  54 + }
  55 +
  56 + public Claims parseRefreshToken(String token) {
  57 + Claims claims = doParse(token);
  58 + if (!"refresh".equals(claims.get("type", String.class))) {
  59 + throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录");
  60 + }
  61 + return claims;
  62 + }
  63 +
  64 + private Claims doParse(String token) {
  65 + try {
  66 + return Jwts.parser()
  67 + .verifyWith(key())
  68 + .build()
  69 + .parseSignedClaims(token)
  70 + .getPayload();
  71 + } catch (JwtException e) {
  72 + throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Token 已失效,请重新登录");
  73 + }
  74 + }
  75 +}
backend/src/main/java/com/example/erp/common/vo/PageVO.java 0 → 100644
  1 +package com.example.erp.common.vo;
  2 +
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +import java.util.List;
  8 +
  9 +@Getter
  10 +@Setter
  11 +public class PageVO<T> {
  12 +
  13 + private long total;
  14 + private long page;
  15 + private long pageSize;
  16 + private List<T> list;
  17 +
  18 + public static <T> PageVO<T> of(IPage<T> iPage) {
  19 + PageVO<T> vo = new PageVO<>();
  20 + vo.total = iPage.getTotal();
  21 + vo.page = iPage.getCurrent();
  22 + vo.pageSize = iPage.getSize();
  23 + vo.list = iPage.getRecords();
  24 + return vo;
  25 + }
  26 +}
backend/src/main/java/com/example/erp/config/BeanConfig.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  6 +
  7 +@Configuration
  8 +public class BeanConfig {
  9 +
  10 + @Bean
  11 + public BCryptPasswordEncoder passwordEncoder() {
  12 + return new BCryptPasswordEncoder();
  13 + }
  14 +}
backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.common.util.JwtUtil;
  5 +import io.jsonwebtoken.Claims;
  6 +import jakarta.servlet.FilterChain;
  7 +import jakarta.servlet.ServletException;
  8 +import jakarta.servlet.http.HttpServletRequest;
  9 +import jakarta.servlet.http.HttpServletResponse;
  10 +import lombok.RequiredArgsConstructor;
  11 +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  12 +import org.springframework.security.core.context.SecurityContextHolder;
  13 +import org.springframework.stereotype.Component;
  14 +import org.springframework.util.StringUtils;
  15 +import org.springframework.web.filter.OncePerRequestFilter;
  16 +
  17 +import java.io.IOException;
  18 +import java.util.Collections;
  19 +
  20 +@Component
  21 +@RequiredArgsConstructor
  22 +public class JwtAuthenticationFilter extends OncePerRequestFilter {
  23 +
  24 + private final JwtUtil jwtUtil;
  25 +
  26 + @Override
  27 + protected void doFilterInternal(HttpServletRequest request,
  28 + HttpServletResponse response,
  29 + FilterChain chain) throws ServletException, IOException {
  30 + String header = request.getHeader("Authorization");
  31 + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
  32 + String token = header.substring(7);
  33 + try {
  34 + Claims claims = jwtUtil.parseAccessToken(token);
  35 + UserPrincipal principal = new UserPrincipal(
  36 + claims.getSubject(),
  37 + claims.get("username", String.class),
  38 + claims.get("userType", String.class),
  39 + claims.get("brandId", String.class));
  40 + UsernamePasswordAuthenticationToken auth =
  41 + new UsernamePasswordAuthenticationToken(
  42 + principal, null, Collections.emptyList());
  43 + SecurityContextHolder.getContext().setAuthentication(auth);
  44 + } catch (BizException ignored) {
  45 + // invalid token — no auth set; Spring Security will return 401
  46 + }
  47 + }
  48 + chain.doFilter(request, response);
  49 + }
  50 +}
backend/src/main/java/com/example/erp/config/JwtProperties.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +import org.springframework.boot.context.properties.ConfigurationProperties;
  6 +import org.springframework.stereotype.Component;
  7 +
  8 +@Getter
  9 +@Setter
  10 +@Component
  11 +@ConfigurationProperties(prefix = "jwt")
  12 +public class JwtProperties {
  13 + private String secret;
  14 + private long accessTokenExpiry;
  15 + private long refreshTokenExpiry;
  16 +}
backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.DbType;
  4 +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
  5 +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
  6 +import org.mybatis.spring.annotation.MapperScan;
  7 +import org.springframework.context.annotation.Bean;
  8 +import org.springframework.context.annotation.Configuration;
  9 +
  10 +@Configuration
  11 +@MapperScan("com.example.erp.module.*.mapper")
  12 +public class MyBatisPlusConfig {
  13 +
  14 + @Bean
  15 + public MybatisPlusInterceptor mybatisPlusInterceptor() {
  16 + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
  17 + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
  18 + return interceptor;
  19 + }
  20 +}
backend/src/main/java/com/example/erp/config/SecurityConfig.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import jakarta.servlet.http.HttpServletResponse;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.springframework.context.annotation.Bean;
  6 +import org.springframework.context.annotation.Configuration;
  7 +import org.springframework.security.config.annotation.web.builders.HttpSecurity;
  8 +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
  9 +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
  10 +import org.springframework.security.config.http.SessionCreationPolicy;
  11 +import org.springframework.security.web.SecurityFilterChain;
  12 +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
  13 +
  14 +@Configuration
  15 +@EnableWebSecurity
  16 +@RequiredArgsConstructor
  17 +public class SecurityConfig {
  18 +
  19 + private final JwtAuthenticationFilter jwtAuthenticationFilter;
  20 +
  21 + @Bean
  22 + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  23 + http
  24 + .csrf(AbstractHttpConfigurer::disable)
  25 + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  26 + .authorizeHttpRequests(auth -> auth
  27 + .requestMatchers("/api/auth/**").permitAll()
  28 + .anyRequest().authenticated()
  29 + )
  30 + .exceptionHandling(ex -> ex
  31 + .authenticationEntryPoint((request, response, authException) ->
  32 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))
  33 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
  34 + return http.build();
  35 + }
  36 +}
backend/src/main/java/com/example/erp/config/UserPrincipal.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +public record UserPrincipal(String userId, String username, String userType, String brandId) {}
backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java 0 → 100644
  1 +package com.example.erp.module.usr.controller;
  2 +
  3 +import com.example.erp.common.response.Result;
  4 +import com.example.erp.module.usr.dto.LoginReqDTO;
  5 +import com.example.erp.module.usr.dto.RefreshTokenReqDTO;
  6 +import com.example.erp.module.usr.service.AuthService;
  7 +import com.example.erp.module.usr.vo.BrandVO;
  8 +import com.example.erp.module.usr.vo.LoginVO;
  9 +import jakarta.validation.Valid;
  10 +import lombok.RequiredArgsConstructor;
  11 +import org.springframework.web.bind.annotation.GetMapping;
  12 +import org.springframework.web.bind.annotation.PostMapping;
  13 +import org.springframework.web.bind.annotation.RequestBody;
  14 +import org.springframework.web.bind.annotation.RequestMapping;
  15 +import org.springframework.web.bind.annotation.RestController;
  16 +
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +
  20 +@RestController
  21 +@RequestMapping("/api/auth")
  22 +@RequiredArgsConstructor
  23 +public class AuthController {
  24 +
  25 + private final AuthService authService;
  26 +
  27 + @PostMapping("/login")
  28 + public Result<LoginVO> login(@Valid @RequestBody LoginReqDTO req) {
  29 + return Result.ok(authService.login(req));
  30 + }
  31 +
  32 + @PostMapping("/refresh")
  33 + public Result<Map<String, String>> refresh(@Valid @RequestBody RefreshTokenReqDTO req) {
  34 + String newToken = authService.refresh(req.getRefreshToken());
  35 + return Result.ok(Map.of("accessToken", newToken));
  36 + }
  37 +
  38 + @GetMapping("/brands")
  39 + public Result<List<BrandVO>> brands() {
  40 + return Result.ok(authService.getBrands());
  41 + }
  42 +}
backend/src/main/java/com/example/erp/module/usr/controller/UserController.java 0 → 100644
  1 +package com.example.erp.module.usr.controller;
  2 +
  3 +import com.example.erp.common.response.Result;
  4 +import com.example.erp.common.vo.PageVO;
  5 +import com.example.erp.config.UserPrincipal;
  6 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  7 +import com.example.erp.module.usr.dto.UserListQueryDTO;
  8 +import com.example.erp.module.usr.dto.UserUpdateReqDTO;
  9 +import com.example.erp.module.usr.service.UserService;
  10 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  11 +import com.example.erp.module.usr.vo.StaffVO;
  12 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  13 +import com.example.erp.module.usr.vo.UserListItemVO;
  14 +import com.example.erp.module.usr.vo.UserUpdateRespVO;
  15 +import jakarta.validation.Valid;
  16 +import lombok.RequiredArgsConstructor;
  17 +import org.springframework.security.core.annotation.AuthenticationPrincipal;
  18 +import org.springframework.web.bind.annotation.GetMapping;
  19 +import org.springframework.web.bind.annotation.PathVariable;
  20 +import org.springframework.web.bind.annotation.PostMapping;
  21 +import org.springframework.web.bind.annotation.PutMapping;
  22 +import org.springframework.web.bind.annotation.RequestBody;
  23 +import org.springframework.web.bind.annotation.RequestMapping;
  24 +import org.springframework.web.bind.annotation.RequestParam;
  25 +import org.springframework.web.bind.annotation.RestController;
  26 +
  27 +import java.util.List;
  28 +
  29 +@RestController
  30 +@RequestMapping("/api/usr")
  31 +@RequiredArgsConstructor
  32 +public class UserController {
  33 +
  34 + private final UserService userService;
  35 +
  36 + @PostMapping("/users")
  37 + public Result<UserCreateRespVO> createUser(
  38 + @Valid @RequestBody UserCreateReqDTO req,
  39 + @AuthenticationPrincipal UserPrincipal principal) {
  40 + return Result.ok(userService.createUser(req, principal));
  41 + }
  42 +
  43 + // REQ-USR-003: 查询用户
  44 + @GetMapping("/users")
  45 + public Result<PageVO<UserListItemVO>> getUsers(
  46 + @RequestParam(defaultValue = "username") String queryField,
  47 + @RequestParam(defaultValue = "contains") String matchType,
  48 + @RequestParam(defaultValue = "") String queryValue,
  49 + @RequestParam(defaultValue = "1") int page,
  50 + @RequestParam(defaultValue = "20") int pageSize,
  51 + @AuthenticationPrincipal UserPrincipal principal) {
  52 + UserListQueryDTO q = new UserListQueryDTO();
  53 + q.setQueryField(queryField);
  54 + q.setMatchType(matchType);
  55 + q.setQueryValue(queryValue);
  56 + q.setPage(page);
  57 + q.setPageSize(pageSize);
  58 + return Result.ok(userService.getUserList(q, principal.brandId()));
  59 + }
  60 +
  61 + // REQ-USR-002: 修改用户
  62 + @PutMapping("/users/{userId}")
  63 + public Result<UserUpdateRespVO> updateUser(
  64 + @PathVariable String userId,
  65 + @Valid @RequestBody UserUpdateReqDTO req,
  66 + @AuthenticationPrincipal UserPrincipal principal) {
  67 + return Result.ok(userService.updateUser(userId, req, principal));
  68 + }
  69 +
  70 + @GetMapping("/users/staffs")
  71 + public Result<List<StaffVO>> getStaffs(@AuthenticationPrincipal UserPrincipal principal) {
  72 + return Result.ok(userService.getStaffs(principal.brandId()));
  73 + }
  74 +
  75 + @GetMapping("/users/permission-groups")
  76 + public Result<List<PermissionGroupVO>> getPermissionGroups(
  77 + @AuthenticationPrincipal UserPrincipal principal) {
  78 + return Result.ok(userService.getPermissionGroups(principal.brandId()));
  79 + }
  80 +}
backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class LoginReqDTO {
  10 +
  11 + @NotBlank(message = "公司编号不能为空")
  12 + private String brandNo;
  13 +
  14 + @NotBlank(message = "用户名不能为空")
  15 + private String username;
  16 +
  17 + @NotBlank(message = "密码不能为空")
  18 + private String password;
  19 +}
backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class RefreshTokenReqDTO {
  10 +
  11 + @NotBlank(message = "refreshToken 不能为空")
  12 + private String refreshToken;
  13 +}
backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import lombok.Getter;
  6 +import lombok.Setter;
  7 +
  8 +import java.util.List;
  9 +
  10 +@Getter
  11 +@Setter
  12 +public class UserCreateReqDTO {
  13 +
  14 + @NotBlank(message = "用户号不能为空")
  15 + private String userCode;
  16 +
  17 + @NotBlank(message = "用户名不能为空")
  18 + private String username;
  19 +
  20 + @NotBlank(message = "用户类型不能为空")
  21 + @Pattern(regexp = "普通用户|超级管理员", message = "用户类型无效")
  22 + private String userType;
  23 +
  24 + @NotBlank(message = "语言不能为空")
  25 + @Pattern(regexp = "中文|英文|繁体", message = "语言无效")
  26 + private String language;
  27 +
  28 + private boolean canEditDoc = false;
  29 +
  30 + private String employeeId;
  31 +
  32 + private List<String> permGroupIds;
  33 +}
backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class UserListQueryDTO {
  9 +
  10 + private String queryField = "username";
  11 + private String matchType = "contains";
  12 + private String queryValue;
  13 + private int page = 1;
  14 + private int pageSize = 20;
  15 +}
backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java 0 → 100644
  1 +package com.example.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 lombok.Getter;
  7 +import lombok.Setter;
  8 +
  9 +import java.util.List;
  10 +
  11 +@Getter
  12 +@Setter
  13 +public class UserUpdateReqDTO {
  14 +
  15 + @NotBlank(message = "用户类型不能为空")
  16 + @Pattern(regexp = "普通用户|超级管理员", message = "用户类型无效")
  17 + private String userType;
  18 +
  19 + @NotBlank(message = "语言不能为空")
  20 + @Pattern(regexp = "中文|英文|繁体", message = "语言无效")
  21 + private String language;
  22 + private boolean canEditDoc;
  23 + private boolean isDisabled;
  24 + private String employeeId;
  25 + @NotNull(message = "权限组列表不能为空")
  26 + private List<String> permGroupIds;
  27 +}
backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("brand")
  15 +public class BrandEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sName")
  33 + private String sName;
  34 +
  35 + @TableField("sShortName")
  36 + private String sShortName;
  37 +
  38 + @TableField("sNo")
  39 + private String sNo;
  40 +}
backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("usr_permission_group")
  15 +public class PermissionGroupEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sGroupCode")
  33 + private String sGroupCode;
  34 +
  35 + @TableField("sGroupName")
  36 + private String sGroupName;
  37 +
  38 + @TableField("sCategory")
  39 + private String sCategory;
  40 +}
backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("tStaff")
  15 +public class StaffEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sStaffNo")
  33 + private String sStaffNo;
  34 +
  35 + @TableField("sStaffName")
  36 + private String sStaffName;
  37 +
  38 + @TableField("sDepartment")
  39 + private String sDepartment;
  40 +
  41 + @TableField("sCreatedBy")
  42 + private String sCreatedBy;
  43 +
  44 + @TableField("bDeleted")
  45 + private Integer bDeleted;
  46 +
  47 + @TableField("tDeletedDate")
  48 + private LocalDateTime tDeletedDate;
  49 +
  50 + @TableField("sDeletedBy")
  51 + private String sDeletedBy;
  52 +}
backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("usr_user_permission")
  15 +public class UserPermissionEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sUserId")
  33 + private String sUserId;
  34 +
  35 + @TableField("sPermGroupId")
  36 + private String sPermGroupId;
  37 +}
backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("usr_user")
  15 +public class UsrUserEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sUserCode")
  33 + private String sUserCode;
  34 +
  35 + @TableField("sUsername")
  36 + private String sUsername;
  37 +
  38 + @TableField("sPasswordHash")
  39 + private String sPasswordHash;
  40 +
  41 + @TableField("sUserType")
  42 + private String sUserType;
  43 +
  44 + @TableField("sLanguage")
  45 + private String sLanguage;
  46 +
  47 + @TableField("bCanEditDoc")
  48 + private Integer bCanEditDoc;
  49 +
  50 + @TableField("bIsDisabled")
  51 + private Integer bIsDisabled;
  52 +
  53 + @TableField("sEmployeeId")
  54 + private String sEmployeeId;
  55 +
  56 + @TableField("sCreatorUsername")
  57 + private String sCreatorUsername;
  58 +
  59 + @TableField("tLastLoginDate")
  60 + private LocalDateTime tLastLoginDate;
  61 +
  62 + @TableField("iLoginFailCount")
  63 + private Integer iLoginFailCount;
  64 +
  65 + @TableField("tLockUntil")
  66 + private LocalDateTime tLockUntil;
  67 +}
backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.BrandEntity;
  5 +
  6 +public interface BrandMapper extends BaseMapper<BrandEntity> {
  7 +}
backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.PermissionGroupEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface PermissionGroupMapper extends BaseMapper<PermissionGroupEntity> {}
backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.StaffEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface StaffMapper extends BaseMapper<StaffEntity> {}
backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface UserPermissionMapper extends BaseMapper<UserPermissionEntity> {}
backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
  4 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  5 +import com.example.erp.module.usr.entity.UsrUserEntity;
  6 +import com.example.erp.module.usr.vo.UserListItemVO;
  7 +import org.apache.ibatis.annotations.Param;
  8 +
  9 +public interface UsrUserMapper extends BaseMapper<UsrUserEntity> {
  10 +
  11 + IPage<UserListItemVO> selectUserList(
  12 + IPage<UserListItemVO> page,
  13 + @Param("brandId") String brandId,
  14 + @Param("queryField") String queryField,
  15 + @Param("matchType") String matchType,
  16 + @Param("queryValue") String queryValue
  17 + );
  18 +}
backend/src/main/java/com/example/erp/module/usr/service/AuthService.java 0 → 100644
  1 +package com.example.erp.module.usr.service;
  2 +
  3 +import com.example.erp.module.usr.dto.LoginReqDTO;
  4 +import com.example.erp.module.usr.vo.BrandVO;
  5 +import com.example.erp.module.usr.vo.LoginVO;
  6 +
  7 +import java.util.List;
  8 +
  9 +public interface AuthService {
  10 +
  11 + LoginVO login(LoginReqDTO req);
  12 +
  13 + String refresh(String refreshToken);
  14 +
  15 + List<BrandVO> getBrands();
  16 +}
backend/src/main/java/com/example/erp/module/usr/service/UserService.java 0 → 100644
  1 +package com.example.erp.module.usr.service;
  2 +
  3 +import com.example.erp.common.vo.PageVO;
  4 +import com.example.erp.config.UserPrincipal;
  5 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  6 +import com.example.erp.module.usr.dto.UserListQueryDTO;
  7 +import com.example.erp.module.usr.dto.UserUpdateReqDTO;
  8 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  9 +import com.example.erp.module.usr.vo.StaffVO;
  10 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  11 +import com.example.erp.module.usr.vo.UserListItemVO;
  12 +import com.example.erp.module.usr.vo.UserUpdateRespVO;
  13 +
  14 +import java.util.List;
  15 +
  16 +public interface UserService {
  17 +
  18 + UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal);
  19 +
  20 + List<StaffVO> getStaffs(String brandId);
  21 +
  22 + List<PermissionGroupVO> getPermissionGroups(String brandId);
  23 +
  24 + PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId);
  25 +
  26 + UserUpdateRespVO updateUser(String userId, UserUpdateReqDTO req, UserPrincipal principal);
  27 +}
backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java 0 → 100644
  1 +package com.example.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  5 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  6 +import com.example.erp.common.constants.AuthErrorCode;
  7 +import com.example.erp.common.exception.BizException;
  8 +import com.example.erp.common.util.JwtUtil;
  9 +import com.example.erp.module.usr.dto.LoginReqDTO;
  10 +import com.example.erp.module.usr.entity.BrandEntity;
  11 +import com.example.erp.module.usr.entity.UsrUserEntity;
  12 +import com.example.erp.module.usr.mapper.BrandMapper;
  13 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  14 +import com.example.erp.module.usr.service.AuthService;
  15 +import com.example.erp.module.usr.vo.BrandVO;
  16 +import com.example.erp.module.usr.vo.LoginVO;
  17 +import io.jsonwebtoken.Claims;
  18 +import lombok.RequiredArgsConstructor;
  19 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  20 +import org.springframework.stereotype.Service;
  21 +import org.springframework.transaction.annotation.Transactional;
  22 +
  23 +import java.time.LocalDateTime;
  24 +import java.time.temporal.ChronoUnit;
  25 +import java.util.List;
  26 +import java.util.stream.Collectors;
  27 +
  28 +@Service
  29 +@RequiredArgsConstructor
  30 +public class AuthServiceImpl implements AuthService {
  31 +
  32 + private final BrandMapper brandMapper;
  33 + private final UsrUserMapper userMapper;
  34 + private final JwtUtil jwtUtil;
  35 + private final BCryptPasswordEncoder passwordEncoder;
  36 +
  37 + @Override
  38 + @Transactional
  39 + public LoginVO login(LoginReqDTO req) {
  40 + // 1. 查 brand
  41 + BrandEntity brand = brandMapper.selectOne(
  42 + new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo()));
  43 + if (brand == null) {
  44 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  45 + }
  46 +
  47 + // 2. 查 user(多租户)
  48 + UsrUserEntity user = userMapper.selectOne(
  49 + new LambdaQueryWrapper<UsrUserEntity>()
  50 + .eq(UsrUserEntity::getSUsername, req.getUsername())
  51 + .eq(UsrUserEntity::getSBrandsId, brand.getSId()));
  52 + if (user == null) {
  53 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  54 + }
  55 +
  56 + // 3. 禁用检查
  57 + if (Integer.valueOf(1).equals(user.getBIsDisabled())) {
  58 + throw new BizException(AuthErrorCode.ACCOUNT_DISABLED, "账号已被禁用,请联系管理员");
  59 + }
  60 +
  61 + // 4. 锁定检查
  62 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
  63 + long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil());
  64 + int minutes = (int) Math.ceil(seconds / 60.0);
  65 + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 " + minutes + " 分钟后重试");
  66 + }
  67 +
  68 + // 5. 密码校验
  69 + if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) {
  70 + int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1;
  71 + LambdaUpdateWrapper<UsrUserEntity> updateWrapper = new LambdaUpdateWrapper<UsrUserEntity>()
  72 + .eq(UsrUserEntity::getSId, user.getSId())
  73 + .set(UsrUserEntity::getILoginFailCount, newCount);
  74 + if (newCount >= 5) {
  75 + LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30);
  76 + updateWrapper.set(UsrUserEntity::getTLockUntil, lockUntil);
  77 + userMapper.update(null, updateWrapper);
  78 + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试");
  79 + }
  80 + userMapper.update(null, updateWrapper);
  81 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  82 + }
  83 +
  84 + // 6. 登录成功
  85 + userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>()
  86 + .eq(UsrUserEntity::getSId, user.getSId())
  87 + .set(UsrUserEntity::getILoginFailCount, 0)
  88 + .set(UsrUserEntity::getTLockUntil, null)
  89 + .set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now()));
  90 +
  91 + String accessToken = jwtUtil.generateAccessToken(
  92 + user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId());
  93 + String refreshToken = jwtUtil.generateRefreshToken(user.getSId(), brand.getSId());
  94 +
  95 + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO();
  96 + userInfo.setUserId(user.getSId());
  97 + userInfo.setUsername(user.getSUsername());
  98 + userInfo.setUserType(user.getSUserType());
  99 + userInfo.setLanguage(user.getSLanguage());
  100 + userInfo.setBrandId(brand.getSId());
  101 +
  102 + LoginVO vo = new LoginVO();
  103 + vo.setAccessToken(accessToken);
  104 + vo.setRefreshToken(refreshToken);
  105 + vo.setExpiresIn(86400L);
  106 + vo.setUserInfo(userInfo);
  107 + return vo;
  108 + }
  109 +
  110 + @Override
  111 + public String refresh(String refreshToken) {
  112 + Claims claims = jwtUtil.parseRefreshToken(refreshToken);
  113 + String userId = claims.getSubject();
  114 + String brandId = claims.get("brandId", String.class);
  115 +
  116 + UsrUserEntity user = userMapper.selectOne(
  117 + new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId));
  118 + if (user == null
  119 + || Integer.valueOf(1).equals(user.getBIsDisabled())
  120 + || (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now()))) {
  121 + throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录");
  122 + }
  123 +
  124 + return jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId);
  125 + }
  126 +
  127 + @Override
  128 + public List<BrandVO> getBrands() {
  129 + List<BrandEntity> brands = brandMapper.selectList(
  130 + new QueryWrapper<BrandEntity>().select("sNo", "sName").orderByAsc("sName"));
  131 + return brands.stream().map(b -> {
  132 + BrandVO vo = new BrandVO();
  133 + vo.setSNo(b.getSNo());
  134 + vo.setSName(b.getSName());
  135 + return vo;
  136 + }).collect(Collectors.toList());
  137 + }
  138 +}
backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java 0 → 100644
  1 +package com.example.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  5 +import com.baomidou.mybatisplus.core.metadata.IPage;
  6 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  7 +import com.example.erp.common.constants.UsrErrorCode;
  8 +import com.example.erp.common.exception.BizException;
  9 +import com.example.erp.common.vo.PageVO;
  10 +import com.example.erp.config.UserPrincipal;
  11 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  12 +import com.example.erp.module.usr.dto.UserListQueryDTO;
  13 +import com.example.erp.module.usr.dto.UserUpdateReqDTO;
  14 +import com.example.erp.module.usr.entity.PermissionGroupEntity;
  15 +import com.example.erp.module.usr.entity.StaffEntity;
  16 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  17 +import com.example.erp.module.usr.entity.UsrUserEntity;
  18 +import com.example.erp.module.usr.mapper.PermissionGroupMapper;
  19 +import com.example.erp.module.usr.mapper.StaffMapper;
  20 +import com.example.erp.module.usr.mapper.UserPermissionMapper;
  21 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  22 +import com.example.erp.module.usr.service.UserService;
  23 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  24 +import com.example.erp.module.usr.vo.StaffVO;
  25 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  26 +import com.example.erp.module.usr.vo.UserListItemVO;
  27 +import com.example.erp.module.usr.vo.UserUpdateRespVO;
  28 +import lombok.RequiredArgsConstructor;
  29 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  30 +import org.springframework.stereotype.Service;
  31 +import org.springframework.transaction.annotation.Transactional;
  32 +
  33 +import java.time.LocalDateTime;
  34 +import java.util.List;
  35 +import java.util.UUID;
  36 +import java.util.stream.Collectors;
  37 +
  38 +@Service
  39 +@RequiredArgsConstructor
  40 +public class UserServiceImpl implements UserService {
  41 +
  42 + private final UsrUserMapper userMapper;
  43 + private final StaffMapper staffMapper;
  44 + private final PermissionGroupMapper permGroupMapper;
  45 + private final UserPermissionMapper userPermissionMapper;
  46 + private final BCryptPasswordEncoder passwordEncoder;
  47 +
  48 + @Override
  49 + @Transactional
  50 + public UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal) {
  51 + if (!"超级管理员".equals(principal.userType())) {
  52 + throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足");
  53 + }
  54 +
  55 + if (userMapper.selectCount(new LambdaQueryWrapper<UsrUserEntity>()
  56 + .eq(UsrUserEntity::getSUserCode, req.getUserCode())) > 0) {
  57 + throw new BizException(UsrErrorCode.USER_CODE_EXISTS, "用户号已存在");
  58 + }
  59 +
  60 + if (userMapper.selectCount(new LambdaQueryWrapper<UsrUserEntity>()
  61 + .eq(UsrUserEntity::getSUsername, req.getUsername())
  62 + .eq(UsrUserEntity::getSBrandsId, principal.brandId())) > 0) {
  63 + throw new BizException(UsrErrorCode.USERNAME_EXISTS, "用户名已存在");
  64 + }
  65 +
  66 + if (req.getEmployeeId() != null) {
  67 + StaffEntity staff = staffMapper.selectOne(new LambdaQueryWrapper<StaffEntity>()
  68 + .eq(StaffEntity::getSId, req.getEmployeeId())
  69 + .eq(StaffEntity::getSBrandsId, principal.brandId()));
  70 + if (staff == null) {
  71 + throw new BizException(UsrErrorCode.EMPLOYEE_NOT_FOUND, "员工不存在");
  72 + }
  73 + }
  74 +
  75 + UsrUserEntity user = new UsrUserEntity();
  76 + user.setSId(UUID.randomUUID().toString());
  77 + user.setSBrandsId(principal.brandId());
  78 + user.setSCreatorUsername(principal.username());
  79 + user.setTCreateDate(LocalDateTime.now());
  80 + user.setSUserCode(req.getUserCode());
  81 + user.setSUsername(req.getUsername());
  82 + user.setSPasswordHash(passwordEncoder.encode("666666"));
  83 + user.setSUserType(req.getUserType());
  84 + user.setSLanguage(req.getLanguage());
  85 + user.setBCanEditDoc(req.isCanEditDoc() ? 1 : 0);
  86 + user.setBIsDisabled(0);
  87 + user.setSEmployeeId(req.getEmployeeId());
  88 + user.setILoginFailCount(0);
  89 + userMapper.insert(user);
  90 +
  91 + if (req.getPermGroupIds() != null && !req.getPermGroupIds().isEmpty()) {
  92 + List<UserPermissionEntity> perms = req.getPermGroupIds().stream()
  93 + .map(groupId -> {
  94 + UserPermissionEntity perm = new UserPermissionEntity();
  95 + perm.setSId(UUID.randomUUID().toString());
  96 + perm.setSBrandsId(principal.brandId());
  97 + perm.setTCreateDate(LocalDateTime.now());
  98 + perm.setSUserId(user.getSId());
  99 + perm.setSPermGroupId(groupId);
  100 + return perm;
  101 + })
  102 + .collect(Collectors.toList());
  103 + userPermissionMapper.insert(perms);
  104 + }
  105 +
  106 + UserCreateRespVO vo = new UserCreateRespVO();
  107 + vo.setUserId(user.getSId());
  108 + vo.setUserCode(user.getSUserCode());
  109 + vo.setUsername(user.getSUsername());
  110 + return vo;
  111 + }
  112 +
  113 + @Override
  114 + @Transactional(readOnly = true)
  115 + public List<StaffVO> getStaffs(String brandId) {
  116 + List<StaffEntity> staffs = staffMapper.selectList(new LambdaQueryWrapper<StaffEntity>()
  117 + .eq(StaffEntity::getSBrandsId, brandId)
  118 + .eq(StaffEntity::getBDeleted, 0));
  119 + return staffs.stream().map(s -> {
  120 + StaffVO vo = new StaffVO();
  121 + vo.setSId(s.getSId());
  122 + vo.setSStaffName(s.getSStaffName());
  123 + return vo;
  124 + }).collect(Collectors.toList());
  125 + }
  126 +
  127 + // REQ-USR-003: 查询用户
  128 + @Override
  129 + @Transactional(readOnly = true)
  130 + public PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId) {
  131 + int cappedSize = Math.min(query.getPageSize(), 100);
  132 + IPage<UserListItemVO> iPage = new Page<>(query.getPage(), cappedSize);
  133 + String queryValue = query.getQueryValue() == null ? "" : query.getQueryValue();
  134 + IPage<UserListItemVO> result = userMapper.selectUserList(iPage, brandId, query.getQueryField(), query.getMatchType(), queryValue);
  135 + return PageVO.of(result);
  136 + }
  137 +
  138 + @Override
  139 + @Transactional(readOnly = true)
  140 + public List<PermissionGroupVO> getPermissionGroups(String brandId) {
  141 + List<PermissionGroupEntity> groups = permGroupMapper.selectList(new LambdaQueryWrapper<PermissionGroupEntity>()
  142 + .eq(PermissionGroupEntity::getSBrandsId, brandId));
  143 + return groups.stream().map(g -> {
  144 + PermissionGroupVO vo = new PermissionGroupVO();
  145 + vo.setSId(g.getSId());
  146 + vo.setSGroupCode(g.getSGroupCode());
  147 + vo.setSGroupName(g.getSGroupName());
  148 + vo.setSCategory(g.getSCategory());
  149 + return vo;
  150 + }).collect(Collectors.toList());
  151 + }
  152 +
  153 + // REQ-USR-002: 修改用户
  154 + @Override
  155 + @Transactional
  156 + public UserUpdateRespVO updateUser(String userId, UserUpdateReqDTO req, UserPrincipal principal) {
  157 + if (!"超级管理员".equals(principal.userType())) {
  158 + throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足");
  159 + }
  160 +
  161 + UsrUserEntity existing = userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>()
  162 + .eq(UsrUserEntity::getSId, userId)
  163 + .eq(UsrUserEntity::getSBrandsId, principal.brandId()));
  164 + if (existing == null) {
  165 + throw new BizException(UsrErrorCode.USER_NOT_FOUND, "用户不存在");
  166 + }
  167 +
  168 + if (principal.userId().equals(userId) && !"超级管理员".equals(req.getUserType())) {
  169 + throw new BizException(UsrErrorCode.SELF_ADMIN_CHANGE, "禁止修改自己的管理员角色");
  170 + }
  171 +
  172 + if (req.getEmployeeId() != null) {
  173 + StaffEntity staff = staffMapper.selectOne(new LambdaQueryWrapper<StaffEntity>()
  174 + .eq(StaffEntity::getSId, req.getEmployeeId())
  175 + .eq(StaffEntity::getSBrandsId, principal.brandId()));
  176 + if (staff == null) {
  177 + throw new BizException(UsrErrorCode.EMPLOYEE_NOT_FOUND, "员工不存在");
  178 + }
  179 + }
  180 +
  181 + userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>()
  182 + .eq(UsrUserEntity::getSId, userId)
  183 + .eq(UsrUserEntity::getSBrandsId, principal.brandId())
  184 + .set(UsrUserEntity::getSUserType, req.getUserType())
  185 + .set(UsrUserEntity::getSLanguage, req.getLanguage())
  186 + .set(UsrUserEntity::getBCanEditDoc, req.isCanEditDoc() ? 1 : 0)
  187 + .set(UsrUserEntity::getBIsDisabled, req.isDisabled() ? 1 : 0)
  188 + .set(UsrUserEntity::getSEmployeeId, req.getEmployeeId()));
  189 +
  190 + userPermissionMapper.delete(new LambdaQueryWrapper<UserPermissionEntity>()
  191 + .eq(UserPermissionEntity::getSUserId, userId)
  192 + .eq(UserPermissionEntity::getSBrandsId, principal.brandId()));
  193 +
  194 + if (req.getPermGroupIds() != null && !req.getPermGroupIds().isEmpty()) {
  195 + List<UserPermissionEntity> perms = req.getPermGroupIds().stream()
  196 + .map(groupId -> {
  197 + UserPermissionEntity perm = new UserPermissionEntity();
  198 + perm.setSId(UUID.randomUUID().toString());
  199 + perm.setSBrandsId(principal.brandId());
  200 + perm.setTCreateDate(LocalDateTime.now());
  201 + perm.setSUserId(userId);
  202 + perm.setSPermGroupId(groupId);
  203 + return perm;
  204 + })
  205 + .collect(Collectors.toList());
  206 + userPermissionMapper.insert(perms);
  207 + }
  208 +
  209 + UserUpdateRespVO vo = new UserUpdateRespVO();
  210 + vo.setUserId(userId);
  211 + vo.setUsername(existing.getSUsername());
  212 + vo.setUpdatedAt(LocalDateTime.now());
  213 + return vo;
  214 + }
  215 +}
backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class BrandVO {
  10 + @JsonProperty("sNo")
  11 + private String sNo;
  12 +
  13 + @JsonProperty("sName")
  14 + private String sName;
  15 +}
backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class LoginVO {
  9 +
  10 + private String accessToken;
  11 + private String refreshToken;
  12 + private long expiresIn;
  13 + private UserInfoVO userInfo;
  14 +
  15 + @Getter
  16 + @Setter
  17 + public static class UserInfoVO {
  18 + private String userId;
  19 + private String username;
  20 + private String userType;
  21 + private String language;
  22 + private String brandId;
  23 + }
  24 +}
backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class PermissionGroupVO {
  10 + @JsonProperty("sId")
  11 + private String sId;
  12 + @JsonProperty("sGroupCode")
  13 + private String sGroupCode;
  14 + @JsonProperty("sGroupName")
  15 + private String sGroupName;
  16 + @JsonProperty("sCategory")
  17 + private String sCategory;
  18 +}
backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class StaffVO {
  10 + @JsonProperty("sId")
  11 + private String sId;
  12 + @JsonProperty("sStaffName")
  13 + private String sStaffName;
  14 +}
backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class UserCreateRespVO {
  9 + private String userId;
  10 + private String userCode;
  11 + private String username;
  12 +}
backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +import java.time.LocalDateTime;
  8 +
  9 +@Getter
  10 +@Setter
  11 +public class UserListItemVO {
  12 +
  13 + @JsonProperty("sId")
  14 + private String sId;
  15 +
  16 + @JsonProperty("sUsername")
  17 + private String sUsername;
  18 +
  19 + @JsonProperty("sUserCode")
  20 + private String sUserCode;
  21 +
  22 + @JsonProperty("sUserType")
  23 + private String sUserType;
  24 +
  25 + @JsonProperty("sLanguage")
  26 + private String sLanguage;
  27 +
  28 + @JsonProperty("bCanEditDoc")
  29 + private Integer bCanEditDoc;
  30 +
  31 + @JsonProperty("bIsDisabled")
  32 + private Integer bIsDisabled;
  33 +
  34 + @JsonProperty("tLastLoginDate")
  35 + private LocalDateTime tLastLoginDate;
  36 +
  37 + @JsonProperty("sCreatorUsername")
  38 + private String sCreatorUsername;
  39 +
  40 + @JsonProperty("tCreateDate")
  41 + private LocalDateTime tCreateDate;
  42 +
  43 + @JsonProperty("sStaffName")
  44 + private String sStaffName;
  45 +
  46 + @JsonProperty("sDepartment")
  47 + private String sDepartment;
  48 +}
backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +@Getter
  9 +@Setter
  10 +public class UserUpdateRespVO {
  11 +
  12 + private String userId;
  13 + private String username;
  14 + private LocalDateTime updatedAt;
  15 +}
backend/src/main/resources/application-dev.yml 0 → 100644
  1 +logging:
  2 + level:
  3 + com.example.erp: DEBUG
  4 + org.springframework.security: DEBUG
backend/src/main/resources/application.yml 0 → 100644
  1 +spring:
  2 + datasource:
  3 + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=UTF-8
  4 + username: ${DB_USER}
  5 + password: ${DB_PASSWORD}
  6 + driver-class-name: com.mysql.cj.jdbc.Driver
  7 + flyway:
  8 + locations: classpath:db/migration
  9 + baseline-on-migrate: true
  10 + baseline-version: 1
  11 + validate-on-migrate: false
  12 + out-of-order: false
  13 +
  14 +server:
  15 + port: 8080
  16 +
  17 +jwt:
  18 + secret: ${JWT_SECRET}
  19 + access-token-expiry: 86400
  20 + refresh-token-expiry: 604800
  21 +
  22 +mybatis-plus:
  23 + configuration:
  24 + map-underscore-to-camel-case: false
  25 + global-config:
  26 + db-config:
  27 + id-type: auto
backend/src/main/resources/db/migration/V1__initial_schema.sql 0 → 100644
  1 +-- Flyway migration V1 — initial schema for 小羚羊
  2 +-- Generated: 2026-05-08T01:01:55Z
  3 +-- Source: 由 A4 db-init 从 docs/03-数据库设计文档.md 翻译生成(schema SSoT 是 docs/03)
  4 +-- This is the FIRST migration; subsequent schema changes must be written as new files sql/migrations/V2__<desc>.sql, V3__... etc.
  5 +-- Apply: Flyway runs this automatically at Spring Boot startup.
  6 +-- Do not hand-edit this file after it is committed; write a new migration instead.
  7 +
  8 +SET NAMES utf8mb4;
  9 +SET CHARACTER_SET_CLIENT = utf8mb4;
  10 +
  11 +-- ============================================================
  12 +-- Table: usr_user
  13 +-- ============================================================
  14 +CREATE TABLE `usr_user` (
  15 + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)',
  16 + `sId` VARCHAR(100) NULL COMMENT '业务 ID(标准列)',
  17 + `sBrandsId` VARCHAR(100) NULL COMMENT '品牌 ID(多租户隔离,标准列)',
  18 + `sSubsidiaryId` VARCHAR(100) NULL COMMENT '子公司 ID(组织层级隔离,标准列)',
  19 + `tCreateDate` DATETIME NOT NULL COMMENT '创建时间(标准列)',
  20 + `sUserCode` VARCHAR(50) NOT NULL COMMENT '用户号(业务编号,人类可读唯一标识)',
  21 + `sUsername` VARCHAR(100) NOT NULL COMMENT '用户名(登录标识,全局唯一,不可修改)',
  22 + `sPasswordHash` VARCHAR(255) NOT NULL COMMENT 'BCrypt 哈希密码,禁止存储明文',
  23 + `sUserType` VARCHAR(20) NOT NULL DEFAULT '普通用户' COMMENT '用户类型:普通用户 / 超级管理员',
  24 + `sLanguage` VARCHAR(20) NOT NULL DEFAULT '中文' COMMENT '界面语言:中文 / 英文 / 繁体',
  25 + `bCanEditDoc` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '单据修改权限:0=否,1=是',
  26 + `bIsDisabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否作废/禁用:0=正常,1=禁用',
  27 + `sEmployeeId` VARCHAR(100) NULL COMMENT '关联职员 ID(跨模块引用,职员未关联时为 NULL)',
  28 + `sCreatorUsername` VARCHAR(100) NULL COMMENT '制单人用户名(冗余字段,便于列表展示)',
  29 + `tLastLoginDate` DATETIME NULL COMMENT '最后登录时间',
  30 + `iLoginFailCount` INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数,用于防暴力破解',
  31 + `tLockUntil` DATETIME NULL COMMENT '账号锁定截止时间,NULL 表示未锁定',
  32 + PRIMARY KEY (`iIncrement`)
  33 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  34 + COMMENT='用户账户主表,存储登录信息、类型、语言偏好及安全控制字段';
  35 +
  36 +-- ============================================================
  37 +-- Table: usr_permission_group
  38 +-- ============================================================
  39 +CREATE TABLE `usr_permission_group` (
  40 + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)',
  41 + `sId` VARCHAR(100) NULL COMMENT '业务 ID(标准列)',
  42 + `sBrandsId` VARCHAR(100) NULL COMMENT '品牌 ID(多租户隔离,标准列)',
  43 + `sSubsidiaryId` VARCHAR(100) NULL COMMENT '子公司 ID(组织层级隔离,标准列)',
  44 + `tCreateDate` DATETIME NOT NULL COMMENT '创建时间(标准列)',
  45 + `sGroupCode` VARCHAR(100) NOT NULL COMMENT '权限代码(如 usr:create、usr:edit),全局唯一',
  46 + `sGroupName` VARCHAR(200) NOT NULL COMMENT '权限显示名称(如"新增用户"、"修改用户")',
  47 + `sCategory` VARCHAR(100) NULL COMMENT '权限分类标签,用于前端权限分组展示',
  48 + PRIMARY KEY (`iIncrement`)
  49 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  50 + COMMENT='权限分类/权限组定义表,每行对应一个可分配给用户的权限项';
  51 +
  52 +-- ============================================================
  53 +-- Table: usr_user_permission
  54 +-- ============================================================
  55 +CREATE TABLE `usr_user_permission` (
  56 + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)',
  57 + `sId` VARCHAR(100) NULL COMMENT '业务 ID(标准列)',
  58 + `sBrandsId` VARCHAR(100) NULL COMMENT '品牌 ID(多租户隔离,标准列)',
  59 + `sSubsidiaryId` VARCHAR(100) NULL COMMENT '子公司 ID(组织层级隔离,标准列)',
  60 + `tCreateDate` DATETIME NOT NULL COMMENT '创建时间(标准列)',
  61 + `sUserId` VARCHAR(100) NOT NULL COMMENT '关联 usr_user.sId',
  62 + `sPermGroupId` VARCHAR(100) NOT NULL COMMENT '关联 usr_permission_group.sId',
  63 + PRIMARY KEY (`iIncrement`)
  64 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  65 + COMMENT='用户与权限组的多对多关联表';
  66 +
  67 +-- ============================================================
  68 +-- Table: tStaff
  69 +-- ============================================================
  70 +CREATE TABLE `tStaff` (
  71 + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)',
  72 + `sId` VARCHAR(100) NULL COMMENT '业务 ID(标准列)',
  73 + `sBrandsId` VARCHAR(100) NULL COMMENT '品牌 ID(多租户隔离,标准列)',
  74 + `sSubsidiaryId` VARCHAR(100) NULL COMMENT '子公司 ID(组织层级隔离,标准列)',
  75 + `tCreateDate` DATETIME NOT NULL COMMENT '创建时间(标准列)',
  76 + `sStaffNo` VARCHAR(50) NULL COMMENT '职员编号;系统内唯一',
  77 + `sStaffName` VARCHAR(50) NOT NULL COMMENT '职员姓名',
  78 + `sDepartment` VARCHAR(100) NULL DEFAULT NULL COMMENT '所属部门(本期暂用字符串,未来如需独立 tDepartment 字典表再另行重构)',
  79 + `sCreatedBy` VARCHAR(50) NULL COMMENT '制单人',
  80 + `bDeleted` BIT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记',
  81 + `tDeletedDate` DATETIME NULL DEFAULT NULL COMMENT '软删除时间',
  82 + `sDeletedBy` VARCHAR(50) NULL DEFAULT NULL COMMENT '软删除操作人',
  83 + PRIMARY KEY (`iIncrement`)
  84 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  85 + COMMENT='职员维度(员工名 / 部门 / 编号)';
  86 +
  87 +-- ============================================================
  88 +-- Table: brand
  89 +-- ============================================================
  90 +CREATE TABLE `brand` (
  91 + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)',
  92 + `sId` VARCHAR(100) NULL COMMENT '业务 ID(标准列)',
  93 + `sBrandsId` VARCHAR(100) NULL COMMENT '品牌 ID(多租户隔离,标准列)',
  94 + `sSubsidiaryId` VARCHAR(100) NULL COMMENT '子公司 ID(组织层级隔离,标准列)',
  95 + `tCreateDate` DATETIME NOT NULL COMMENT '创建时间(标准列)',
  96 + `sName` VARCHAR(100) NULL COMMENT '公司名称',
  97 + `sShortName` VARCHAR(100) NULL COMMENT '公司简称',
  98 + `sNo` VARCHAR(100) NULL COMMENT '单位编号(登录账号根据单位编号作为前缀)',
  99 + PRIMARY KEY (`iIncrement`)
  100 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  101 + COMMENT='公司表';
  102 +
  103 +-- ============================================================
  104 +-- Indexes: usr_user
  105 +-- ============================================================
  106 +CREATE UNIQUE INDEX `uk_usr_user_sid` ON `usr_user` (`sId`);
  107 +CREATE UNIQUE INDEX `uk_usr_user_username` ON `usr_user` (`sUsername`);
  108 +CREATE UNIQUE INDEX `uk_usr_user_usercode` ON `usr_user` (`sUserCode`);
  109 +CREATE INDEX `idx_usr_user_tenant` ON `usr_user` (`sBrandsId`, `sSubsidiaryId`);
  110 +CREATE INDEX `idx_usr_user_type` ON `usr_user` (`sUserType`);
  111 +CREATE INDEX `idx_usr_user_disabled` ON `usr_user` (`bIsDisabled`);
  112 +
  113 +-- ============================================================
  114 +-- Indexes: usr_permission_group
  115 +-- ============================================================
  116 +CREATE UNIQUE INDEX `uk_usr_perm_group_sid` ON `usr_permission_group` (`sId`);
  117 +CREATE UNIQUE INDEX `uk_usr_perm_group_code` ON `usr_permission_group` (`sGroupCode`);
  118 +CREATE INDEX `idx_usr_perm_group_tenant` ON `usr_permission_group` (`sBrandsId`, `sSubsidiaryId`);
  119 +
  120 +-- ============================================================
  121 +-- Indexes: usr_user_permission
  122 +-- ============================================================
  123 +CREATE UNIQUE INDEX `uk_usr_user_perm` ON `usr_user_permission` (`sUserId`, `sPermGroupId`);
  124 +CREATE INDEX `idx_usr_user_perm_user` ON `usr_user_permission` (`sUserId`);
  125 +CREATE INDEX `idx_usr_user_perm_group` ON `usr_user_permission` (`sPermGroupId`);
  126 +
  127 +-- ============================================================
  128 +-- Indexes: tStaff
  129 +-- ============================================================
  130 +CREATE UNIQUE INDEX `uk_staff_no` ON `tStaff` (`sStaffNo`);
  131 +CREATE INDEX `idx_staff_name` ON `tStaff` (`sStaffName`);
  132 +CREATE INDEX `idx_department` ON `tStaff` (`sDepartment`);
  133 +
  134 +-- ============================================================
  135 +-- Indexes: brand
  136 +-- ============================================================
  137 +CREATE UNIQUE INDEX `uk_brand_no` ON `brand` (`sNo`);
  138 +CREATE INDEX `idx_brand_name` ON `brand` (`sName`);
  139 +
  140 +-- ============================================================
  141 +-- Foreign Keys
  142 +-- ============================================================
  143 +ALTER TABLE `usr_user_permission`
  144 + ADD CONSTRAINT `fk_usr_user_perm_user`
  145 + FOREIGN KEY (`sUserId`) REFERENCES `usr_user` (`sId`)
  146 + ON DELETE CASCADE ON UPDATE CASCADE;
  147 +
  148 +ALTER TABLE `usr_user_permission`
  149 + ADD CONSTRAINT `fk_usr_user_perm_group`
  150 + FOREIGN KEY (`sPermGroupId`) REFERENCES `usr_permission_group` (`sId`)
  151 + ON DELETE CASCADE ON UPDATE CASCADE;
backend/src/main/resources/db/migration/V2__fix_username_unique_per_tenant.sql 0 → 100644
  1 +-- Flyway migration V2 — fix usr_user username uniqueness to per-tenant scope
  2 +-- Generated: 2026-05-08
  3 +-- Reason: uk_usr_user_username was globally unique; same username must be allowed across different brands (sBrandsId)
  4 +-- New unique constraint: (sUsername, sBrandsId) composite
  5 +
  6 +ALTER TABLE usr_user DROP INDEX uk_usr_user_username;
  7 +CREATE UNIQUE INDEX uk_usr_user_username_tenant ON usr_user (sUsername, sBrandsId);
backend/src/main/resources/mapper/UsrUserMapper.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  3 + "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  4 +<mapper namespace="com.example.erp.module.usr.mapper.UsrUserMapper">
  5 +
  6 + <!-- REQ-USR-003: 查询用户 -->
  7 + <select id="selectUserList" resultType="com.example.erp.module.usr.vo.UserListItemVO">
  8 + SELECT u.sId, u.sUsername, u.sUserCode, u.sUserType, u.sLanguage,
  9 + u.bCanEditDoc, u.bIsDisabled, u.tLastLoginDate, u.sCreatorUsername, u.tCreateDate,
  10 + s.sStaffName, s.sDepartment
  11 + FROM usr_user u
  12 + LEFT JOIN tStaff s ON u.sEmployeeId = s.sId
  13 + AND s.sBrandsId = u.sBrandsId
  14 + AND s.bDeleted = 0
  15 + WHERE u.sBrandsId = #{brandId}
  16 + <if test="queryValue != null and queryValue != ''">
  17 + <choose>
  18 + <when test="queryField == 'username'">
  19 + <choose>
  20 + <when test="matchType == 'contains'">AND u.sUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  21 + <when test="matchType == 'notContains'">AND u.sUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  22 + <otherwise>AND u.sUsername = #{queryValue}</otherwise>
  23 + </choose>
  24 + </when>
  25 + <when test="queryField == 'staffName'">
  26 + <choose>
  27 + <when test="matchType == 'contains'">AND s.sStaffName LIKE CONCAT('%', #{queryValue}, '%')</when>
  28 + <when test="matchType == 'notContains'">AND (s.sStaffName IS NULL OR s.sStaffName NOT LIKE CONCAT('%', #{queryValue}, '%'))</when>
  29 + <otherwise>AND s.sStaffName = #{queryValue}</otherwise>
  30 + </choose>
  31 + </when>
  32 + <when test="queryField == 'userCode'">
  33 + <choose>
  34 + <when test="matchType == 'contains'">AND u.sUserCode LIKE CONCAT('%', #{queryValue}, '%')</when>
  35 + <when test="matchType == 'notContains'">AND u.sUserCode NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  36 + <otherwise>AND u.sUserCode = #{queryValue}</otherwise>
  37 + </choose>
  38 + </when>
  39 + <when test="queryField == 'department'">
  40 + <choose>
  41 + <when test="matchType == 'contains'">AND s.sDepartment LIKE CONCAT('%', #{queryValue}, '%')</when>
  42 + <when test="matchType == 'notContains'">AND (s.sDepartment IS NULL OR s.sDepartment NOT LIKE CONCAT('%', #{queryValue}, '%'))</when>
  43 + <otherwise>AND s.sDepartment = #{queryValue}</otherwise>
  44 + </choose>
  45 + </when>
  46 + <when test="queryField == 'userType'">
  47 + <choose>
  48 + <when test="matchType == 'contains'">AND u.sUserType LIKE CONCAT('%', #{queryValue}, '%')</when>
  49 + <when test="matchType == 'notContains'">AND u.sUserType NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  50 + <otherwise>AND u.sUserType = #{queryValue}</otherwise>
  51 + </choose>
  52 + </when>
  53 + <when test="queryField == 'creator'">
  54 + <choose>
  55 + <when test="matchType == 'contains'">AND u.sCreatorUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  56 + <when test="matchType == 'notContains'">AND u.sCreatorUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  57 + <otherwise>AND u.sCreatorUsername = #{queryValue}</otherwise>
  58 + </choose>
  59 + </when>
  60 + <when test="queryField == 'disabled'">
  61 + <choose>
  62 + <when test="queryValue == '是'">AND u.bIsDisabled = 1</when>
  63 + <when test="queryValue == '否'">AND u.bIsDisabled = 0</when>
  64 + </choose>
  65 + </when>
  66 + <when test="queryField == 'lastLoginDate'">
  67 + AND DATE(u.tLastLoginDate) = #{queryValue}
  68 + </when>
  69 + </choose>
  70 + </if>
  71 + ORDER BY u.tCreateDate DESC
  72 + </select>
  73 +
  74 +</mapper>
backend/src/test/java/com/example/erp/ApplicationContextTest.java 0 → 100644
  1 +package com.example.erp;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +import org.springframework.boot.test.context.SpringBootTest;
  5 +import org.springframework.test.context.ActiveProfiles;
  6 +
  7 +@SpringBootTest
  8 +@ActiveProfiles("test")
  9 +class ApplicationContextTest {
  10 +
  11 + @Test
  12 + void contextLoads() {
  13 + }
  14 +}
backend/src/test/java/com/example/erp/common/JwtUtilTest.java 0 → 100644
  1 +package com.example.erp.common;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.common.util.JwtUtil;
  5 +import com.example.erp.config.JwtProperties;
  6 +import io.jsonwebtoken.Claims;
  7 +import org.junit.jupiter.api.BeforeEach;
  8 +import org.junit.jupiter.api.Test;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.*;
  11 +
  12 +class JwtUtilTest {
  13 +
  14 + private JwtUtil jwtUtil;
  15 +
  16 + @BeforeEach
  17 + void setUp() {
  18 + JwtProperties props = new JwtProperties();
  19 + props.setSecret("testSecretKey32CharactersMinimumXXXXX");
  20 + props.setAccessTokenExpiry(86400L);
  21 + props.setRefreshTokenExpiry(604800L);
  22 + jwtUtil = new JwtUtil(props);
  23 + }
  24 +
  25 + @Test
  26 + void generateAndParseAccessToken_containsAllClaims() {
  27 + String token = jwtUtil.generateAccessToken("u1", "admin", "超级管理员", "b1");
  28 + Claims claims = jwtUtil.parseAccessToken(token);
  29 +
  30 + assertEquals("u1", claims.getSubject());
  31 + assertEquals("admin", claims.get("username", String.class));
  32 + assertEquals("超级管理员", claims.get("userType", String.class));
  33 + assertEquals("b1", claims.get("brandId", String.class));
  34 + assertNotNull(claims.getExpiration());
  35 + }
  36 +
  37 + @Test
  38 + void parseRefreshToken_withAccessToken_throws40103() {
  39 + String accessToken = jwtUtil.generateAccessToken("u1", "admin", "普通用户", "b1");
  40 + BizException ex = assertThrows(BizException.class, () -> jwtUtil.parseRefreshToken(accessToken));
  41 + assertEquals(40103, ex.getCode());
  42 + }
  43 +
  44 + @Test
  45 + void parseAccessToken_withExpiredToken_throws40103() {
  46 + JwtProperties expiredProps = new JwtProperties();
  47 + expiredProps.setSecret("testSecretKey32CharactersMinimumXXXXX");
  48 + expiredProps.setAccessTokenExpiry(-1L);
  49 + expiredProps.setRefreshTokenExpiry(604800L);
  50 + JwtUtil expiredJwtUtil = new JwtUtil(expiredProps);
  51 +
  52 + String token = expiredJwtUtil.generateAccessToken("u1", "admin", "普通用户", "b1");
  53 + BizException ex = assertThrows(BizException.class, () -> jwtUtil.parseAccessToken(token));
  54 + assertEquals(40103, ex.getCode());
  55 + }
  56 +}
backend/src/test/java/com/example/erp/common/ResultTest.java 0 → 100644
  1 +package com.example.erp.common;
  2 +
  3 +import com.example.erp.common.response.Result;
  4 +import org.junit.jupiter.api.Test;
  5 +
  6 +import static org.junit.jupiter.api.Assertions.*;
  7 +
  8 +class ResultTest {
  9 +
  10 + @Test
  11 + void ok_setsCode200AndData() {
  12 + Result<String> result = Result.ok("hello");
  13 + assertEquals(200, result.getCode());
  14 + assertEquals("hello", result.getData());
  15 + assertEquals("操作成功", result.getMessage());
  16 + }
  17 +
  18 + @Test
  19 + void fail_setsCodeAndNullData() {
  20 + Result<Object> result = Result.fail(40100, "用户名或密码错误");
  21 + assertEquals(40100, result.getCode());
  22 + assertNull(result.getData());
  23 + assertEquals("用户名或密码错误", result.getMessage());
  24 + }
  25 +
  26 + @Test
  27 + void ok_hasPositiveTimestamp() {
  28 + Result<Void> result = Result.ok(null);
  29 + assertTrue(result.getTimestamp() > 0);
  30 + }
  31 +}
backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.module.usr.dto.LoginReqDTO;
  5 +import com.example.erp.module.usr.service.AuthService;
  6 +import com.example.erp.module.usr.vo.BrandVO;
  7 +import com.example.erp.module.usr.vo.LoginVO;
  8 +import com.fasterxml.jackson.databind.ObjectMapper;
  9 +import org.junit.jupiter.api.Test;
  10 +import org.springframework.beans.factory.annotation.Autowired;
  11 +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
  12 +import org.springframework.boot.test.mock.mockito.MockBean;
  13 +import org.springframework.context.annotation.Import;
  14 +import org.springframework.http.MediaType;
  15 +import org.springframework.test.web.servlet.MockMvc;
  16 +
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +
  20 +import static org.mockito.ArgumentMatchers.any;
  21 +import static org.mockito.ArgumentMatchers.anyString;
  22 +import static org.mockito.Mockito.when;
  23 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
  24 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  25 +
  26 +import com.example.erp.config.SecurityConfig;
  27 +import com.example.erp.config.JwtAuthenticationFilter;
  28 +import com.example.erp.config.BeanConfig;
  29 +import com.example.erp.common.util.JwtUtil;
  30 +import com.example.erp.config.JwtProperties;
  31 +import com.example.erp.module.usr.controller.AuthController;
  32 +
  33 +@WebMvcTest(controllers = AuthController.class)
  34 +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class})
  35 +class AuthControllerTest {
  36 +
  37 + @Autowired private MockMvc mockMvc;
  38 + @Autowired private ObjectMapper objectMapper;
  39 + @MockBean private AuthService authService;
  40 +
  41 + @Test
  42 + void login_wrongPassword_returns40100() throws Exception {
  43 + when(authService.login(any())).thenThrow(new BizException(40100, "用户名或密码错误"));
  44 +
  45 + Map<String, String> body = Map.of("brandNo", "STD", "username", "admin", "password", "wrong");
  46 + mockMvc.perform(post("/api/auth/login")
  47 + .contentType(MediaType.APPLICATION_JSON)
  48 + .content(objectMapper.writeValueAsString(body)))
  49 + .andExpect(status().isOk())
  50 + .andExpect(jsonPath("$.code").value(40100))
  51 + .andExpect(jsonPath("$.message").value("用户名或密码错误"));
  52 + }
  53 +
  54 + @Test
  55 + void login_validCredentials_returns200AndTokens() throws Exception {
  56 + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO();
  57 + userInfo.setUserId("u1");
  58 + userInfo.setUsername("admin");
  59 + userInfo.setUserType("普通用户");
  60 + userInfo.setLanguage("中文");
  61 + userInfo.setBrandId("b1");
  62 +
  63 + LoginVO loginVO = new LoginVO();
  64 + loginVO.setAccessToken("access-token");
  65 + loginVO.setRefreshToken("refresh-token");
  66 + loginVO.setExpiresIn(86400L);
  67 + loginVO.setUserInfo(userInfo);
  68 +
  69 + when(authService.login(any())).thenReturn(loginVO);
  70 +
  71 + Map<String, String> body = Map.of("brandNo", "STD", "username", "admin", "password", "666666");
  72 + mockMvc.perform(post("/api/auth/login")
  73 + .contentType(MediaType.APPLICATION_JSON)
  74 + .content(objectMapper.writeValueAsString(body)))
  75 + .andExpect(status().isOk())
  76 + .andExpect(jsonPath("$.code").value(200))
  77 + .andExpect(jsonPath("$.data.accessToken").value("access-token"))
  78 + .andExpect(jsonPath("$.data.userInfo.userId").value("u1"));
  79 + }
  80 +
  81 + @Test
  82 + void refresh_withInvalidToken_returns40103() throws Exception {
  83 + when(authService.refresh(anyString())).thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录"));
  84 +
  85 + Map<String, String> body = Map.of("refreshToken", "invalid-token");
  86 + mockMvc.perform(post("/api/auth/refresh")
  87 + .contentType(MediaType.APPLICATION_JSON)
  88 + .content(objectMapper.writeValueAsString(body)))
  89 + .andExpect(status().isOk())
  90 + .andExpect(jsonPath("$.code").value(40103));
  91 + }
  92 +
  93 + @Test
  94 + void getBrands_returns200AndList() throws Exception {
  95 + BrandVO b = new BrandVO();
  96 + b.setSNo("STD");
  97 + b.setSName("标准版");
  98 + when(authService.getBrands()).thenReturn(List.of(b));
  99 +
  100 + mockMvc.perform(get("/api/auth/brands"))
  101 + .andExpect(status().isOk())
  102 + .andExpect(jsonPath("$.code").value(200))
  103 + .andExpect(jsonPath("$.data[0].sNo").value("STD"))
  104 + .andExpect(jsonPath("$.data[0].sName").value("标准版"));
  105 + }
  106 +}
backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.common.util.JwtUtil;
  5 +import com.example.erp.module.usr.dto.LoginReqDTO;
  6 +import com.example.erp.module.usr.entity.BrandEntity;
  7 +import com.example.erp.module.usr.entity.UsrUserEntity;
  8 +import com.example.erp.module.usr.mapper.BrandMapper;
  9 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  10 +import com.example.erp.module.usr.service.impl.AuthServiceImpl;
  11 +import com.example.erp.module.usr.vo.BrandVO;
  12 +import com.example.erp.module.usr.vo.LoginVO;
  13 +import io.jsonwebtoken.Claims;
  14 +import com.baomidou.mybatisplus.core.MybatisConfiguration;
  15 +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
  16 +import org.apache.ibatis.builder.MapperBuilderAssistant;
  17 +import org.junit.jupiter.api.BeforeAll;
  18 +import org.junit.jupiter.api.BeforeEach;
  19 +import org.junit.jupiter.api.Test;
  20 +import org.junit.jupiter.api.extension.ExtendWith;
  21 +import org.mockito.InjectMocks;
  22 +import org.mockito.Mock;
  23 +import org.mockito.junit.jupiter.MockitoExtension;
  24 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  25 +
  26 +import java.time.LocalDateTime;
  27 +import java.util.List;
  28 +import java.util.Map;
  29 +
  30 +import static org.junit.jupiter.api.Assertions.*;
  31 +import static org.mockito.ArgumentMatchers.*;
  32 +import static org.mockito.Mockito.*;
  33 +
  34 +@ExtendWith(MockitoExtension.class)
  35 +class AuthServiceTest {
  36 +
  37 + @BeforeAll
  38 + static void initMyBatisEntityCache() {
  39 + TableInfoHelper.initTableInfo(
  40 + new MapperBuilderAssistant(new MybatisConfiguration(), ""),
  41 + UsrUserEntity.class);
  42 + }
  43 +
  44 +
  45 + @Mock private BrandMapper brandMapper;
  46 + @Mock private UsrUserMapper userMapper;
  47 + @Mock private JwtUtil jwtUtil;
  48 + @Mock private BCryptPasswordEncoder passwordEncoder;
  49 +
  50 + @InjectMocks
  51 + private AuthServiceImpl authService;
  52 +
  53 + private LoginReqDTO req;
  54 + private BrandEntity brand;
  55 + private UsrUserEntity user;
  56 +
  57 + @BeforeEach
  58 + void setUp() {
  59 + req = new LoginReqDTO();
  60 + req.setBrandNo("STD");
  61 + req.setUsername("admin");
  62 + req.setPassword("666666");
  63 +
  64 + brand = new BrandEntity();
  65 + brand.setSId("b1");
  66 + brand.setSNo("STD");
  67 + brand.setSName("标准版");
  68 +
  69 + user = new UsrUserEntity();
  70 + user.setSId("u1");
  71 + user.setSUsername("admin");
  72 + user.setSPasswordHash("$2a$10$hashed");
  73 + user.setSUserType("普通用户");
  74 + user.setSLanguage("中文");
  75 + user.setBIsDisabled(0);
  76 + user.setILoginFailCount(0);
  77 + user.setTLockUntil(null);
  78 + }
  79 +
  80 + @Test
  81 + void login_brandNotFound_throws40100() {
  82 + when(brandMapper.selectOne(any())).thenReturn(null);
  83 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  84 + assertEquals(40100, ex.getCode());
  85 + }
  86 +
  87 + @Test
  88 + void login_userNotFound_throws40100() {
  89 + when(brandMapper.selectOne(any())).thenReturn(brand);
  90 + when(userMapper.selectOne(any())).thenReturn(null);
  91 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  92 + assertEquals(40100, ex.getCode());
  93 + }
  94 +
  95 + @Test
  96 + void login_accountDisabled_throws40101() {
  97 + user.setBIsDisabled(1);
  98 + when(brandMapper.selectOne(any())).thenReturn(brand);
  99 + when(userMapper.selectOne(any())).thenReturn(user);
  100 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  101 + assertEquals(40101, ex.getCode());
  102 + }
  103 +
  104 + @Test
  105 + void login_accountLocked_throws40102WithRemainingMinutes() {
  106 + user.setTLockUntil(LocalDateTime.now().plusMinutes(20));
  107 + when(brandMapper.selectOne(any())).thenReturn(brand);
  108 + when(userMapper.selectOne(any())).thenReturn(user);
  109 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  110 + assertEquals(40102, ex.getCode());
  111 + assertTrue(ex.getMessage().contains("分钟"));
  112 + }
  113 +
  114 + @Test
  115 + void login_wrongPassword_firstTime_throws40100AndIncrementsCount() {
  116 + when(brandMapper.selectOne(any())).thenReturn(brand);
  117 + when(userMapper.selectOne(any())).thenReturn(user);
  118 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false);
  119 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  120 + assertEquals(40100, ex.getCode());
  121 + verify(userMapper).update(isNull(), any());
  122 + }
  123 +
  124 + @Test
  125 + void login_wrongPassword_5thTime_setsLockAndThrows40102() {
  126 + user.setILoginFailCount(4);
  127 + when(brandMapper.selectOne(any())).thenReturn(brand);
  128 + when(userMapper.selectOne(any())).thenReturn(user);
  129 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false);
  130 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  131 + assertEquals(40102, ex.getCode());
  132 + verify(userMapper).update(isNull(), any());
  133 + }
  134 +
  135 + @Test
  136 + void login_success_resetsCountAndReturnsTokens() {
  137 + when(brandMapper.selectOne(any())).thenReturn(brand);
  138 + when(userMapper.selectOne(any())).thenReturn(user);
  139 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
  140 + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("access-token");
  141 + when(jwtUtil.generateRefreshToken(anyString(), anyString())).thenReturn("refresh-token");
  142 +
  143 + LoginVO result = authService.login(req);
  144 +
  145 + assertEquals("access-token", result.getAccessToken());
  146 + assertEquals("refresh-token", result.getRefreshToken());
  147 + assertEquals(86400L, result.getExpiresIn());
  148 + assertNotNull(result.getUserInfo());
  149 + assertEquals("u1", result.getUserInfo().getUserId());
  150 + verify(userMapper).update(isNull(), any());
  151 + }
  152 +
  153 + // ---- Task 6: refresh + getBrands tests ----
  154 +
  155 + @Test
  156 + void refresh_validRefreshToken_returnsNewAccessToken() {
  157 + Claims claims = mock(Claims.class);
  158 + when(claims.getSubject()).thenReturn("u1");
  159 + when(claims.get("brandId", String.class)).thenReturn("b1");
  160 + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims);
  161 + when(userMapper.selectOne(any())).thenReturn(user);
  162 + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("new-access-token");
  163 +
  164 + String newToken = authService.refresh("valid-refresh");
  165 + assertEquals("new-access-token", newToken);
  166 + }
  167 +
  168 + @Test
  169 + void refresh_lockedUser_throws40103() {
  170 + Claims claims = mock(Claims.class);
  171 + when(claims.getSubject()).thenReturn("u1");
  172 + when(claims.get("brandId", String.class)).thenReturn("b1");
  173 + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims);
  174 + user.setTLockUntil(LocalDateTime.now().plusMinutes(25));
  175 + when(userMapper.selectOne(any())).thenReturn(user);
  176 +
  177 + BizException ex = assertThrows(BizException.class, () -> authService.refresh("valid-refresh"));
  178 + assertEquals(40103, ex.getCode());
  179 + }
  180 +
  181 + @Test
  182 + void refresh_invalidRefreshToken_throws40103() {
  183 + when(jwtUtil.parseRefreshToken("bad-token"))
  184 + .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录"));
  185 + BizException ex = assertThrows(BizException.class, () -> authService.refresh("bad-token"));
  186 + assertEquals(40103, ex.getCode());
  187 + }
  188 +
  189 + @Test
  190 + void getBrands_returnsListSortedByName() {
  191 + BrandEntity b1 = new BrandEntity();
  192 + b1.setSNo("STD"); b1.setSName("标准版");
  193 + BrandEntity b2 = new BrandEntity();
  194 + b2.setSNo("ENT"); b2.setSName("企业版");
  195 + when(brandMapper.selectList(any())).thenReturn(List.of(b1, b2));
  196 +
  197 + List<BrandVO> result = authService.getBrands();
  198 + assertEquals(2, result.size());
  199 + assertEquals("标准版", result.get(0).getSName());
  200 + }
  201 +}
backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.example.erp.module.usr.entity.BrandEntity;
  5 +import com.example.erp.module.usr.mapper.BrandMapper;
  6 +import org.junit.jupiter.api.AfterEach;
  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.context.SpringBootTest;
  11 +import org.springframework.test.context.ActiveProfiles;
  12 +
  13 +import java.time.LocalDateTime;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +@SpringBootTest
  18 +@ActiveProfiles("test")
  19 +class BrandMapperTest {
  20 +
  21 + @Autowired
  22 + private BrandMapper brandMapper;
  23 +
  24 + @BeforeEach
  25 + void setUp() {
  26 + BrandEntity brand = new BrandEntity();
  27 + brand.setSId("b-test-mapper-001");
  28 + brand.setSNo("TST");
  29 + brand.setSName("测试版");
  30 + brand.setTCreateDate(LocalDateTime.now());
  31 + brandMapper.insert(brand);
  32 + }
  33 +
  34 + @AfterEach
  35 + void tearDown() {
  36 + brandMapper.delete(new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, "TST"));
  37 + }
  38 +
  39 + @Test
  40 + void findByNo_returnsCorrectBrand() {
  41 + BrandEntity found = brandMapper.selectOne(
  42 + new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, "TST"));
  43 + assertNotNull(found);
  44 + assertEquals("测试版", found.getSName());
  45 + assertEquals("b-test-mapper-001", found.getSId());
  46 + }
  47 +}
backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.config.BeanConfig;
  4 +import com.example.erp.config.JwtAuthenticationFilter;
  5 +import com.example.erp.config.JwtProperties;
  6 +import com.example.erp.config.SecurityConfig;
  7 +import com.example.erp.common.util.JwtUtil;
  8 +import com.example.erp.module.usr.controller.UserController;
  9 +import com.example.erp.module.usr.service.UserService;
  10 +import com.example.erp.common.vo.PageVO;
  11 +import com.example.erp.module.usr.dto.UserListQueryDTO;
  12 +import com.example.erp.module.usr.dto.UserUpdateReqDTO;
  13 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  14 +import com.example.erp.module.usr.vo.UserListItemVO;
  15 +import com.example.erp.module.usr.vo.UserUpdateRespVO;
  16 +import com.example.erp.module.usr.vo.StaffVO;
  17 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  18 +import com.fasterxml.jackson.databind.ObjectMapper;
  19 +import org.junit.jupiter.api.Test;
  20 +import org.springframework.beans.factory.annotation.Autowired;
  21 +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
  22 +import org.springframework.boot.test.mock.mockito.MockBean;
  23 +import org.springframework.context.annotation.Import;
  24 +import org.springframework.http.MediaType;
  25 +import org.springframework.test.context.ActiveProfiles;
  26 +import org.springframework.test.web.servlet.MockMvc;
  27 +import org.springframework.test.web.servlet.request.RequestPostProcessor;
  28 +
  29 +import java.util.List;
  30 +import java.util.Map;
  31 +
  32 +import static org.mockito.ArgumentMatchers.any;
  33 +import static org.mockito.ArgumentMatchers.anyString;
  34 +import static org.mockito.Mockito.when;
  35 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
  36 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  37 +
  38 +@ActiveProfiles("test")
  39 +@WebMvcTest(controllers = UserController.class)
  40 +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class})
  41 +class UserControllerTest {
  42 +
  43 + @Autowired private MockMvc mockMvc;
  44 + @Autowired private ObjectMapper objectMapper;
  45 + @Autowired private JwtUtil jwtUtil;
  46 + @MockBean private UserService userService;
  47 +
  48 + RequestPostProcessor superAdmin() {
  49 + String token = jwtUtil.generateAccessToken("u1", "admin", "超级管理员", "b1");
  50 + return request -> { request.addHeader("Authorization", "Bearer " + token); return request; };
  51 + }
  52 +
  53 + @Test
  54 + void createUser_noAuth_returns401() throws Exception {
  55 + mockMvc.perform(post("/api/usr/users")
  56 + .contentType(MediaType.APPLICATION_JSON)
  57 + .content("{}"))
  58 + .andExpect(status().isUnauthorized());
  59 + }
  60 +
  61 + @Test
  62 + void createUser_validRequest_returns200() throws Exception {
  63 + UserCreateRespVO resp = new UserCreateRespVO();
  64 + resp.setUserId("new-user-id");
  65 + resp.setUserCode("UC001");
  66 + resp.setUsername("testuser");
  67 + when(userService.createUser(any(), any())).thenReturn(resp);
  68 +
  69 + Map<String, Object> body = Map.of(
  70 + "userCode", "UC001",
  71 + "username", "testuser",
  72 + "userType", "普通用户",
  73 + "language", "中文");
  74 + mockMvc.perform(post("/api/usr/users")
  75 + .with(superAdmin())
  76 + .contentType(MediaType.APPLICATION_JSON)
  77 + .content(objectMapper.writeValueAsString(body)))
  78 + .andExpect(status().isOk())
  79 + .andExpect(jsonPath("$.code").value(200))
  80 + .andExpect(jsonPath("$.data.userId").value("new-user-id"));
  81 + }
  82 +
  83 + @Test
  84 + void createUser_missingUserCode_returns40001() throws Exception {
  85 + Map<String, Object> body = Map.of(
  86 + "username", "testuser",
  87 + "userType", "普通用户",
  88 + "language", "中文");
  89 + mockMvc.perform(post("/api/usr/users")
  90 + .with(superAdmin())
  91 + .contentType(MediaType.APPLICATION_JSON)
  92 + .content(objectMapper.writeValueAsString(body)))
  93 + .andExpect(status().isOk())
  94 + .andExpect(jsonPath("$.code").value(40001));
  95 + }
  96 +
  97 + @Test
  98 + void getStaffs_returns200() throws Exception {
  99 + StaffVO s = new StaffVO();
  100 + s.setSId("s1");
  101 + s.setSStaffName("张三");
  102 + when(userService.getStaffs(anyString())).thenReturn(List.of(s));
  103 +
  104 + mockMvc.perform(get("/api/usr/users/staffs").with(superAdmin()))
  105 + .andExpect(status().isOk())
  106 + .andExpect(jsonPath("$.code").value(200))
  107 + .andExpect(jsonPath("$.data[0].sId").value("s1"));
  108 + }
  109 +
  110 + @Test
  111 + void getUsers_withToken_returns200() throws Exception {
  112 + PageVO<UserListItemVO> page = new PageVO<>();
  113 + page.setTotal(0);
  114 + page.setPage(1);
  115 + page.setPageSize(20);
  116 + page.setList(List.of());
  117 + when(userService.getUserList(any(UserListQueryDTO.class), anyString())).thenReturn(page);
  118 +
  119 + mockMvc.perform(get("/api/usr/users").with(superAdmin()))
  120 + .andExpect(status().isOk())
  121 + .andExpect(jsonPath("$.code").value(200))
  122 + .andExpect(jsonPath("$.data.total").value(0))
  123 + .andExpect(jsonPath("$.data.list").isArray());
  124 + }
  125 +
  126 + @Test
  127 + void getUsers_noAuth_returns401() throws Exception {
  128 + mockMvc.perform(get("/api/usr/users"))
  129 + .andExpect(status().isUnauthorized());
  130 + }
  131 +
  132 + @Test
  133 + void updateUser_withToken_returns200() throws Exception {
  134 + UserUpdateRespVO resp = new UserUpdateRespVO();
  135 + resp.setUserId("u-target");
  136 + resp.setUsername("alice");
  137 + resp.setUpdatedAt(java.time.LocalDateTime.now());
  138 + when(userService.updateUser(anyString(), any(UserUpdateReqDTO.class), any())).thenReturn(resp);
  139 +
  140 + UserUpdateReqDTO body = new UserUpdateReqDTO();
  141 + body.setUserType("普通用户");
  142 + body.setLanguage("中文");
  143 + body.setPermGroupIds(List.of());
  144 +
  145 + mockMvc.perform(put("/api/usr/users/u-target").with(superAdmin())
  146 + .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
  147 + .content(objectMapper.writeValueAsString(body)))
  148 + .andExpect(status().isOk())
  149 + .andExpect(jsonPath("$.code").value(200))
  150 + .andExpect(jsonPath("$.data.userId").value("u-target"))
  151 + .andExpect(jsonPath("$.data.username").value("alice"));
  152 + }
  153 +
  154 + @Test
  155 + void updateUser_noAuth_returns401() throws Exception {
  156 + mockMvc.perform(put("/api/usr/users/u-target")
  157 + .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
  158 + .content("{}"))
  159 + .andExpect(status().isUnauthorized());
  160 + }
  161 +
  162 + @Test
  163 + void getPermissionGroups_returns200() throws Exception {
  164 + PermissionGroupVO g = new PermissionGroupVO();
  165 + g.setSId("g1");
  166 + g.setSGroupCode("usr:create");
  167 + g.setSGroupName("新增用户");
  168 + when(userService.getPermissionGroups(anyString())).thenReturn(List.of(g));
  169 +
  170 + mockMvc.perform(get("/api/usr/users/permission-groups").with(superAdmin()))
  171 + .andExpect(status().isOk())
  172 + .andExpect(jsonPath("$.code").value(200))
  173 + .andExpect(jsonPath("$.data[0].sGroupCode").value("usr:create"));
  174 + }
  175 +}
backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
  4 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  5 +import com.example.erp.common.exception.BizException;
  6 +import com.example.erp.common.vo.PageVO;
  7 +import com.example.erp.config.UserPrincipal;
  8 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  9 +import com.example.erp.module.usr.dto.UserListQueryDTO;
  10 +import com.example.erp.module.usr.dto.UserUpdateReqDTO;
  11 +import com.example.erp.module.usr.vo.UserListItemVO;
  12 +import com.example.erp.module.usr.vo.UserUpdateRespVO;
  13 +import com.example.erp.module.usr.entity.StaffEntity;
  14 +import com.example.erp.module.usr.entity.UsrUserEntity;
  15 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  16 +import com.example.erp.module.usr.mapper.PermissionGroupMapper;
  17 +import com.example.erp.module.usr.mapper.StaffMapper;
  18 +import com.example.erp.common.constants.UsrErrorCode;
  19 +import com.example.erp.module.usr.mapper.UserPermissionMapper;
  20 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  21 +import com.example.erp.module.usr.service.impl.UserServiceImpl;
  22 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  23 +import org.junit.jupiter.api.BeforeEach;
  24 +import org.junit.jupiter.api.Test;
  25 +import org.junit.jupiter.api.extension.ExtendWith;
  26 +import org.mockito.InjectMocks;
  27 +import org.mockito.Mock;
  28 +import org.mockito.junit.jupiter.MockitoExtension;
  29 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  30 +
  31 +import java.util.List;
  32 +
  33 +import static org.junit.jupiter.api.Assertions.*;
  34 +import static org.mockito.ArgumentMatchers.*;
  35 +import static org.mockito.Mockito.*;
  36 +
  37 +@ExtendWith(MockitoExtension.class)
  38 +class UserServiceTest {
  39 +
  40 + @Mock private UsrUserMapper userMapper;
  41 + @Mock private StaffMapper staffMapper;
  42 + @Mock private PermissionGroupMapper permGroupMapper;
  43 + @Mock private UserPermissionMapper userPermissionMapper;
  44 + @Mock private BCryptPasswordEncoder passwordEncoder;
  45 +
  46 + @InjectMocks
  47 + private UserServiceImpl userService;
  48 +
  49 + private UserCreateReqDTO req;
  50 + private UserPrincipal superAdmin;
  51 + private UserPrincipal normalUser;
  52 +
  53 + @BeforeEach
  54 + void setUp() {
  55 + req = new UserCreateReqDTO();
  56 + req.setUserCode("UC001");
  57 + req.setUsername("testuser");
  58 + req.setUserType("普通用户");
  59 + req.setLanguage("中文");
  60 +
  61 + superAdmin = new UserPrincipal("u1", "admin", "超级管理员", "b1");
  62 + normalUser = new UserPrincipal("u2", "user", "普通用户", "b1");
  63 + }
  64 +
  65 + @Test
  66 + void createUser_normalUser_throws40300() {
  67 + BizException ex = assertThrows(BizException.class,
  68 + () -> userService.createUser(req, normalUser));
  69 + assertEquals(40300, ex.getCode());
  70 + }
  71 +
  72 + @Test
  73 + void createUser_success_insertsUserAndReturnsVO() {
  74 + when(userMapper.selectCount(any())).thenReturn(0L);
  75 + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed");
  76 + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1);
  77 +
  78 + UserCreateRespVO vo = userService.createUser(req, superAdmin);
  79 +
  80 + assertNotNull(vo.getUserId());
  81 + assertEquals("UC001", vo.getUserCode());
  82 + assertEquals("testuser", vo.getUsername());
  83 + verify(userMapper).insert(any(UsrUserEntity.class));
  84 + }
  85 +
  86 + @Test
  87 + void createUser_duplicateUserCode_throws40902() {
  88 + when(userMapper.selectCount(any())).thenReturn(1L);
  89 +
  90 + BizException ex = assertThrows(BizException.class,
  91 + () -> userService.createUser(req, superAdmin));
  92 + assertEquals(40902, ex.getCode());
  93 + }
  94 +
  95 + @Test
  96 + void createUser_duplicateUsername_throws40901() {
  97 + when(userMapper.selectCount(any())).thenReturn(0L, 1L);
  98 +
  99 + BizException ex = assertThrows(BizException.class,
  100 + () -> userService.createUser(req, superAdmin));
  101 + assertEquals(40901, ex.getCode());
  102 + }
  103 +
  104 + @Test
  105 + @SuppressWarnings("unchecked")
  106 + void createUser_withPermGroups_insertsPermissions() {
  107 + req.setPermGroupIds(List.of("g1", "g2"));
  108 + when(userMapper.selectCount(any())).thenReturn(0L);
  109 + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed");
  110 + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1);
  111 +
  112 + userService.createUser(req, superAdmin);
  113 +
  114 + verify(userPermissionMapper).insert(argThat((java.util.Collection<UserPermissionEntity> c) -> c.size() == 2));
  115 + }
  116 +
  117 + @Test
  118 + void getUserList_queryDtoDefaults() {
  119 + UserListQueryDTO q = new UserListQueryDTO();
  120 + assertEquals("username", q.getQueryField());
  121 + assertEquals("contains", q.getMatchType());
  122 + assertEquals(20, q.getPageSize());
  123 + assertEquals(1, q.getPage());
  124 + }
  125 +
  126 + @Test
  127 + void getUserList_capsPageSizeAt100_callsMapper() {
  128 + IPage<UserListItemVO> mockPage = new Page<>(1, 100, 5);
  129 + when(userMapper.selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any()))
  130 + .thenReturn(mockPage);
  131 +
  132 + UserListQueryDTO q = new UserListQueryDTO();
  133 + q.setPageSize(200);
  134 + PageVO<UserListItemVO> result = userService.getUserList(q, "b1");
  135 +
  136 + assertEquals(5L, result.getTotal());
  137 + verify(userMapper).selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any());
  138 + }
  139 +
  140 + @Test
  141 + void getUserList_emptyQueryValue_doesNotFilter() {
  142 + IPage<UserListItemVO> mockPage = new Page<>(1, 20, 2);
  143 + when(userMapper.selectUserList(any(), eq("b1"), eq("username"), eq("contains"), eq("")))
  144 + .thenReturn(mockPage);
  145 +
  146 + UserListQueryDTO q = new UserListQueryDTO();
  147 + q.setQueryValue(null);
  148 + PageVO<UserListItemVO> result = userService.getUserList(q, "b1");
  149 +
  150 + assertEquals(2L, result.getTotal());
  151 + }
  152 +
  153 + @Test
  154 + void createUser_invalidEmployeeId_throws40001() {
  155 + req.setEmployeeId("bad-staff-id");
  156 + when(userMapper.selectCount(any())).thenReturn(0L);
  157 + when(staffMapper.selectOne(any())).thenReturn(null);
  158 +
  159 + BizException ex = assertThrows(BizException.class,
  160 + () -> userService.createUser(req, superAdmin));
  161 + assertEquals(UsrErrorCode.EMPLOYEE_NOT_FOUND, ex.getCode());
  162 + }
  163 +
  164 + @Test
  165 + void updateUser_dtoDefaults() {
  166 + com.example.erp.module.usr.dto.UserUpdateReqDTO dto = new com.example.erp.module.usr.dto.UserUpdateReqDTO();
  167 + dto.setUserType("普通用户");
  168 + dto.setLanguage("中文");
  169 + dto.setCanEditDoc(false);
  170 + dto.setDisabled(false);
  171 + dto.setEmployeeId(null);
  172 + dto.setPermGroupIds(List.of());
  173 +
  174 + assertEquals("普通用户", dto.getUserType());
  175 + assertEquals("中文", dto.getLanguage());
  176 + assertFalse(dto.isCanEditDoc());
  177 + assertFalse(dto.isDisabled());
  178 + assertNull(dto.getEmployeeId());
  179 + assertTrue(dto.getPermGroupIds().isEmpty());
  180 +
  181 + com.example.erp.module.usr.vo.UserUpdateRespVO resp = new com.example.erp.module.usr.vo.UserUpdateRespVO();
  182 + resp.setUserId("u1");
  183 + resp.setUsername("alice");
  184 + resp.setUpdatedAt(java.time.LocalDateTime.now());
  185 + assertEquals("u1", resp.getUserId());
  186 + assertEquals("alice", resp.getUsername());
  187 + assertNotNull(resp.getUpdatedAt());
  188 +
  189 + assertEquals(40400, UsrErrorCode.USER_NOT_FOUND);
  190 + assertEquals(40301, UsrErrorCode.SELF_ADMIN_CHANGE);
  191 + }
  192 +
  193 + @Test
  194 + void updateUser_nonAdmin_throws40300() {
  195 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  196 + dto.setUserType("普通用户");
  197 + dto.setLanguage("中文");
  198 + dto.setPermGroupIds(List.of());
  199 +
  200 + BizException ex = assertThrows(BizException.class,
  201 + () -> userService.updateUser("u-target", dto, normalUser));
  202 + assertEquals(UsrErrorCode.PERMISSION_DENIED, ex.getCode());
  203 + }
  204 +
  205 + @Test
  206 + void updateUser_userNotFound_throws40400() {
  207 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  208 + dto.setUserType("普通用户");
  209 + dto.setLanguage("中文");
  210 + dto.setPermGroupIds(List.of());
  211 + when(userMapper.selectOne(any())).thenReturn(null);
  212 +
  213 + BizException ex = assertThrows(BizException.class,
  214 + () -> userService.updateUser("u-target", dto, superAdmin));
  215 + assertEquals(UsrErrorCode.USER_NOT_FOUND, ex.getCode());
  216 + }
  217 +
  218 + @Test
  219 + void updateUser_selfAdminChange_throws40301() {
  220 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  221 + dto.setUserType("普通用户");
  222 + dto.setLanguage("中文");
  223 + dto.setPermGroupIds(List.of());
  224 + UsrUserEntity existing = new UsrUserEntity();
  225 + existing.setSId("u1");
  226 + existing.setSUsername("admin");
  227 + when(userMapper.selectOne(any())).thenReturn(existing);
  228 +
  229 + // superAdmin.userId() == "u1", target userId == "u1" → self-admin change
  230 + BizException ex = assertThrows(BizException.class,
  231 + () -> userService.updateUser("u1", dto, superAdmin));
  232 + assertEquals(UsrErrorCode.SELF_ADMIN_CHANGE, ex.getCode());
  233 + }
  234 +
  235 + @Test
  236 + void updateUser_selfAdminKeepType_doesNotThrow() {
  237 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  238 + dto.setUserType("超级管理员");
  239 + dto.setLanguage("中文");
  240 + dto.setPermGroupIds(List.of());
  241 + UsrUserEntity existing = new UsrUserEntity();
  242 + existing.setSId("u1");
  243 + existing.setSUsername("admin");
  244 + when(userMapper.selectOne(any())).thenReturn(existing);
  245 + when(userMapper.update(any(), any())).thenReturn(1);
  246 +
  247 + // 自己保持 超级管理员 角色 → 不应抛 40301
  248 + assertDoesNotThrow(() -> userService.updateUser("u1", dto, superAdmin));
  249 + }
  250 +
  251 + @Test
  252 + void updateUser_invalidEmployee_throws40001() {
  253 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  254 + dto.setUserType("普通用户");
  255 + dto.setLanguage("中文");
  256 + dto.setEmployeeId("bad-emp");
  257 + dto.setPermGroupIds(List.of());
  258 + UsrUserEntity existing = new UsrUserEntity();
  259 + existing.setSId("u-target");
  260 + existing.setSUsername("alice");
  261 + when(userMapper.selectOne(any())).thenReturn(existing);
  262 + when(staffMapper.selectOne(any())).thenReturn(null);
  263 +
  264 + BizException ex = assertThrows(BizException.class,
  265 + () -> userService.updateUser("u-target", dto, superAdmin));
  266 + assertEquals(UsrErrorCode.EMPLOYEE_NOT_FOUND, ex.getCode());
  267 + }
  268 +
  269 + @Test
  270 + void updateUser_happyPath_updatesAndReturnsResp() {
  271 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  272 + dto.setUserType("超级管理员");
  273 + dto.setLanguage("英文");
  274 + dto.setCanEditDoc(true);
  275 + dto.setDisabled(false);
  276 + dto.setPermGroupIds(List.of("g1", "g2"));
  277 + UsrUserEntity existing = new UsrUserEntity();
  278 + existing.setSId("u-target");
  279 + existing.setSUsername("alice");
  280 + when(userMapper.selectOne(any())).thenReturn(existing);
  281 + when(userMapper.update(any(), any())).thenReturn(1);
  282 + when(userPermissionMapper.delete(any())).thenReturn(0);
  283 +
  284 + UserUpdateRespVO resp = userService.updateUser("u-target", dto, superAdmin);
  285 +
  286 + assertNotNull(resp);
  287 + assertEquals("u-target", resp.getUserId());
  288 + assertEquals("alice", resp.getUsername());
  289 + assertNotNull(resp.getUpdatedAt());
  290 + verify(userMapper).update(any(), any());
  291 + verify(userPermissionMapper).delete(any());
  292 + verify(userPermissionMapper).insert(argThat((java.util.Collection<UserPermissionEntity> c) -> c.size() == 2));
  293 + }
  294 +
  295 + @Test
  296 + void updateUser_emptyPermGroupIds_onlyDeletes() {
  297 + UserUpdateReqDTO dto = new UserUpdateReqDTO();
  298 + dto.setUserType("普通用户");
  299 + dto.setLanguage("中文");
  300 + dto.setPermGroupIds(List.of());
  301 + UsrUserEntity existing = new UsrUserEntity();
  302 + existing.setSId("u-target");
  303 + existing.setSUsername("bob");
  304 + when(userMapper.selectOne(any())).thenReturn(existing);
  305 + when(userMapper.update(any(), any())).thenReturn(1);
  306 + when(userPermissionMapper.delete(any())).thenReturn(0);
  307 +
  308 + userService.updateUser("u-target", dto, superAdmin);
  309 +
  310 + verify(userPermissionMapper).delete(any());
  311 + verify(userPermissionMapper, never()).insert(anyList());
  312 + }
  313 +}
backend/src/test/resources/application-test.yml 0 → 100644
  1 +spring:
  2 + datasource:
  3 + url: jdbc:mysql://${DB_HOST:118.178.19.35}:${DB_PORT:3318}/${DB_SCHEMA:xlyweberp_vibe_erp_test}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=UTF-8
  4 + username: ${DB_USER:xlyprint}
  5 + password: ${DB_PASSWORD:xlyXLYprint2016}
  6 + driver-class-name: com.mysql.cj.jdbc.Driver
  7 + flyway:
  8 + baseline-on-migrate: true
  9 + baseline-version: 1
  10 + validate-on-migrate: false
  11 +
  12 +jwt:
  13 + secret: ${JWT_SECRET:a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2}
  14 + access-token-expiry: 86400
  15 + refresh-token-expiry: 604800
  16 +
  17 +logging:
  18 + level:
  19 + com.example.erp: DEBUG
docs/03-数据库设计文档.md
@@ -64,7 +64,7 @@ usr_user(用户主表) @@ -64,7 +64,7 @@ usr_user(用户主表)
64 64
65 ### 索引 65 ### 索引
66 66
67 -- `uk_usr_user_username` (UNIQUE): `sUsername` — 全局唯一约束 67 +- `uk_usr_user_username_tenant` (UNIQUE): `(sUsername, sBrandsId)` — 用户名在同一 brand 内唯一(V2 迁移:原全局唯一改为多租户复合唯一)
68 - `uk_usr_user_usercode` (UNIQUE): `sUserCode` — 用户号唯一约束 68 - `uk_usr_user_usercode` (UNIQUE): `sUserCode` — 用户号唯一约束
69 - `idx_usr_user_tenant` (INDEX): `sBrandsId, sSubsidiaryId` — 多租户隔离查询 69 - `idx_usr_user_tenant` (INDEX): `sBrandsId, sSubsidiaryId` — 多租户隔离查询
70 - `idx_usr_user_type` (INDEX): `sUserType` — 按用户类型过滤 70 - `idx_usr_user_type` (INDEX): `sUserType` — 按用户类型过滤
docs/04-技术规范.md
@@ -147,8 +147,8 @@ type 取值:`feat` / `fix` / `refactor` / `test` / `docs` / `chore` @@ -147,8 +147,8 @@ type 取值:`feat` / `fix` / `refactor` / `test` / `docs` / `chore`
147 147
148 ### 3.2 分页查询 148 ### 3.2 分页查询
149 149
150 -- **入参**:`pageNum`(从 1 起)、`pageSize`(默认 20,最大 100)  
151 -- **出参**:`{ list: [], total: N, pageNum: N, pageSize: N }` 150 +- **入参**:`page`(从 1 起)、`pageSize`(默认 20,最大 100)
  151 +- **出参**:`{ list: [], total: N, page: N, pageSize: N }`
152 - 后端使用 MyBatis-Plus `Page<T>` 对象;前端使用 Ant Design `Table` 的 `pagination` 属性 152 - 后端使用 MyBatis-Plus `Page<T>` 对象;前端使用 Ant Design `Table` 的 `pagination` 属性
153 153
154 ### 3.3 日期与金额 154 ### 3.3 日期与金额
docs/05-API接口契约.md
@@ -24,7 +24,7 @@ BasePath: `/api` @@ -24,7 +24,7 @@ BasePath: `/api`
24 除登录接口外,所有接口均需在请求头携带 `Authorization: Bearer <access_token>`。Token 由 REQ-USR-004 登录接口签发,有效期 24 小时。 24 除登录接口外,所有接口均需在请求头携带 `Authorization: Bearer <access_token>`。Token 由 REQ-USR-004 登录接口签发,有效期 24 小时。
25 25
26 ### 分页参数 26 ### 分页参数
27 -列表查询接口统一使用以下分页入参:`pageNum`(页码,从 1 起)、`pageSize`(每页条数,默认 20,最大 100)。响应体 `data` 字段格式:`{ list: [], total: N, pageNum: N, pageSize: N }`。 27 +列表查询接口统一使用以下分页入参:`page`(页码,从 1 起)、`pageSize`(每页条数,默认 20,最大 100)。响应体 `data` 字段格式:`{ list: [], total: N, page: N, pageSize: N }`。
28 28
29 ## 接口清单 29 ## 接口清单
30 (各模块接口段落见下方,由 `downstream-gen` 按 REQ 填入) 30 (各模块接口段落见下方,由 `downstream-gen` 按 REQ 填入)
@@ -73,8 +73,8 @@ BasePath: `/api` @@ -73,8 +73,8 @@ BasePath: `/api`
73 - **Path**: `/api/usr/users` 73 - **Path**: `/api/usr/users`
74 - **Auth**: Bearer Token 74 - **Auth**: Bearer Token
75 - **Permission**: `usr:query` 75 - **Permission**: `usr:query`
76 -- **请求**: Query 参数:`field=用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人`、`matchMode=包含|不包含|等于`、`value=string`、`pageNum=int`、`pageSize=int`  
77 -- **响应**: `{ list: [{ "userId", "username", "staffName", "userCode", "department", "userType", "language", "isDisabled", "lastLoginDate", "creatorUsername", "createDate" }], total, pageNum, pageSize }` — 密码字段不返回 76 +- **请求**: Query 参数:`queryField=username|staffName|userCode|department|userType|disabled|lastLoginDate|creator`(默认 username)、`matchType=contains|notContains|equals`(默认 contains)、`queryValue=string`、`page=int`(默认 1)、`pageSize=int`(默认 20,最大 100)
  77 +- **响应**: `{ list: [{ "sId", "sUsername", "sUserCode", "sUserType", "sLanguage", "bIsDisabled", "tLastLoginDate", "sCreatorUsername", "tCreateDate", "sStaffName", "sDepartment" }], total, page, pageSize }` — sPasswordHash / iLoginFailCount / tLockUntil 不返回
78 78
79 #### 错误码 79 #### 错误码
80 - `40001` — 参数校验失败 80 - `40001` — 参数校验失败
docs/08-模块任务管理.md
@@ -58,9 +58,9 @@ @@ -58,9 +58,9 @@
58 - module_usr 用户管理 58 - module_usr 用户管理
59 - 依赖: — 59 - 依赖: —
60 - 路径: backend/module/usr/, frontend/pages/usr/ 60 - 路径: backend/module/usr/, frontend/pages/usr/
61 - - MR: 61 + - MR: !1
62 - 功能: 62 - 功能:
63 - - [ ] REQ-USR-004 用户登录  
64 - - [ ] REQ-USR-001 增加用户  
65 - - [ ] REQ-USR-003 查询用户  
66 - - [ ] REQ-USR-002 修改用户 63 + - [x] REQ-USR-004 用户登录
  64 + - [x] REQ-USR-001 增加用户
  65 + - [x] REQ-USR-003 查询用户
  66 + - [x] REQ-USR-002 修改用户
docs/superpowers/module-reports/2026-05-08-module_usr.md 0 → 100644
  1 +---
  2 +module_id: module_usr
  3 +date: 2026-05-08
  4 +git_range: master..module-module_usr
  5 +---
  6 +
  7 +# 模块完成报告 — module_usr 用户管理
  8 +
  9 +## ① 模块信息
  10 +- 模块 ID: module_usr
  11 +- 模块名: 用户管理
  12 +- 开发区间: master..module-module_usr(共 29 commits)
  13 +
  14 +## ② REQ 完成清单
  15 +
  16 +- [x] REQ-USR-004 — 用户登录
  17 + - spec: docs/superpowers/specs/2026-05-08-REQ-USR-004.md
  18 + - plan: docs/superpowers/plans/2026-05-08-REQ-USR-004.md
  19 + - review: docs/superpowers/reviews/2026-05-08-REQ-USR-004.md
  20 +
  21 +- [x] REQ-USR-001 — 增加用户
  22 + - spec: docs/superpowers/specs/2026-05-08-REQ-USR-001.md
  23 + - plan: docs/superpowers/plans/2026-05-08-REQ-USR-001.md
  24 + - review: docs/superpowers/reviews/2026-05-08-REQ-USR-001.md
  25 +
  26 +- [x] REQ-USR-003 — 查询用户
  27 + - spec: docs/superpowers/specs/2026-05-08-REQ-USR-003.md
  28 + - plan: docs/superpowers/plans/2026-05-08-REQ-USR-003.md
  29 + - review: docs/superpowers/reviews/2026-05-08-REQ-USR-003.md
  30 +
  31 +- [x] REQ-USR-002 — 修改用户
  32 + - spec: docs/superpowers/specs/2026-05-08-REQ-USR-002.md
  33 + - plan: docs/superpowers/plans/2026-05-08-REQ-USR-002.md
  34 + - review: docs/superpowers/reviews/2026-05-08-REQ-USR-002.md
  35 +
  36 +## ③ 文件变更表
  37 +
  38 +| 文件 | 操作 | 说明 |
  39 +|---|---|---|
  40 +| backend/pom.xml | 新增 | Spring Boot 3 项目配置,含 checkstyle 插件 |
  41 +| backend/checkstyle.xml | 新增 | 项目 checkstyle 规则(Spring Boot 友好) |
  42 +| backend/src/main/java/com/example/erp/common/\* | 新增 | Result/BizException/GlobalExceptionHandler/JwtUtil/PageVO/错误码常量 |
  43 +| backend/src/main/java/com/example/erp/config/\* | 新增 | SecurityConfig/JwtFilter/JwtProperties/UserPrincipal/BeanConfig/MybatisPlusConfig |
  44 +| backend/src/main/java/com/example/erp/module/usr/controller/\* | 新增 | AuthController(登录/刷新/品牌列表)、UserController(CRUD 接口) |
  45 +| backend/src/main/java/com/example/erp/module/usr/service/\* | 新增 | AuthService/AuthServiceImpl/UserService/UserServiceImpl |
  46 +| backend/src/main/java/com/example/erp/module/usr/entity/\* | 新增 | BrandEntity/UsrUserEntity/StaffEntity/PermissionGroupEntity/UserPermissionEntity |
  47 +| backend/src/main/java/com/example/erp/module/usr/mapper/\* | 新增 | BrandMapper/UsrUserMapper/StaffMapper/PermissionGroupMapper/UserPermissionMapper |
  48 +| backend/src/main/java/com/example/erp/module/usr/dto/\* | 新增 | LoginReqDTO/RefreshTokenReqDTO/UserCreateReqDTO/UserListQueryDTO/UserUpdateReqDTO |
  49 +| backend/src/main/java/com/example/erp/module/usr/vo/\* | 新增 | LoginVO/BrandVO/StaffVO/PermissionGroupVO/UserCreateRespVO/UserListItemVO/UserUpdateRespVO |
  50 +| backend/src/main/resources/mapper/UsrUserMapper.xml | 新增 | selectUserList 动态查询 SQL |
  51 +| backend/src/main/resources/db/migration/V1\_\_initial\_schema.sql | 新增 | 初始全量 schema |
  52 +| backend/src/main/resources/db/migration/V2\_\_fix\_username\_unique\_per\_tenant.sql | 新增 | 用户名唯一索引改为租户范围内唯一 |
  53 +| backend/src/test/\* | 新增 | 49 个测试(ApplicationContext/JwtUtil/Result/AuthService/AuthController/UserService/UserController/BrandMapper) |
  54 +| frontend/src/api/auth.ts | 新增 | 登录/刷新 API 函数 |
  55 +| frontend/src/api/request.ts | 新增 | Axios 请求拦截器(含 JWT 自动注入) |
  56 +| frontend/src/api/usr.ts | 新增 | 用户增/查/改 API 接口及类型定义 |
  57 +| frontend/src/pages/usr/LoginPage.tsx | 新增 | 登录页(多品牌选择) |
  58 +| frontend/src/pages/usr/UserFormDrawer.tsx | 新增 | 新增/修改用户抽屉(复用组件) |
  59 +| frontend/src/pages/usr/UserListPage.tsx | 新增 | 用户列表(搜索+分页+操作列) |
  60 +| frontend/src/store/\* | 新增 | Redux auth 切片(credentials/userInfo) |
  61 +| frontend/src/components/PermButton.tsx | 新增 | 基于用户类型的权限按钮组件 |
  62 +| frontend/src/test/\* | 新增 | 10 个前端测试(authSlice/LoginPage/UserListPage) |
  63 +| frontend/package.json | 修改 | lint 脚本改为 tsc --noEmit(eslint 未安装) |
  64 +| docs/03-数据库设计文档.md | 修改 | 同步 V2 migration 唯一索引变更 |
  65 +| docs/04-技术规范.md | 修改 | 分页入参规范统一为 page(原 pageNum) |
  66 +| docs/05-API接口契约.md | 修改 | 分页规范同步更新 |
  67 +| docs/08-模块任务管理.md | 修改 | 4 个 REQ 全部勾选 [x] |
  68 +| scripts/test.sh | 修改 | 添加 Java 21 PATH 适配(Lombok 兼容性) |
  69 +
  70 +## ④ 数据库使用表
  71 +- 读: `brand`(登录品牌列表)、`tStaff`(员工下拉)、`usr_permission_group`(权限组下拉)
  72 +- 写: `usr_user`(登录时更新 tLastLoginDate/iLoginFailCount/tLockUntil;新增/修改用户字段)、`usr_user_permission`(新增/修改时先删后插)
  73 +
  74 +## ⑤ 测试结果
  75 +- `scripts/test.sh` 最终:green
  76 +- 通过: 59 / 失败: 0 / 跳过: 0
  77 +- 覆盖率: 未配置覆盖率工具(所有业务路径已有对应测试用例)
  78 +
  79 +## ⑥ 本模块新增 Migration
  80 +
  81 +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` — 全量初始 schema(usr_user / tStaff / usr_permission_group / usr_user_permission / brand 五张表)
  82 +- `backend/src/main/resources/db/migration/V2__fix_username_unique_per_tenant.sql` — 将 uk_usr_user_username 从全局唯一改为租户范围内唯一(允许不同 brand 使用相同用户名)
  83 +
  84 +## ⑦ 跨模块改动清单(软规则 S2)
  85 +
  86 +本项目当前仅含 module_usr 一个模块,无跨模块改动。
  87 +
  88 +改动了两处项目级规范文档:
  89 +- `docs/04-技术规范.md § 3.2`:分页入参统一为 `page`(REQ-USR-003 review 发现 `pageNum` 与实现不一致,修正文档)
  90 +- `docs/05-API接口契约.md`:分页规范同步更新
  91 +
  92 +## ⑧ 偏离 spec 清单
  93 +
  94 +- REQ-USR-002:`UserListPage` 传给编辑抽屉的 `initialData` 中 `permGroupIds` 字段未传值(始终为空数组)。原因:用户列表 API 不返回每用户的权限组关联,无法在列表页预填。规格 § 前端交互设计 line 103 已明确说明"初始化为空数组即可,用户可重新勾选",属有意决策而非实现缺陷。
  95 +
  96 +## ⑨ AI reviewer 报告汇总
  97 +
  98 +- REQ-USR-004: round 2 — approve(link: docs/superpowers/reviews/2026-05-08-REQ-USR-004.md)
  99 +- REQ-USR-001: round 2 — approve(link: docs/superpowers/reviews/2026-05-08-REQ-USR-001.md)
  100 +- REQ-USR-003: round 4 — approve(link: docs/superpowers/reviews/2026-05-08-REQ-USR-003.md)
  101 +- REQ-USR-002: round 2 — approve(link: docs/superpowers/reviews/2026-05-08-REQ-USR-002.md)
  102 +
  103 +## ⑩ 已知问题
  104 +
  105 +1. **前端 lint 工具缺失**:`eslint` 未在 `package.json` 中声明为 devDependency,lint 脚本临时改为 `tsc --noEmit`。建议后续补充 ESLint + TypeScript ESLint 插件配置。
  106 +2. **`updatedAt` 序列化格式**:`UserUpdateRespVO.updatedAt` 未加 `@JsonFormat` 注解,依赖 Jackson 全局配置。如未配置全局 LocalDateTime 序列化器,返回值可能为数组格式而非 ISO-8601 字符串。
  107 +3. **controller 层 BizException 错误码映射测试缺失**:`UserControllerTest` 未覆盖 40300/40400/40301 通过 `GlobalExceptionHandler` 映射为 JSON 错误码的路径(低风险,handler 已通过 createUser 路径间接覆盖)。
  108 +
  109 +## ⑪ 下一模块预览
  110 +
  111 +当前项目 `docs/02-开发计划.md` 仅包含 module_usr 一个模块,无后续模块。本次开发计划全部完成。
  112 +
  113 +## ⑫ MR 链接
  114 +
  115 +http://git.xlyprint.cn/zhuzc/test3/-/merge_requests/1
docs/superpowers/module-reports/module_usr-test-gate.md 0 → 100644
  1 +## Local test gate — module_usr
  2 +
  3 +执行时间: 2026-05-08T12:40:11+08:00
  4 +
  5 +### scripts/test.sh (subagent)
  6 +- 子会话: a5ec519c45282bef8
  7 +- 命令: ./scripts/test.sh
  8 +- 退出码: 0
  9 +- 通过: 59 / 失败: 0
  10 +- 关键 stdout (≤30 行):
  11 +
  12 +```
  13 +[test.sh] 1/6 setup test db
  14 +[test.sh] 2/6 build
  15 +[test.sh] 3/6 lint
  16 + Backend: 0 Checkstyle violations — BUILD SUCCESS
  17 + Frontend: tsc --noEmit (0 errors)
  18 +[test.sh] 4/6 unit + integration
  19 + Backend: Tests run: 49, Failures: 0, Errors: 0, Skipped: 0
  20 + Frontend: Test Files 3 passed (3), Tests 10 passed (10)
  21 +[test.sh] 5/6 E2E
  22 +[test.sh] e2e 略
  23 +[test.sh] 6/6 reset test db
  24 +[test.sh] GREEN
  25 +```
  26 +
  27 +结论: green
docs/superpowers/plans/2026-05-08-REQ-USR-001.md 0 → 100644
  1 +# REQ-USR-001 增加用户 Implementation Plan
  2 +
  3 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  4 +
  5 +**Goal:** 超级管理员通过 POST /api/usr/users 新建用户账号,系统自动初始化密码、写入权限关联;前端提供「用户管理」页+新增 Drawer 表单。
  6 +
  7 +**Architecture:** Spring Boot 后端新增 UserController(三个端点:createUser / getStaffs / getPermissionGroups)+ UserServiceImpl(业务逻辑+事务)+ 三个辅助 Entity/Mapper(StaffEntity、PermissionGroupEntity、UserPermissionEntity)。JwtAuthenticationFilter 升级为存储 UserPrincipal record,使 Controller 通过 @AuthenticationPrincipal 取到 brandId / username / userType。前端新增 api/usr.ts + UserListPage.tsx + UserFormDrawer.tsx,在 App.tsx 补充 /usr/users 路由。
  8 +
  9 +**Tech Stack:** Spring Boot 3.3.5 + MyBatis-Plus 3.5.7 + Lombok + Spring Security + spring-security-test;React 18 + Ant Design 5 + Vitest + @testing-library/react
  10 +
  11 +---
  12 +
  13 +## 文件映射
  14 +
  15 +**新建**:
  16 +- `backend/.../config/UserPrincipal.java` — JWT principal record(userId, username, userType, brandId)
  17 +- `backend/.../module/usr/entity/StaffEntity.java` — 映射 tStaff
  18 +- `backend/.../module/usr/entity/PermissionGroupEntity.java` — 映射 usr_permission_group
  19 +- `backend/.../module/usr/entity/UserPermissionEntity.java` — 映射 usr_user_permission
  20 +- `backend/.../module/usr/mapper/StaffMapper.java`
  21 +- `backend/.../module/usr/mapper/PermissionGroupMapper.java`
  22 +- `backend/.../module/usr/mapper/UserPermissionMapper.java`
  23 +- `backend/.../common/constants/UsrErrorCode.java` — 40300/40901/40902
  24 +- `backend/.../module/usr/dto/UserCreateReqDTO.java`
  25 +- `backend/.../module/usr/vo/UserCreateRespVO.java`
  26 +- `backend/.../module/usr/vo/StaffVO.java`
  27 +- `backend/.../module/usr/vo/PermissionGroupVO.java`
  28 +- `backend/.../module/usr/service/UserService.java`
  29 +- `backend/.../module/usr/service/impl/UserServiceImpl.java`
  30 +- `backend/.../module/usr/controller/UserController.java`
  31 +- `backend/.../module/usr/UserServiceTest.java`
  32 +- `backend/.../module/usr/UserControllerTest.java`
  33 +- `frontend/src/api/usr.ts`
  34 +- `frontend/src/pages/usr/UserListPage.tsx`
  35 +- `frontend/src/pages/usr/UserFormDrawer.tsx`
  36 +- `frontend/src/test/UserListPage.test.tsx`
  37 +
  38 +**修改**:
  39 +- `backend/.../config/JwtAuthenticationFilter.java` — 存 UserPrincipal 而非 String(CLAUDE.md S2,必要基础设施)
  40 +- `frontend/src/App.tsx` — 补 /usr/users 路由
  41 +
  42 +---
  43 +
  44 +## 合同级常量
  45 +
  46 +**UsrErrorCode**:
  47 +- `PERMISSION_DENIED = 40300`
  48 +- `USERNAME_EXISTS = 40901`
  49 +- `USER_CODE_EXISTS = 40902`
  50 +
  51 +**UsrErrorCode 用法**:`throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足")`
  52 +
  53 +**UserPrincipal(Java record)**:
  54 +```java
  55 +package com.example.erp.config;
  56 +public record UserPrincipal(String userId, String username, String userType, String brandId) {}
  57 +```
  58 +
  59 +**JWT claim 名**(来自 JwtUtil.generateAccessToken):
  60 +- subject → userId
  61 +- `"username"` → username
  62 +- `"userType"` → userType
  63 +- `"brandId"` → brandId
  64 +
  65 +---
  66 +
  67 +### Task 1: UserPrincipal + JwtAuthenticationFilter 升级(跨模块基础设施 S2)
  68 +
  69 +**Files:**
  70 +- Create: `backend/src/main/java/com/example/erp/config/UserPrincipal.java`
  71 +- Modify: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  72 +
  73 +**API shape:**
  74 +- `record UserPrincipal(String userId, String username, String userType, String brandId) {}`
  75 +- `JwtAuthenticationFilter.doFilterInternal()` — 解析 claims 后构造 `UserPrincipal`,存入 `UsernamePasswordAuthenticationToken` 的 principal
  76 +
  77 +- [ ] **Step 1: 写失败测试(在 UserControllerTest 中)**
  78 + - 文件:`backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  79 + - 仅写类骨架 + 一个空测试方法 `createUser_noToken_returns401()`,注解 `@WebMvcTest(controllers = UserController.class)`
  80 + - 此时编译失败(UserController 不存在)→ 子会话确认 FAIL
  81 +
  82 +- [ ] **Step 2: 实现 UserPrincipal + 修改 JwtAuthenticationFilter**
  83 + - 创建 `UserPrincipal.java` record(见合同级常量)
  84 + - 修改 `JwtAuthenticationFilter.doFilterInternal()`:
  85 + ```java
  86 + UserPrincipal principal = new UserPrincipal(
  87 + claims.getSubject(),
  88 + claims.get("username", String.class),
  89 + claims.get("userType", String.class),
  90 + claims.get("brandId", String.class)
  91 + );
  92 + UsernamePasswordAuthenticationToken auth =
  93 + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList());
  94 + SecurityContextHolder.getContext().setAuthentication(auth);
  95 + ```
  96 +
  97 +- [ ] **Step 3: 子会话验证已有测试仍通过**
  98 + - 命令:`JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home mvn test -pl backend -Dtest=AuthControllerTest,AuthServiceTest`
  99 + - 期待:全部 PASS
  100 +
  101 +- [ ] **Step 4: Commit**
  102 + - `git add backend/src/main/java/com/example/erp/config/UserPrincipal.java backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  103 + - `git commit -m "feat(usr): UserPrincipal record + JwtFilter升级存principal REQ-USR-001"`
  104 +
  105 +---
  106 +
  107 +### Task 2: 实体 + Mapper + 错误码 + DTO/VO 骨架
  108 +
  109 +**Files:**
  110 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java`
  111 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java`
  112 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java`
  113 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java`
  114 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java`
  115 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java`
  116 +- Create: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java`
  117 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java`
  118 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java`
  119 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java`
  120 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java`
  121 +
  122 +**StaffEntity 字段**(来自 docs/03 tStaff 表):`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sStaffNo`, `sStaffName`, `sDepartment`, `sCreatedBy`, `bDeleted(Bit/Integer)`, `tDeletedDate`, `sDeletedBy`;`@TableName("tStaff")`
  123 +
  124 +**PermissionGroupEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sGroupCode`, `sGroupName`, `sCategory`;`@TableName("usr_permission_group")`
  125 +
  126 +**UserPermissionEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sUserId`, `sPermGroupId`;`@TableName("usr_user_permission")`
  127 +
  128 +**UserCreateReqDTO 字段**(`@Valid` 注解):
  129 +```java
  130 +@NotBlank String userCode;
  131 +@NotBlank String username;
  132 +@NotBlank @Pattern(regexp = "普通用户|超级管理员") String userType;
  133 +@NotBlank @Pattern(regexp = "中文|英文|繁体") String language;
  134 +boolean canEditDoc = false;
  135 +String employeeId; // nullable
  136 +List<String> permGroupIds; // nullable
  137 +```
  138 +
  139 +**UserCreateRespVO**:`String userId, userCode, username`
  140 +**StaffVO**:`String sId, sStaffName`
  141 +**PermissionGroupVO**:`String sId, sGroupCode, sGroupName, sCategory`
  142 +
  143 +- [ ] **Step 1: 写失败测试**
  144 + - 在 `UserServiceTest.java` 中写类骨架 + 一个空测试 `createUser_normalUser_throws40300()`,引用 `UserService`(未创建)
  145 + - 子会话确认编译 FAIL
  146 +
  147 +- [ ] **Step 2: 创建所有文件**
  148 + - 按上方规格创建所有 entity / mapper / errorcode / dto / vo 文件
  149 + - 每个 mapper 继承 `BaseMapper<T>`
  150 + - 每个 entity 使用 Lombok `@Getter @Setter`
  151 +
  152 +- [ ] **Step 3: 子会话验证编译通过**
  153 + - 命令:`JAVA_HOME=... mvn compile -pl backend`
  154 +
  155 +- [ ] **Step 4: Commit**
  156 + - `git add backend/src/main/java/`
  157 + - `git commit -m "feat(usr): 实体/Mapper/DTO/VO骨架 + UsrErrorCode REQ-USR-001"`
  158 +
  159 +---
  160 +
  161 +### Task 3: UserServiceImpl — createUser 业务逻辑
  162 +
  163 +**Files:**
  164 +- Create: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  165 +- Create: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  166 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  167 +
  168 +**API shape:**
  169 +- `UserService#createUser(UserCreateReqDTO req, UserPrincipal principal) : UserCreateRespVO`
  170 +- `UserService#getStaffs(String brandId) : List<StaffVO>`
  171 +- `UserService#getPermissionGroups(String brandId) : List<PermissionGroupVO>`
  172 +
  173 +**createUser 逻辑序列**:
  174 +1. `!"超级管理员".equals(principal.userType())` → `throw new BizException(40300, "权限不足")`
  175 +2. `userMapper.selectCount(LQ...eq(sUserCode, req.getUserCode())) > 0` → `throw new BizException(40902, "用户号已存在")`
  176 +3. `userMapper.selectCount(LQ...eq(sUsername).eq(sBrandsId)) > 0` → `throw new BizException(40901, "用户名已存在")`
  177 +4. `req.getEmployeeId() != null` → `staffMapper.selectOne(LQ...eq(sId).eq(sBrandsId)) == null` → `throw new BizException(40001, "员工不存在")`
  178 +5. 构造 `UsrUserEntity`:sId=UUID,sBrandsId=principal.brandId(),sCreatorUsername=principal.username(),tCreateDate=now,sPasswordHash=passwordEncoder.encode("666666"),bIsDisabled=0,iLoginFailCount=0
  179 +6. `userMapper.insert(user)`
  180 +7. `permGroupIds` 非 null 且非空 → 循环 `userPermissionMapper.insert(new UserPermissionEntity(UUID, brandId, now, user.sId, groupId))`
  181 +8. 返回 `UserCreateRespVO(user.sId, user.sUserCode, user.sUsername)`
  182 +
  183 +**UserServiceImpl 依赖**:`@RequiredArgsConstructor`;注入 `UsrUserMapper`、`StaffMapper`、`PermissionGroupMapper`、`UserPermissionMapper`、`BCryptPasswordEncoder`
  184 +
  185 +- [ ] **Step 1: 写失败测试**
  186 + - `UserServiceTest.java` 完整写入下列测试(全部引用 `UserService` 接口),子会话确认失败(UserService 接口不存在):
  187 + - `createUser_normalUser_throws40300` — principal.userType=普通用户 → BizException(40300)
  188 + - `createUser_success_insertsUserAndReturnsVO` — all mocks pass → 验证 userMapper.insert 被调用,返回 VO 有 userId
  189 + - `createUser_duplicateUserCode_throws40902` — selectCount 第1次返回 1L → BizException(40902)
  190 + - `createUser_duplicateUsername_throws40901` — selectCount 第1次返回 0L,第2次返回 1L → BizException(40901)
  191 + - `createUser_withPermGroups_insertsPermissions` — permGroupIds=["g1","g2"] → verify userPermissionMapper.insert 被调用 2 次
  192 + - `createUser_invalidEmployeeId_throws40001` — staffMapper.selectOne 返回 null → BizException(40001)
  193 +
  194 +- [ ] **Step 2: 实现 UserService + UserServiceImpl**
  195 + - 创建 `UserService.java` 接口(三个方法签名)
  196 + - 创建 `UserServiceImpl.java`,`@Service @RequiredArgsConstructor`,按逻辑序列实现 `createUser()`
  197 + - `getStaffs(brandId)`: `staffMapper.selectList(LQ...eq(sBrandsId, brandId).eq(bDeleted, 0).select("sId","sStaffName"))` → 映射 `StaffVO`
  198 + - `getPermissionGroups(brandId)`: `permGroupMapper.selectList(LQ...eq(sBrandsId, brandId))` → 映射 `PermissionGroupVO`(如 brandId 空则 selectList(null))
  199 +
  200 +- [ ] **Step 3: 子会话验证 UserServiceTest 全部通过**
  201 + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserServiceTest`
  202 +
  203 +- [ ] **Step 4: Commit**
  204 + - `git add backend/src/`
  205 + - `git commit -m "feat(usr): UserServiceImpl.createUser + getStaffs + getPermissionGroups REQ-USR-001"`
  206 +
  207 +---
  208 +
  209 +### Task 4: UserController — 三个端点
  210 +
  211 +**Files:**
  212 +- Create: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  213 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  214 +
  215 +**API shape:**
  216 +```java
  217 +@RestController
  218 +@RequestMapping("/api/usr")
  219 +@RequiredArgsConstructor
  220 +public class UserController {
  221 + private final UserService userService;
  222 +
  223 + @PostMapping("/users")
  224 + public Result<UserCreateRespVO> createUser(
  225 + @Valid @RequestBody UserCreateReqDTO req,
  226 + @AuthenticationPrincipal UserPrincipal principal) { ... }
  227 +
  228 + @GetMapping("/users/staffs")
  229 + public Result<List<StaffVO>> getStaffs(@AuthenticationPrincipal UserPrincipal principal) { ... }
  230 +
  231 + @GetMapping("/users/permission-groups")
  232 + public Result<List<PermissionGroupVO>> getPermissionGroups(@AuthenticationPrincipal UserPrincipal principal) { ... }
  233 +}
  234 +```
  235 +
  236 +**测试助手** — `SecurityMockMvcRequestPostProcessors.authentication(...)` 注入 UserPrincipal:
  237 +```java
  238 +static RequestPostProcessor superAdmin() {
  239 + return authentication(new UsernamePasswordAuthenticationToken(
  240 + new UserPrincipal("u1","admin","超级管理员","b1"), null, emptyList()));
  241 +}
  242 +static RequestPostProcessor normalUser() {
  243 + return authentication(new UsernamePasswordAuthenticationToken(
  244 + new UserPrincipal("u2","user","普通用户","b1"), null, emptyList()));
  245 +}
  246 +```
  247 +
  248 +- [ ] **Step 1: 写失败测试**
  249 + - `UserControllerTest.java` 完整(`@WebMvcTest(UserController.class)` + `@Import(SecurityConfig, JwtAuthenticationFilter, BeanConfig, JwtUtil, JwtProperties)` + `@MockBean UserService`):
  250 + - `createUser_noAuth_returns401` — 无 auth header → Spring Security 返回 401
  251 + - `createUser_validRequest_returns200` — `superAdmin()` + mock service返回VO → 验证 code=200, data.userId 非空
  252 + - `createUser_missingUserCode_returns40001` — 请求体 userCode 为空 → 验证 code=40001
  253 + - `getStaffs_returns200` — `superAdmin()` + mock returns list → 验证 code=200
  254 + - `getPermissionGroups_returns200` — `superAdmin()` + mock returns list → 验证 code=200
  255 + - 子会话确认 FAIL(UserController 不存在)
  256 +
  257 +- [ ] **Step 2: 实现 UserController**
  258 + - 按 API shape 创建 `UserController.java`
  259 + - `createUser`: `Result.ok(userService.createUser(req, principal))`
  260 + - `getStaffs`: `Result.ok(userService.getStaffs(principal.brandId()))`
  261 + - `getPermissionGroups`: `Result.ok(userService.getPermissionGroups(principal.brandId()))`
  262 +
  263 +- [ ] **Step 3: 子会话验证 UserControllerTest 全部通过**
  264 + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserControllerTest,UserServiceTest,AuthControllerTest,AuthServiceTest`
  265 +
  266 +- [ ] **Step 4: Commit**
  267 + - `git add backend/src/`
  268 + - `git commit -m "feat(usr): UserController POST/users GET/staffs GET/permission-groups REQ-USR-001"`
  269 +
  270 +---
  271 +
  272 +### Task 5: 前端 api/usr.ts + UserFormDrawer + UserListPage + App.tsx 路由
  273 +
  274 +**Files:**
  275 +- Create: `frontend/src/api/usr.ts`
  276 +- Create: `frontend/src/pages/usr/UserListPage.tsx`
  277 +- Create: `frontend/src/pages/usr/UserFormDrawer.tsx`
  278 +- Create: `frontend/src/test/UserListPage.test.tsx`
  279 +- Modify: `frontend/src/App.tsx`
  280 +
  281 +**api/usr.ts 接口**:
  282 +```ts
  283 +export interface StaffVO { sId: string; sStaffName: string }
  284 +export interface PermissionGroupVO { sId: string; sGroupCode: string; sGroupName: string; sCategory: string | null }
  285 +export interface UserCreateReq {
  286 + userCode: string; username: string; userType: '普通用户' | '超级管理员';
  287 + language: '中文' | '英文' | '繁体'; canEditDoc?: boolean;
  288 + employeeId?: string | null; permGroupIds?: string[]
  289 +}
  290 +export interface UserCreateResp { userId: string; userCode: string; username: string }
  291 +
  292 +export function getStaffs(): Promise<StaffVO[]>
  293 +export function getPermissionGroups(): Promise<PermissionGroupVO[]>
  294 +export function createUser(req: UserCreateReq): Promise<UserCreateResp>
  295 +```
  296 +(`request.get('/usr/users/staffs')` 等)
  297 +
  298 +**UserFormDrawer.tsx** props: `open: boolean`, `onClose: () => void`, `onSuccess: () => void`
  299 +- `useEffect` 拉取 staffs + permissionGroups
  300 +- Form 字段:userCode(Input), username(Input), userType(Select), language(Select), canEditDoc(Checkbox), employeeId(Select, options=staffs), permissions(Table with checkbox column)
  301 +- 提交:调 `createUser()`,成功 → `message.success('新增用户成功')` + `onSuccess()`;失败 → `message.error(e.message)`
  302 +
  303 +**UserListPage.tsx**:
  304 +- 顶部「新增」按钮(`<PermButton permission="usr:create">`,由 authSlice 中 userType 控制显示)
  305 +- 点击按钮 → `setDrawerOpen(true)`
  306 +- `<UserFormDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} onSuccess={() => setDrawerOpen(false)} />`
  307 +- 表格区域为 stub(empty Table,REQ-USR-003 补充)
  308 +
  309 +**PermButton**(如不存在则在本任务创建 `frontend/src/components/PermButton.tsx`):
  310 +```tsx
  311 +// REQ-USR-001: 权限按钮,根据 userType 控制显示
  312 +export function PermButton({ permission, children, ...props }: { permission: string } & ButtonProps) {
  313 + const userType = useAppSelector(s => s.auth.userInfo?.userType)
  314 + // usr:create / usr:edit 仅超级管理员可见
  315 + if (userType !== '超级管理员') return null
  316 + return <Button {...props}>{children}</Button>
  317 +}
  318 +```
  319 +
  320 +**App.tsx 新增路由**:
  321 +```tsx
  322 +<Route path="/usr/users" element={<PrivateRoute><UserListPage /></PrivateRoute>} />
  323 +```
  324 +
  325 +**测试 `UserListPage.test.tsx`**(三个测试):
  326 +1. `superAdmin_seesNewButton` — store userType=超级管理员 → 「新增」按钮可见
  327 +2. `normalUser_doesNotSeeNewButton` — store userType=普通用户 → 「新增」按钮不可见
  328 +3. `clickNewButton_opensDrawer` — 超级管理员点击新增 → UserFormDrawer 出现(mock API calls with vi.mock)
  329 +
  330 +- [ ] **Step 1: 写失败测试**
  331 + - 写 `UserListPage.test.tsx` 三个测试(引用 `UserListPage`、`PermButton`)
  332 + - 子会话确认 FAIL(文件不存在)
  333 +
  334 +- [ ] **Step 2: 实现**
  335 + - 按上方规格创建 `api/usr.ts`、`components/PermButton.tsx`、`UserFormDrawer.tsx`、`UserListPage.tsx`
  336 + - 修改 `App.tsx` 添加路由
  337 +
  338 +- [ ] **Step 3: 子会话验证前端测试通过**
  339 + - 命令:`cd frontend && npm run test -- --run`
  340 +
  341 +- [ ] **Step 4: Commit**
  342 + - `git add frontend/src/`
  343 + - `git commit -m "feat(usr): 前端 UserListPage + UserFormDrawer + usr.ts + PermButton REQ-USR-001"`
docs/superpowers/plans/2026-05-08-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-002.md
  5 +---
  6 +
  7 +# 修改用户 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `PUT /api/usr/users/{userId}`,允许超级管理员修改用户类型、语言、权限等,并在前端用户列表增加"修改"按钮触发编辑抽屉。
  12 +
  13 +**Architecture:** 后端遵循现有三层(Controller → Service → Mapper)模式,新增 UserUpdateReqDTO/UserUpdateRespVO,UserServiceImpl.updateUser 做权限、存在性、自身角色三重校验后执行字段更新 + 权限组先删后插;前端复用 UserFormDrawer(新增 userId/initialData props 区分 create/edit 模式),UserListPage 增加"操作"列。
  14 +
  15 +**Tech Stack:** Spring Boot 3 / MyBatis-Plus LambdaUpdateWrapper / @Transactional / React 18 / Ant Design 5 / Vitest
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(不需要新 migration)
  22 +
  23 +## 文件变更清单
  24 +
  25 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java` — 修改用户请求体
  26 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java` — 修改用户响应 VO
  27 +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java` — 追加 USER_NOT_FOUND=40400 / SELF_ADMIN_CHANGE=40301
  28 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 追加 updateUser 接口方法
  29 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 updateUser
  30 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 追加 PUT /users/{userId}
  31 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 追加 updateUser 测试
  32 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 追加 HTTP 测试
  33 +- Modify: `frontend/src/api/usr.ts` — 追加 UserUpdateReq / UserUpdateResp / updateUser()
  34 +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx` — 支持 edit 模式(userId + initialData props)
  35 +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 追加操作列 + editingUser 状态
  36 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 追加编辑流程测试
  37 +
  38 +---
  39 +
  40 +## 任务步骤
  41 +
  42 +### Task 1: 错误码 + DTO/VO 数据结构
  43 +
  44 +**Files:**
  45 +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java`
  46 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java`
  47 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java`
  48 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  49 +
  50 +**API shape:**
  51 +```
  52 +UserUpdateReqDTO {
  53 + String userType; // "普通用户" | "超级管理员"
  54 + String language; // "中文" | "英文" | "繁体"
  55 + boolean canEditDoc;
  56 + boolean isDisabled;
  57 + String employeeId; // nullable
  58 + List<String> permGroupIds;
  59 +}
  60 +
  61 +UserUpdateRespVO {
  62 + String userId;
  63 + String username;
  64 + LocalDateTime updatedAt;
  65 +}
  66 +
  67 +UsrErrorCode.USER_NOT_FOUND = 40400
  68 +UsrErrorCode.SELF_ADMIN_CHANGE = 40301
  69 +```
  70 +
  71 +- [ ] **Step 1: 写失败测试**
  72 + - 测试名: `UserServiceTest#updateUser_dtoDefaults`
  73 + - 意图: `new UserUpdateReqDTO()` 后各字段可 set/get;`new UserUpdateRespVO()` 可 set/get userId/username/updatedAt;`UsrErrorCode.USER_NOT_FOUND == 40400`,`UsrErrorCode.SELF_ADMIN_CHANGE == 40301`
  74 + - 子会话确认 FAIL(类不存在)
  75 +
  76 +- [ ] **Step 2: 实现最小代码**
  77 + - 在 UsrErrorCode 追加两个常量
  78 + - 创建 UserUpdateReqDTO(@Getter @Setter,6 个字段)
  79 + - 创建 UserUpdateRespVO(@Getter @Setter,3 个字段:userId/username/updatedAt: LocalDateTime)
  80 +
  81 +- [ ] **Step 3: 子会话验证 PASS**
  82 +
  83 +- [ ] **Step 4: Commit**
  84 + - `git add backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  85 + - `git commit -m "feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002"`
  86 +
  87 +---
  88 +
  89 +### Task 2: UserService.updateUser 实现(含权限校验与权限组 replace)
  90 +
  91 +**Files:**
  92 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  93 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  94 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  95 +
  96 +**API shape:**
  97 +```
  98 +UserService#updateUser(String userId, UserUpdateReqDTO req, UserPrincipal principal) : UserUpdateRespVO
  99 +```
  100 +
  101 +业务逻辑约束(按顺序执行):
  102 +1. `!"超级管理员".equals(principal.userType())` → `BizException(PERMISSION_DENIED, "权限不足")`
  103 +2. `userMapper.selectOne(sId=userId AND sBrandsId=brandId)` == null → `BizException(USER_NOT_FOUND, "用户不存在")`
  104 +3. `principal.userId().equals(userId) && !"超级管理员".equals(req.getUserType())` → `BizException(SELF_ADMIN_CHANGE, "禁止修改自己的管理员角色")`
  105 +4. `req.getEmployeeId() != null` → staffMapper 校验存在性,不存在 → `BizException(EMPLOYEE_NOT_FOUND, "员工不存在")`
  106 +5. 用 `LambdaUpdateWrapper<UsrUserEntity>` 更新 sUserType/sLanguage/bCanEditDoc/bIsDisabled/sEmployeeId(按 sId=userId AND sBrandsId=brandId)
  107 +6. `userPermissionMapper.delete(sUserId=userId AND sBrandsId=brandId)`,再批量 insert 新 UserPermissionEntity 列表(permGroupIds 为空时只删不插)
  108 +7. 返回 UserUpdateRespVO(userId, username=entity.sUsername, updatedAt=LocalDateTime.now())
  109 +
  110 +- [ ] **Step 1: 写失败测试(共 6 个)**
  111 +
  112 + **T1** `updateUser_nonAdmin_throws40300`
  113 + - 意图: principal.userType="普通用户" → BizException.code==40300
  114 + - principal: new UserPrincipal("u1","admin","普通用户","b1")
  115 +
  116 + **T2** `updateUser_userNotFound_throws40400`
  117 + - 意图: 超级管理员 principal,userMapper.selectOne 返回 null → BizException.code==40400
  118 +
  119 + **T3** `updateUser_selfAdminChange_throws40301`
  120 + - 意图: principal.userId=="u1",userId=="u1",req.userType="普通用户" → BizException.code==40301
  121 + - userMapper.selectOne 需 stub 返回一个存在的用户实体
  122 +
  123 + **T4** `updateUser_invalidEmployee_throws40001`
  124 + - 意图: req.employeeId="bad-emp",staffMapper.selectOne 返回 null → BizException.code==40001
  125 + - userMapper.selectOne stub 返回有效用户;principal.userId!="u-target" 确保不触发 40301
  126 +
  127 + **T5** `updateUser_happyPath_updatesAndReturnsResp`
  128 + - 意图: 一切校验通过 → userMapper 执行 update;permissionMapper 先 delete 再 insert;返回 vo.userId/username/updatedAt 非 null
  129 + - Stub: userMapper.selectOne 返回含 sUsername="alice" 的实体;staffMapper.selectOne 返回非 null(若 employeeId 非 null);userMapper.update 返回 1;delete/insert 正常
  130 +
  131 + **T6** `updateUser_emptyPermGroupIds_onlyDeletes`
  132 + - 意图: req.permGroupIds=[] → userPermissionMapper.delete 被调用;insert 不被调用
  133 + - verify(userPermissionMapper, never()).insert(anyList())
  134 +
  135 + - 子会话确认 6 个测试均 FAIL(方法不存在)
  136 +
  137 +- [ ] **Step 2: 在 UserService 接口追加方法声明,在 UserServiceImpl 实现 updateUser**
  138 + - 使用 @Transactional
  139 + - 注意: UsrUserEntity 已有 sId / sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId 字段(来自现有代码)
  140 +
  141 +- [ ] **Step 3: 子会话验证全部 PASS**
  142 +
  143 +- [ ] **Step 4: Commit**
  144 + - `git add backend/src/main/java/com/example/erp/module/usr/service/ backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  145 + - `git commit -m "feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002"`
  146 +
  147 +---
  148 +
  149 +### Task 3: UserController PUT /api/usr/users/{userId}
  150 +
  151 +**Files:**
  152 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  153 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  154 +
  155 +**API shape:**
  156 +```
  157 +@PutMapping("/users/{userId}")
  158 +public Result<UserUpdateRespVO> updateUser(
  159 + @PathVariable String userId,
  160 + @Valid @RequestBody UserUpdateReqDTO req,
  161 + @AuthenticationPrincipal UserPrincipal principal)
  162 +// REQ-USR-002: 修改用户
  163 +```
  164 +
  165 +- [ ] **Step 1: 写失败测试(共 2 个)**
  166 +
  167 + **T1** `updateUser_withToken_returns200`
  168 + - 意图: PUT /api/usr/users/u-target,含超级管理员 Token,body={userType,language,canEditDoc,isDisabled,permGroupIds}
  169 + - stub: userService.updateUser(any,any,any) 返回 UserUpdateRespVO{userId="u-target",username="alice",updatedAt=now}
  170 + - 断言: HTTP 200, $.code==200, $.data.userId=="u-target", $.data.username=="alice"
  171 +
  172 + **T2** `updateUser_noAuth_returns401`
  173 + - 意图: 无 Token → HTTP 401
  174 +
  175 + - 子会话确认 FAIL(endpoint 不存在)
  176 +
  177 +- [ ] **Step 2: 在 UserController 追加 PUT /users/{userId} 方法,加 // REQ-USR-002: 修改用户 注释**
  178 +
  179 +- [ ] **Step 3: 子会话验证 PASS**
  180 +
  181 +- [ ] **Step 4: Commit**
  182 + - `git add backend/src/main/java/com/example/erp/module/usr/controller/UserController.java backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  183 + - `git commit -m "feat(usr): PUT /api/usr/users/{userId} REQ-USR-002"`
  184 +
  185 +---
  186 +
  187 +### Task 4: 前端 API + UserFormDrawer edit 模式
  188 +
  189 +**Files:**
  190 +- Modify: `frontend/src/api/usr.ts`
  191 +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx`
  192 +- Test: `frontend/src/test/UserListPage.test.tsx`
  193 +
  194 +**API shape (usr.ts):**
  195 +```ts
  196 +export interface UserUpdateReq {
  197 + userType: string
  198 + language: string
  199 + canEditDoc: boolean
  200 + isDisabled: boolean
  201 + employeeId: string | null
  202 + permGroupIds: string[]
  203 +}
  204 +
  205 +export interface UserUpdateResp {
  206 + userId: string
  207 + username: string
  208 + updatedAt: string
  209 +}
  210 +
  211 +export function updateUser(userId: string, req: UserUpdateReq): Promise<UserUpdateResp>
  212 +// → request.put(`/usr/users/${userId}`, req)
  213 +```
  214 +
  215 +**UserFormDrawer props 扩展:**
  216 +```ts
  217 +interface Props {
  218 + open: boolean
  219 + onClose: () => void
  220 + onSuccess: () => void
  221 + userId?: string // 非 null = edit 模式
  222 + initialData?: {
  223 + userType: string
  224 + language: string
  225 + canEditDoc: boolean
  226 + isDisabled: boolean
  227 + employeeId?: string | null
  228 + }
  229 +}
  230 +```
  231 +edit 模式行为:
  232 +- 抽屉 title="修改用户",隐藏 userCode/username Form.Item
  233 +- open 时用 initialData 初始化 form(form.setFieldsValue),selectedPermIds=[]
  234 +- 提交调 updateUser(userId, req) 而非 createUser;req.isDisabled 从 form.values.isDisabled 读取(Checkbox valuePropName="checked")
  235 +
  236 +- [ ] **Step 1: 写失败测试**
  237 +
  238 + 在 `UserListPage.test.tsx` vi.mock 里追加:
  239 + ```ts
  240 + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' })
  241 + ```
  242 +
  243 + **T1** `editMode_drawerTitle_shows修改用户`
  244 + - renderPage,设 drawerOpen=true + editingUser 存在(通过点击模拟行上的修改按钮)
  245 + - 断言: screen 中存在 "修改用户" 文本
  246 +
  247 + **T2** `editMode_submit_callsUpdateUser`
  248 + - 打开 edit 抽屉,点击确认
  249 + - waitFor: vi.mocked(updateUser) 被调用 1 次
  250 +
  251 + - 子会话确认 FAIL(updateUser 不存在于 mock 和 usr.ts)
  252 +
  253 +- [ ] **Step 2: 在 usr.ts 追加 UserUpdateReq / UserUpdateResp / updateUser;改造 UserFormDrawer 支持 edit props**
  254 +
  255 +- [ ] **Step 3: 子会话验证 PASS**
  256 +
  257 +- [ ] **Step 4: Commit**
  258 + - `git add frontend/src/api/usr.ts frontend/src/pages/usr/UserFormDrawer.tsx frontend/src/test/UserListPage.test.tsx`
  259 + - `git commit -m "feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002"`
  260 +
  261 +---
  262 +
  263 +### Task 5: UserListPage 操作列 + editingUser 状态
  264 +
  265 +**Files:**
  266 +- Modify: `frontend/src/pages/usr/UserListPage.tsx`
  267 +- Test: `frontend/src/test/UserListPage.test.tsx`
  268 +
  269 +**行为:**
  270 +- columns 末尾追加 "操作" 列,每行渲染 `<PermButton permission="usr:edit" onClick={() => setEditingUser(row)}>修改</PermButton>`
  271 +- 新增状态: `const [editingUser, setEditingUser] = useState<UserListItemVO | null>(null)`
  272 +- UserFormDrawer 新增两个 props:
  273 + - `open={drawerOpen || editingUser !== null}`(或用 editDrawerOpen 独立状态)
  274 + - `userId={editingUser?.sId}`
  275 + - `initialData={editingUser ? { userType: editingUser.sUserType, language: editingUser.sLanguage, canEditDoc: false, isDisabled: editingUser.bIsDisabled === 1, employeeId: null } : undefined}`
  276 + - 注意:UserListItemVO 不含 bCanEditDoc,edit 模式下默认 false,用户可手动勾选
  277 + - `onSuccess={() => { setEditingUser(null); load(1) }}`
  278 + - `onClose={() => setEditingUser(null)}`
  279 +
  280 + 注意:区分新增抽屉(drawerOpen)和编辑抽屉(editingUser !== null),两者可独立触发 UserFormDrawer,也可复用同一个实例——推荐独立实例避免状态冲突:新增用 drawerOpen/setDrawerOpen,编辑用 editingUser/setEditingUser。
  281 +
  282 +- [ ] **Step 1: 写失败测试**
  283 +
  284 + **T1** `editButton_openEditDrawer`
  285 + - mock getUserList 返回 1 行(sId='u1',sUsername='alice',...)
  286 + - renderPage('超级管理员')
  287 + - waitFor: screen 中有 "alice" 渲染出来
  288 + - 点击 "修改" 按钮
  289 + - waitFor: screen 中有 "修改用户" 文本(抽屉标题)
  290 +
  291 + - 子会话确认 FAIL(无"修改"按钮)
  292 +
  293 +- [ ] **Step 2: 在 UserListPage 追加操作列和编辑抽屉逻辑;追加 // REQ-USR-002: 修改用户 注释**
  294 +
  295 +- [ ] **Step 3: 子会话验证 PASS(全量测试:后端 45+ / 前端 11+)**
  296 +
  297 +- [ ] **Step 4: Commit**
  298 + - `git add frontend/src/pages/usr/UserListPage.tsx frontend/src/test/UserListPage.test.tsx`
  299 + - `git commit -m "feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002"`
  300 +
  301 +---
  302 +
  303 +## 提交计划
  304 +
  305 +- `feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002`(覆盖 Task 1)
  306 +- `feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002`(覆盖 Task 2)
  307 +- `feat(usr): PUT /api/usr/users/{userId} REQ-USR-002`(覆盖 Task 3)
  308 +- `feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002`(覆盖 Task 4)
  309 +- `feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002`(覆盖 Task 5)
docs/superpowers/plans/2026-05-08-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-003.md
  5 +---
  6 +
  7 +# Plan: REQ-USR-003 查询用户
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `GET /api/usr/users` 动态查询分页接口(8 字段 × 3 匹配方式,LEFT JOIN tStaff)及前端搜索列表页。
  12 +
  13 +**Architecture:** 自定义 MyBatis XML Mapper 实现 LEFT JOIN 动态 SQL + MyBatis-Plus `IPage` 分页;Service 层负责 pageSize 上限截断与 queryValue 空值短路;Controller 收 `@RequestParam`,`@AuthenticationPrincipal` 取 brandId。前端用 `useEffect` 初始加载 + 搜索按钮触发重新查询,Ant Design Table 接收 `PageVO` 分页数据。
  14 +
  15 +**Tech Stack:** Spring Boot 3 / MyBatis-Plus 3.x / JUnit 5 + Mockito / React 18 / Ant Design 5 / Vitest + @testing-library/react
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +无(V1 已包含 usr_user、tStaff 所有字段)
  21 +
  22 +## 文件变更清单
  23 +
  24 +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java` — 通用分页包装(含 `static of(IPage<T>)` 工厂)
  25 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java` — 查询入参 DTO
  26 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java` — 列表项 VO(11 字段)
  27 +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml` — LEFT JOIN 动态分页 SQL
  28 +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 添加 `selectUserList`
  29 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 添加 `getUserList` 签名
  30 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 `getUserList`
  31 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 添加 `GET /api/usr/users`
  32 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 添加 `getUserList` 测试
  33 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 添加 `getUsers` 测试
  34 +- Modify: `frontend/src/api/usr.ts` — 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()`
  35 +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 搜索表单 + 真实 Table + 分页
  36 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 添加列表初始加载测试
  37 +
  38 +## 任务步骤
  39 +
  40 +---
  41 +
  42 +### Task 1: 后端 VO/DTO 骨架(PageVO + UserListItemVO + UserListQueryDTO)
  43 +
  44 +**Files:**
  45 +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java`
  46 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java`
  47 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java`
  48 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  49 +
  50 +**API shape(锁定签名):**
  51 +```java
  52 +// PageVO<T> — com.example.erp.common.vo
  53 +@Getter @Setter
  54 +public class PageVO<T> {
  55 + private long total;
  56 + private long page;
  57 + private long pageSize;
  58 + private List<T> list;
  59 +
  60 + public static <T> PageVO<T> of(IPage<T> iPage) {
  61 + PageVO<T> vo = new PageVO<>();
  62 + vo.total = iPage.getTotal();
  63 + vo.page = iPage.getCurrent();
  64 + vo.pageSize = iPage.getSize();
  65 + vo.list = iPage.getRecords();
  66 + return vo;
  67 + }
  68 +}
  69 +
  70 +// UserListQueryDTO — com.example.erp.module.usr.dto
  71 +@Getter @Setter
  72 +public class UserListQueryDTO {
  73 + private String queryField = "username";
  74 + private String matchType = "contains";
  75 + private String queryValue;
  76 + private int page = 1;
  77 + private int pageSize = 20;
  78 +}
  79 +
  80 +// UserListItemVO — com.example.erp.module.usr.vo(全部字段加 @JsonProperty)
  81 +@Getter @Setter
  82 +public class UserListItemVO {
  83 + @JsonProperty("sId") private String sId;
  84 + @JsonProperty("sUsername") private String sUsername;
  85 + @JsonProperty("sUserCode") private String sUserCode;
  86 + @JsonProperty("sUserType") private String sUserType;
  87 + @JsonProperty("sLanguage") private String sLanguage;
  88 + @JsonProperty("bIsDisabled") private Integer bIsDisabled;
  89 + @JsonProperty("tLastLoginDate") private LocalDateTime tLastLoginDate;
  90 + @JsonProperty("sCreatorUsername") private String sCreatorUsername;
  91 + @JsonProperty("tCreateDate") private LocalDateTime tCreateDate;
  92 + @JsonProperty("sStaffName") private String sStaffName;
  93 + @JsonProperty("sDepartment") private String sDepartment;
  94 +}
  95 +```
  96 +
  97 +- [ ] **Step 1: 写失败测试**
  98 + - 在 `UserServiceTest.java` 顶部 import `UserListQueryDTO`
  99 + - 添加占位测试方法(方法体可为空,仅引用该类型使编译失败):
  100 + ```java
  101 + @Test
  102 + void getUserList_capsPageSizeAt100() {
  103 + UserListQueryDTO q = new UserListQueryDTO();
  104 + q.setPageSize(200);
  105 + }
  106 + ```
  107 + - 子会话确认:`mvn test -pl backend -Dtest=UserServiceTest` 编译失败(`UserListQueryDTO` 符号找不到)
  108 +
  109 +- [ ] **Step 2: 创建 VO/DTO**
  110 + - 创建 `PageVO.java`(含 `of()` 工厂)
  111 + - 创建 `UserListQueryDTO.java`
  112 + - 创建 `UserListItemVO.java`
  113 +
  114 +- [ ] **Step 3: 子会话验证 PASS**
  115 + - `mvn test -pl backend -Dtest=UserServiceTest` 全部通过
  116 +
  117 +- [ ] **Step 4: Commit**
  118 + - `git add backend/src/main/java/com/example/erp/common/vo/PageVO.java backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  119 + - `git commit -m "feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003"`
  120 +
  121 +---
  122 +
  123 +### Task 2: 自定义 Mapper(UsrUserMapper.xml + 方法声明)
  124 +
  125 +**Files:**
  126 +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml`
  127 +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java`
  128 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  129 +
  130 +**API shape(锁定方法签名):**
  131 +```java
  132 +// UsrUserMapper.java — 新增方法
  133 +IPage<UserListItemVO> selectUserList(
  134 + IPage<UserListItemVO> page,
  135 + @Param("brandId") String brandId,
  136 + @Param("queryField") String queryField,
  137 + @Param("matchType") String matchType,
  138 + @Param("queryValue") String queryValue
  139 +);
  140 +```
  141 +
  142 +**XML SQL 完整内容(UsrUserMapper.xml):**
  143 +```xml
  144 +<?xml version="1.0" encoding="UTF-8"?>
  145 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  146 + "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  147 +<mapper namespace="com.example.erp.module.usr.mapper.UsrUserMapper">
  148 +
  149 + <select id="selectUserList" resultType="com.example.erp.module.usr.vo.UserListItemVO">
  150 + SELECT u.sId, u.sUsername, u.sUserCode, u.sUserType, u.sLanguage,
  151 + u.bIsDisabled, u.tLastLoginDate, u.sCreatorUsername, u.tCreateDate,
  152 + s.sStaffName, s.sDepartment
  153 + FROM usr_user u
  154 + LEFT JOIN tStaff s ON u.sEmployeeId = s.sId
  155 + AND s.sBrandsId = u.sBrandsId
  156 + AND s.bDeleted = 0
  157 + WHERE u.sBrandsId = #{brandId}
  158 + <if test="queryValue != null and queryValue != ''">
  159 + <choose>
  160 + <when test="queryField == 'username'">
  161 + <choose>
  162 + <when test="matchType == 'contains'">AND u.sUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  163 + <when test="matchType == 'notContains'">AND u.sUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  164 + <otherwise>AND u.sUsername = #{queryValue}</otherwise>
  165 + </choose>
  166 + </when>
  167 + <when test="queryField == 'staffName'">
  168 + <choose>
  169 + <when test="matchType == 'contains'">AND s.sStaffName LIKE CONCAT('%', #{queryValue}, '%')</when>
  170 + <when test="matchType == 'notContains'">AND s.sStaffName NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  171 + <otherwise>AND s.sStaffName = #{queryValue}</otherwise>
  172 + </choose>
  173 + </when>
  174 + <when test="queryField == 'userCode'">
  175 + <choose>
  176 + <when test="matchType == 'contains'">AND u.sUserCode LIKE CONCAT('%', #{queryValue}, '%')</when>
  177 + <when test="matchType == 'notContains'">AND u.sUserCode NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  178 + <otherwise>AND u.sUserCode = #{queryValue}</otherwise>
  179 + </choose>
  180 + </when>
  181 + <when test="queryField == 'department'">
  182 + <choose>
  183 + <when test="matchType == 'contains'">AND s.sDepartment LIKE CONCAT('%', #{queryValue}, '%')</when>
  184 + <when test="matchType == 'notContains'">AND s.sDepartment NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  185 + <otherwise>AND s.sDepartment = #{queryValue}</otherwise>
  186 + </choose>
  187 + </when>
  188 + <when test="queryField == 'userType'">
  189 + <choose>
  190 + <when test="matchType == 'contains'">AND u.sUserType LIKE CONCAT('%', #{queryValue}, '%')</when>
  191 + <when test="matchType == 'notContains'">AND u.sUserType NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  192 + <otherwise>AND u.sUserType = #{queryValue}</otherwise>
  193 + </choose>
  194 + </when>
  195 + <when test="queryField == 'creator'">
  196 + <choose>
  197 + <when test="matchType == 'contains'">AND u.sCreatorUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  198 + <when test="matchType == 'notContains'">AND u.sCreatorUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  199 + <otherwise>AND u.sCreatorUsername = #{queryValue}</otherwise>
  200 + </choose>
  201 + </when>
  202 + <when test="queryField == 'disabled'">
  203 + <choose>
  204 + <when test="queryValue == '是'">AND u.bIsDisabled = 1</when>
  205 + <when test="queryValue == '否'">AND u.bIsDisabled = 0</when>
  206 + </choose>
  207 + </when>
  208 + <when test="queryField == 'lastLoginDate'">
  209 + AND DATE(u.tLastLoginDate) = #{queryValue}
  210 + </when>
  211 + </choose>
  212 + </if>
  213 + ORDER BY u.tCreateDate DESC
  214 + </select>
  215 +
  216 +</mapper>
  217 +```
  218 +
  219 +注:`mybatis-plus.mapper-locations` 默认为 `classpath*:/mapper/**/*.xml`,文件放 `src/main/resources/mapper/UsrUserMapper.xml` 会自动扫描到。
  220 +
  221 +- [ ] **Step 1: 写失败测试**
  222 + - 在 `UserServiceTest.java` 中添加 mock stub 引用 `selectUserList`:
  223 + ```java
  224 + @Test
  225 + void getUserList_callsSelectUserList() {
  226 + IPage<UserListItemVO> mockPage = new Page<>(1, 20, 0);
  227 + when(userMapper.selectUserList(any(), eq("b1"), any(), any(), any()))
  228 + .thenReturn(mockPage);
  229 + // getUserList 方法此时不存在于 UserService → 编译失败
  230 + }
  231 + ```
  232 + - 同时 `@Mock private UsrUserMapper userMapper` 已有,直接 `userMapper.selectUserList(...)` 引用即可触发编译失败
  233 + - 子会话确认 FAIL(`selectUserList` 方法不存在)
  234 +
  235 +- [ ] **Step 2: 实现 Mapper**
  236 + - `UsrUserMapper.java` 添加 `selectUserList` 方法(带 `@Param` 注解,需 import `com.baomidou.mybatisplus.extension.plugins.pagination.Page`)
  237 + - 创建 `src/main/resources/mapper/UsrUserMapper.xml`
  238 +
  239 +- [ ] **Step 3: 子会话验证 PASS**
  240 + - `mvn test -pl backend -Dtest=UserServiceTest` 通过
  241 +
  242 +- [ ] **Step 4: Commit**
  243 + - `git commit -m "feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003"`
  244 +
  245 +---
  246 +
  247 +### Task 3: Service 层 + Controller 端点
  248 +
  249 +**Files:**
  250 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  251 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  252 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  253 +- Test: `UserServiceTest.java`(service 层)、`UserControllerTest.java`(HTTP 层)
  254 +
  255 +**API shape(锁定签名):**
  256 +```java
  257 +// UserService 接口
  258 +PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId);
  259 +
  260 +// UserServiceImpl — getUserList 实现逻辑
  261 +@Transactional(readOnly = true)
  262 +public PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId) {
  263 + int cappedSize = Math.min(query.getPageSize(), 100);
  264 + IPage<UserListItemVO> iPage = new Page<>(query.getPage(), cappedSize);
  265 + userMapper.selectUserList(iPage, brandId,
  266 + query.getQueryField(), query.getMatchType(),
  267 + query.getQueryValue() == null ? "" : query.getQueryValue());
  268 + return PageVO.of(iPage);
  269 +}
  270 +
  271 +// UserController — 新增端点
  272 +@GetMapping("/users")
  273 +public Result<PageVO<UserListItemVO>> getUsers(
  274 + @RequestParam(defaultValue = "username") String queryField,
  275 + @RequestParam(defaultValue = "contains") String matchType,
  276 + @RequestParam(defaultValue = "") String queryValue,
  277 + @RequestParam(defaultValue = "1") int page,
  278 + @RequestParam(defaultValue = "20") int pageSize,
  279 + @AuthenticationPrincipal UserPrincipal principal) {
  280 + UserListQueryDTO q = new UserListQueryDTO();
  281 + q.setQueryField(queryField);
  282 + q.setMatchType(matchType);
  283 + q.setQueryValue(queryValue);
  284 + q.setPage(page);
  285 + q.setPageSize(pageSize);
  286 + return Result.ok(userService.getUserList(q, principal.brandId()));
  287 +}
  288 +```
  289 +
  290 +- [ ] **Step 1: 写失败测试(Service)**
  291 + - 测试名: `UserServiceTest#getUserList_capsPageSizeAt100_callsMapper`
  292 + - 意图:`pageSize=200` → mapper 被调用时 `iPage.getSize() == 100`;返回的 `PageVO.getTotal() == 5`
  293 + - 断言 sketch:
  294 + ```java
  295 + UserListQueryDTO q = new UserListQueryDTO();
  296 + q.setPageSize(200);
  297 + IPage<UserListItemVO> mockPage = new Page<>(1, 100, 5);
  298 + when(userMapper.selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any()))
  299 + .thenReturn(mockPage);
  300 + PageVO<UserListItemVO> result = userService.getUserList(q, "b1"); // 编译失败
  301 + assertEquals(5, result.getTotal());
  302 + ```
  303 + - 子会话确认 FAIL(`getUserList` 不在接口/实现中)
  304 +
  305 +- [ ] **Step 2: 实现 Service**
  306 + - `UserService.java` 添加 `getUserList` 方法签名
  307 + - `UserServiceImpl.java` 实现(pageSize cap + delegate + `PageVO.of`)
  308 +
  309 +- [ ] **Step 3: 子会话验证 Service PASS**
  310 + - `mvn test -pl backend -Dtest=UserServiceTest` 通过
  311 +
  312 +- [ ] **Step 4: 写失败测试(Controller)**
  313 + - 测试名: `UserControllerTest#getUsers_withToken_returns200`
  314 + - 意图:带 JWT Token 的 `GET /api/usr/users` → HTTP 200,响应 JSON 中 `$.data.total` 存在
  315 + - mock stub:`when(userService.getUserList(any(), eq("b1"))).thenReturn(pageVO)`(pageVO.total=0, pageVO.list=[])
  316 + - 子会话确认 FAIL(端点不存在 → Spring 返回 404 或方法签名缺失导致编译失败)
  317 +
  318 +- [ ] **Step 5: 实现 Controller 端点**
  319 + - `UserController.java` 添加 `@GetMapping("/users")` 方法
  320 +
  321 +- [ ] **Step 6: 子会话验证 Controller PASS**
  322 + - `mvn test -pl backend -Dtest=UserControllerTest` 通过
  323 +
  324 +- [ ] **Step 7: Commit**
  325 + - `git commit -m "feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003"`
  326 +
  327 +---
  328 +
  329 +### Task 4: 前端 API 类型 + 用户列表页
  330 +
  331 +**Files:**
  332 +- Modify: `frontend/src/api/usr.ts`
  333 +- Modify: `frontend/src/pages/usr/UserListPage.tsx`
  334 +- Modify: `frontend/src/test/UserListPage.test.tsx`
  335 +
  336 +**API shape(锁定 usr.ts 新增部分):**
  337 +```ts
  338 +export interface UserListQueryReq {
  339 + queryField?: string;
  340 + matchType?: string;
  341 + queryValue?: string;
  342 + page?: number;
  343 + pageSize?: number;
  344 +}
  345 +
  346 +export interface UserListItemVO {
  347 + sId: string;
  348 + sUsername: string;
  349 + sUserCode: string;
  350 + sUserType: string;
  351 + sLanguage: string;
  352 + bIsDisabled: number;
  353 + tLastLoginDate: string | null;
  354 + sCreatorUsername: string | null;
  355 + tCreateDate: string;
  356 + sStaffName: string | null;
  357 + sDepartment: string | null;
  358 +}
  359 +
  360 +export interface PageVO<T> {
  361 + total: number;
  362 + page: number;
  363 + pageSize: number;
  364 + list: T[];
  365 +}
  366 +
  367 +export function getUserList(params?: UserListQueryReq): Promise<PageVO<UserListItemVO>> {
  368 + return request.get('/usr/users', { params })
  369 +}
  370 +```
  371 +
  372 +**UserListPage.tsx 结构(含搜索表单):**
  373 +```tsx
  374 +export default function UserListPage() {
  375 + const [data, setData] = useState<PageVO<UserListItemVO> | null>(null)
  376 + const [queryField, setQueryField] = useState('username')
  377 + const [matchType, setMatchType] = useState('contains')
  378 + const [queryValue, setQueryValue] = useState('')
  379 + const [page, setPage] = useState(1)
  380 +
  381 + const load = (pg = 1) => {
  382 + getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData)
  383 + setPage(pg)
  384 + }
  385 +
  386 + useEffect(() => { load() }, []) // 初始加载
  387 +
  388 + const columns: ColumnsType<UserListItemVO> = [
  389 + { title: '用户名', dataIndex: 'sUsername' },
  390 + { title: '员工名', dataIndex: 'sStaffName' },
  391 + { title: '用户号', dataIndex: 'sUserCode' },
  392 + { title: '部门', dataIndex: 'sDepartment' },
  393 + { title: '用户类型', dataIndex: 'sUserType' },
  394 + { title: '语言', dataIndex: 'sLanguage' },
  395 + { title: '作废', dataIndex: 'bIsDisabled', render: v => v ? '是' : '否' },
  396 + { title: '登录日期', dataIndex: 'tLastLoginDate' },
  397 + { title: '制单人', dataIndex: 'sCreatorUsername' },
  398 + { title: '制单日期', dataIndex: 'tCreateDate' },
  399 + ]
  400 +
  401 + return (
  402 + <div>
  403 + <Space style={{ marginBottom: 16 }}>
  404 + <Select value={queryField} onChange={setQueryField} style={{ width: 120 }}>
  405 + <Select.Option value="username">用户名</Select.Option>
  406 + <Select.Option value="staffName">员工名</Select.Option>
  407 + <Select.Option value="userCode">用户号</Select.Option>
  408 + <Select.Option value="department">部门</Select.Option>
  409 + <Select.Option value="userType">用户类型</Select.Option>
  410 + <Select.Option value="disabled">作废</Select.Option>
  411 + <Select.Option value="lastLoginDate">登录日期</Select.Option>
  412 + <Select.Option value="creator">制单人</Select.Option>
  413 + </Select>
  414 + <Select value={matchType} onChange={setMatchType} style={{ width: 100 }}>
  415 + <Select.Option value="contains">包含</Select.Option>
  416 + <Select.Option value="notContains">不包含</Select.Option>
  417 + <Select.Option value="equals">等于</Select.Option>
  418 + </Select>
  419 + <Input value={queryValue} onChange={e => setQueryValue(e.target.value)} placeholder="查询值" />
  420 + <Button type="primary" onClick={() => load(1)}>搜索</Button>
  421 + <PermButton permission="usr:create" type="primary" onClick={() => setDrawerOpen(true)}>新增</PermButton>
  422 + </Space>
  423 + <Table
  424 + dataSource={data?.list ?? []}
  425 + columns={columns}
  426 + rowKey="sId"
  427 + pagination={{
  428 + total: data?.total ?? 0,
  429 + pageSize: 20,
  430 + current: page,
  431 + onChange: load,
  432 + }}
  433 + />
  434 + <UserFormDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} onSuccess={() => { setDrawerOpen(false); load(1) }} />
  435 + </div>
  436 + )
  437 +}
  438 +```
  439 +
  440 +注:`drawerOpen` state 也需添加(与 Task 1 中 UserListPage 原来的 `drawerOpen` 合并)。
  441 +
  442 +- [ ] **Step 1: 写失败测试**
  443 + - 测试名: `UserListPage.test.tsx#initialLoad_rendersTableRows`
  444 + - 意图:mock `getUserList` 返回含 1 条 `{ sId:'u1', sUsername:'alice', ... }` 的 PageVO → 等待 data 渲染后 table 中有 "alice" 文本
  445 + - 断言 sketch:
  446 + ```ts
  447 + vi.mock('@/api/usr', async (importOriginal) => {
  448 + const actual = await importOriginal<typeof import('@/api/usr')>()
  449 + return { ...actual, getUserList: vi.fn().mockResolvedValue({ total: 1, page: 1, pageSize: 20, list: [{ sId:'u1', sUsername:'alice', sUserCode:'UC001', sUserType:'普通用户', sLanguage:'中文', bIsDisabled:0, tLastLoginDate:null, sCreatorUsername:'admin', tCreateDate:'2026-01-01', sStaffName:null, sDepartment:null }] }) }
  450 + })
  451 + // render + waitFor → screen.getByText('alice')
  452 + ```
  453 + - 子会话确认 FAIL(`getUserList` 不在 `usr.ts` 中 → import 报错)
  454 +
  455 +- [ ] **Step 2: 实现前端**
  456 + - `usr.ts` 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()`
  457 + - `UserListPage.tsx` 重构为搜索表单 + 真实 Table(保留 `drawerOpen` 和 `UserFormDrawer`)
  458 +
  459 +- [ ] **Step 3: 子会话验证 PASS**
  460 + - `pnpm test --run` 全部通过
  461 +
  462 +- [ ] **Step 4: Commit**
  463 + - `git commit -m "feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003"`
  464 +
  465 +---
  466 +
  467 +## 提交计划
  468 +
  469 +- `feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003`(覆盖 Task 1)
  470 +- `feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003`(覆盖 Task 2)
  471 +- `feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003`(覆盖 Task 3)
  472 +- `feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003`(覆盖 Task 4)
docs/superpowers/plans/2026-05-08-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-004.md
  5 +---
  6 +
  7 +# REQ-USR-004 用户登录 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 从零搭建后端 Spring Boot 3 项目与前端 Vite+React 项目,实现三条公开认证接口(POST /api/auth/login、POST /api/auth/refresh、GET /api/auth/brands),完成多租户隔离的 JWT 登录认证及账号锁定防暴力破解。
  12 +
  13 +**Architecture:** 后端分四层推进:公共层(Result/BizException/JwtUtil)→ 数据访问层(BrandMapper/UsrUserMapper)→ 业务层(AuthServiceImpl,含 brand 多租户查找、BCrypt 校验、禁用/锁定检查、失败计数、JWT 签发 6 条业务规则)→ 接口层(SecurityConfig + AuthController)。前端分三层推进:项目骨架 → Axios 封装 + Redux authSlice → LoginPage.tsx 含 brand 下拉 + 用户名密码表单。
  14 +
  15 +**Tech Stack:** Spring Boot 3.x · Spring Security · JJWT 0.12.x · MyBatis-Plus 3.5.x · Flyway 10.x · MySQL 8.x · Vite · React 18 · Ant Design 5.x · Redux Toolkit · Axios · Vitest + @testing-library/react
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(`sql/migrations/V1__initial_schema.sql` 已包含 `usr_user` + `brand` 表,已 apply 至测试库 `xlyweberp_vibe_erp_test`)
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 后端(全部新建)
  26 +- `backend/pom.xml` — 创建(Spring Boot 3 Maven 项目根 POM)
  27 +- `backend/src/main/java/com/example/erp/Application.java` — 创建(启动类)
  28 +- `backend/src/main/resources/application.yml` — 创建(主配置,DB/JWT 用 `${ENV_VAR}` 占位)
  29 +- `backend/src/main/resources/application-dev.yml` — 创建(dev profile,Flyway baseline 防重复迁移)
  30 +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` — 创建(内容与 `sql/migrations/V1__initial_schema.sql` 完全一致,供 Flyway classpath 找到)
  31 +- `backend/src/main/java/com/example/erp/common/response/Result.java` — 创建(统一响应体,含 timestamp)
  32 +- `backend/src/main/java/com/example/erp/common/exception/BizException.java` — 创建(业务异常基类)
  33 +- `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java` — 创建(认证错误码常量)
  34 +- `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java` — 创建(@RestControllerAdvice)
  35 +- `backend/src/main/java/com/example/erp/common/util/JwtUtil.java` — 创建(JWT 生成 + 解析)
  36 +- `backend/src/main/java/com/example/erp/config/JwtProperties.java` — 创建(@ConfigurationProperties("jwt"))
  37 +- `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java` — 创建(brand 表映射)
  38 +- `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java` — 创建(usr_user 表映射)
  39 +- `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java` — 创建(extends BaseMapper<BrandEntity>)
  40 +- `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 创建(extends BaseMapper<UsrUserEntity>)
  41 +- `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java` — 创建(@MapperScan + 分页插件)
  42 +- `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java` — 创建(登录入参)
  43 +- `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java` — 创建(刷新入参)
  44 +- `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java` — 创建(登录出参,含内部静态类 UserInfoVO)
  45 +- `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java` — 创建(brand 下拉出参)
  46 +- `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java` — 创建(接口)
  47 +- `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java` — 创建(业务实现)
  48 +- `backend/src/main/java/com/example/erp/config/BeanConfig.java` — 创建(@Bean BCryptPasswordEncoder;与 SecurityConfig 分离,避免循环依赖)
  49 +- `backend/src/main/java/com/example/erp/config/SecurityConfig.java` — 创建(放行 /api/auth/**,注册 JwtFilter)
  50 +- `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` — 创建(OncePerRequestFilter,验证 Bearer Token)
  51 +- `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java` — 创建(3 个端点)
  52 +- `backend/src/test/java/com/example/erp/ApplicationContextTest.java` — 创建
  53 +- `backend/src/test/java/com/example/erp/common/JwtUtilTest.java` — 创建
  54 +- `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java` — 创建
  55 +- `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java` — 创建
  56 +- `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java` — 创建
  57 +
  58 +### 前端(全部新建)
  59 +- `frontend/package.json` — 创建
  60 +- `frontend/vite.config.ts` — 创建(代理 /api → http://localhost:8080)
  61 +- `frontend/tsconfig.json` — 创建
  62 +- `frontend/index.html` — 创建
  63 +- `frontend/src/main.tsx` — 创建(Provider + BrowserRouter + App)
  64 +- `frontend/src/App.tsx` — 创建(路由表,/ 重定向 /login,受保护路由守卫 PrivateRoute)
  65 +- `frontend/src/styles/tokens.css` — 创建(内容与根目录 `src/styles/tokens.css` 一致)
  66 +- `frontend/src/api/request.ts` — 创建(Axios 实例 + 请求/响应拦截器 + 401 refresh 流程)
  67 +- `frontend/src/api/auth.ts` — 创建(login / refresh / getBrands 接口函数)
  68 +- `frontend/src/store/index.ts` — 创建(configureStore)
  69 +- `frontend/src/store/slices/authSlice.ts` — 创建(setCredentials / clearCredentials)
  70 +- `frontend/src/pages/usr/LoginPage.tsx` — 创建(AntD Form:brand Select + 用户名 + 密码 + 提交)
  71 +- `frontend/src/test/setup.ts` — 创建(@testing-library/jest-dom setup)
  72 +- `frontend/src/test/authSlice.test.ts` — 创建
  73 +- `frontend/src/test/LoginPage.test.tsx` — 创建
  74 +
  75 +---
  76 +
  77 +## 任务步骤
  78 +
  79 +### Task 1: 后端项目骨架
  80 +
  81 +**Files:**
  82 +- 创建: `backend/pom.xml`
  83 +- 创建: `backend/src/main/java/com/example/erp/Application.java`
  84 +- 创建: `backend/src/main/resources/application.yml`
  85 +- 创建: `backend/src/main/resources/application-dev.yml`
  86 +- 创建: `backend/src/main/resources/db/migration/V1__initial_schema.sql`
  87 +- 测试: `backend/src/test/java/com/example/erp/ApplicationContextTest.java`
  88 +
  89 +**pom.xml 必须包含的依赖:**
  90 +- `spring-boot-starter-parent` 3.x(parent)
  91 +- `spring-boot-starter-web`, `spring-boot-starter-security`, `spring-boot-starter-validation`, `spring-boot-starter-test`
  92 +- `mybatis-plus-spring-boot3-starter` 3.5.x
  93 +- `mysql-connector-j`(runtime scope)
  94 +- `flyway-core` + `flyway-mysql`(10.x)
  95 +- `jjwt-api` + `jjwt-impl` + `jjwt-jackson`(0.12.x;impl/jackson 用 runtime scope)
  96 +- `lombok`(optional)
  97 +- `hutool-all` 5.8.x
  98 +
  99 +**application.yml 关键片段:**
  100 +```yaml
  101 +spring:
  102 + datasource:
  103 + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
  104 + username: ${DB_USER}
  105 + password: ${DB_PASSWORD}
  106 + driver-class-name: com.mysql.cj.jdbc.Driver
  107 + flyway:
  108 + locations: classpath:db/migration
  109 + baseline-on-migrate: true
  110 + baseline-version: 1
  111 +server:
  112 + port: 8080
  113 +jwt:
  114 + secret: ${JWT_SECRET}
  115 + access-token-expiry: 86400
  116 + refresh-token-expiry: 604800
  117 +```
  118 +
  119 +**application-dev.yml:**
  120 +```yaml
  121 +spring:
  122 + config:
  123 + import: optional:file:.env.local[.properties]
  124 +```
  125 +
  126 +**说明:** `.env.local` 含 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_SCHEMA`, `JWT_SECRET`。dev profile 通过 `spring.config.import` 自动加载;`baseline-on-migrate=true` + `baseline-version=1` 让 Flyway 把已有 schema 标记为 V1 已应用,不重复执行。
  127 +
  128 +- [ ] **Step 1: 写失败测试**
  129 + - 测试名: `ApplicationContextTest#contextLoads`
  130 + - 意图: `@SpringBootTest` 启动 ApplicationContext 不报错(验证 Bean 配置和 DB 连接)
  131 + - 子会话确认 FAIL(项目不存在,无法编译)
  132 +
  133 +- [ ] **Step 2: 创建 Maven 项目结构**
  134 + - 创建目录树:`backend/src/main/java/com/example/erp/`、`backend/src/main/resources/db/migration/`、`backend/src/test/java/com/example/erp/`
  135 + - 写 pom.xml(含上述依赖)
  136 + - 写 Application.java(`@SpringBootApplication`,标准 main 方法)
  137 + - 写 application.yml + application-dev.yml(按上述片段)
  138 + - 复制 `sql/migrations/V1__initial_schema.sql` 内容到 `backend/src/main/resources/db/migration/V1__initial_schema.sql`
  139 + - 写 `ApplicationContextTest.java`(`@SpringBootTest`,空的 `contextLoads()` 方法)
  140 + - 写 `backend/src/test/java/com/example/erp/TestApplication.java`(继承 `Application`,供测试使用 dev profile)
  141 +
  142 +- [ ] **Step 3: 子会话运行 `cd backend && mvn test -Dspring.profiles.active=dev`,确认 contextLoads PASS**
  143 +
  144 +- [ ] **Step 4: Commit**
  145 + - `git add backend/`
  146 + - `git commit -m "chore(backend): init Spring Boot 3 project skeleton REQ-USR-004"`
  147 +
  148 +---
  149 +
  150 +### Task 2: Common 层(Result / BizException / ErrorCode / GlobalExceptionHandler)
  151 +
  152 +**Files:**
  153 +- 创建: `backend/src/main/java/com/example/erp/common/response/Result.java`
  154 +- 创建: `backend/src/main/java/com/example/erp/common/exception/BizException.java`
  155 +- 创建: `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java`
  156 +- 创建: `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java`
  157 +- 测试: `backend/src/test/java/com/example/erp/common/ResultTest.java`
  158 +
  159 +**API shape:**
  160 +- `Result<T>` — 字段:`int code`,`String message`,`T data`,`long timestamp`
  161 + - `static <T> Result<T> ok(T data)` → code=200, message="操作成功", timestamp=System.currentTimeMillis()
  162 + - `static <T> Result<T> fail(int code, String message)` → data=null, timestamp=System.currentTimeMillis()
  163 +- `BizException(int code, String message)` — 继承 RuntimeException,含 `int code` 字段
  164 +- `GlobalExceptionHandler (@RestControllerAdvice)`:
  165 + - `handleBizException(BizException e)` → `Result.fail(e.getCode(), e.getMessage())`,HTTP 200
  166 + - `handleMethodArgumentNotValid(MethodArgumentNotValidException e)` → `Result.fail(40001, 首个字段错误信息)`,HTTP 200
  167 + - `handleException(Exception e)` → `Result.fail(99000, "系统内部错误")`,记 Logback error 级日志
  168 +
  169 +**合同级错误码常量(AuthErrorCode.java,`public static final int`):**
  170 +```java
  171 +USERNAME_OR_PASSWORD_ERROR = 40100 // 用户名或密码错误(不区分哪个,防枚举)
  172 +ACCOUNT_DISABLED = 40101 // 账号已被禁用,请联系管理员
  173 +ACCOUNT_LOCKED = 40102 // 账号已被锁定,请 N 分钟后重试
  174 +REFRESH_TOKEN_INVALID = 40103 // Refresh Token 已失效,请重新登录
  175 +```
  176 +
  177 +- [ ] **Step 1: 写失败测试**
  178 + - `ResultTest#ok_setsCode200AndData` — `Result.ok("hello").getCode() == 200 && "hello".equals(result.getData())`
  179 + - `ResultTest#fail_setsCodeAndNullData` — `Result.fail(40100, "msg").getCode() == 40100 && result.getData() == null`
  180 + - `ResultTest#ok_hasTimestamp` — `Result.ok(null).getTimestamp() > 0`
  181 + - 子会话确认 FAIL(类不存在)
  182 +
  183 +- [ ] **Step 2: 实现 Result.java + BizException.java + AuthErrorCode.java + GlobalExceptionHandler.java**
  184 +
  185 +- [ ] **Step 3: 子会话运行 `mvn test`,确认 3 个 ResultTest PASS**
  186 +
  187 +- [ ] **Step 4: Commit**
  188 + - `git commit -m "feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004"`
  189 +
  190 +---
  191 +
  192 +### Task 3: JwtUtil
  193 +
  194 +**Files:**
  195 +- 创建: `backend/src/main/java/com/example/erp/config/JwtProperties.java`
  196 +- 创建: `backend/src/main/java/com/example/erp/common/util/JwtUtil.java`
  197 +- 测试: `backend/src/test/java/com/example/erp/common/JwtUtilTest.java`
  198 +
  199 +**API shape:**
  200 +- `JwtProperties (@ConfigurationProperties("jwt"))` — `String secret`,`long accessTokenExpiry`(秒),`long refreshTokenExpiry`(秒);加 `@EnableConfigurationProperties(JwtProperties.class)` 于 Application 或 Config 类
  201 +- `JwtUtil (@Component)`:注入 JwtProperties
  202 + - `generateAccessToken(String userId, String username, String userType, String brandId) : String`
  203 + - claims: sub=userId, "username"=username, "userType"=userType, "brandId"=brandId, exp=now + accessTokenExpiry 秒
  204 + - HMAC-SHA256,key = `Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8))`
  205 + - `generateRefreshToken(String userId, String brandId) : String`
  206 + - claims: sub=userId, "brandId"=brandId, "type"="refresh", exp=now + refreshTokenExpiry 秒
  207 + - `parseAccessToken(String token) : Claims`
  208 + - 若签名无效或过期 → throw `new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Token 已失效,请重新登录")`
  209 + - `parseRefreshToken(String token) : Claims`
  210 + - 解析同 parseAccessToken
  211 + - 验证 claim "type" == "refresh";否则 → throw BizException(40103)
  212 +
  213 +- [ ] **Step 1: 写失败测试**
  214 + - `JwtUtilTest#generateAndParseAccessToken_containsAllClaims`
  215 + - 构造 JwtUtil(properties: secret="testSecretKey32CharacterMinLength!", accessTokenExpiry=86400, refreshTokenExpiry=604800)
  216 + - 生成 access token,parseAccessToken → sub=="u1", username=="admin", userType=="超级管理员", brandId=="b1"
  217 + - `JwtUtilTest#parseRefreshToken_withAccessToken_throws40103`
  218 + - 生成 access token,传入 parseRefreshToken → 抛 BizException,code=40103
  219 + - `JwtUtilTest#parseAccessToken_withExpiredToken_throws40103`
  220 + - 构造 JwtUtil(accessTokenExpiry=-1 或 0),生成 token 后 parseAccessToken → 抛 BizException,code=40103
  221 + - 子会话确认 FAIL(类不存在)
  222 +
  223 +- [ ] **Step 2: 实现 JwtProperties + JwtUtil**
  224 + - JJWT 0.12.x API:`Jwts.builder()...signWith(key, Jwts.SIG.HS256).compact()`;解析:`Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()`
  225 +
  226 +- [ ] **Step 3: 子会话运行 JwtUtilTest(3 个测试),确认 PASS**
  227 +
  228 +- [ ] **Step 4: Commit**
  229 + - `git commit -m "feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004"`
  230 +
  231 +---
  232 +
  233 +### Task 4: Entity + Mapper(BrandEntity / UsrUserEntity)
  234 +
  235 +**Files:**
  236 +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java`
  237 +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java`
  238 +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java`
  239 +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java`
  240 +- 创建: `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java`
  241 +- 测试: `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java`
  242 +
  243 +**API shape:**
  244 +- `BrandEntity (@TableName("brand"))` — 字段(均 `@TableField("<column_name>")`):`iIncrement`(@TableId,AUTO),`sId`,`sNo`,`sName`,`sShortName`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)`
  245 +- `UsrUserEntity (@TableName("usr_user"))` — 字段:`iIncrement`(@TableId,AUTO),`sId`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)`,`sUserCode`,`sUsername`,`sPasswordHash`,`sUserType`,`sLanguage`,`bCanEditDoc(Integer)`,`bIsDisabled(Integer)`,`sEmployeeId`,`sCreatorUsername`,`tLastLoginDate(LocalDateTime)`,`iLoginFailCount(Integer)`,`tLockUntil(LocalDateTime)`
  246 +- `BrandMapper extends BaseMapper<BrandEntity>`(无额外方法)
  247 +- `UsrUserMapper extends BaseMapper<UsrUserEntity>`(无额外方法;更新逻辑在 Service 用 LambdaUpdateWrapper 完成)
  248 +- `MyBatisPlusConfig (@Configuration)` — `@MapperScan("com.example.erp.module.*.mapper")`;注册 `MybatisPlusInterceptor` + `PaginationInnerInterceptor(DbType.MYSQL)`
  249 +
  250 +**测试(BrandMapperTest — @SpringBootTest,使用真实测试库):**
  251 +- `@BeforeEach` 插入 brand 行:`sId='b-test-001', sNo='TST', sName='测试版', iIncrement=null`(自增),其余字段留 null
  252 +- `@AfterEach` DELETE WHERE sNo='TST'
  253 +- 测试方法 `findByNo_returnsCorrectBrand` — `new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, "TST")` selectOne → sName == "测试版"
  254 +
  255 +- [ ] **Step 1: 写失败测试**
  256 + - `BrandMapperTest#findByNo_returnsCorrectBrand`
  257 + - 子会话确认 FAIL(类不存在)
  258 +
  259 +- [ ] **Step 2: 实现 Entity + Mapper + MyBatisPlusConfig**
  260 + - 注意:Entity 字段名用 Java camelCase,`@TableField` 注解对应数据库实际列名(如 `@TableField("sBrandsId")` 或直接用 MyBatis-Plus 全局下划线转换——由于列名本身是驼峰,需关闭 `map-underscore-to-camel-case` 或手动 @TableField)
  261 +
  262 +- [ ] **Step 3: 子会话运行 BrandMapperTest,确认 PASS**
  263 +
  264 +- [ ] **Step 4: Commit**
  265 + - `git commit -m "feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004"`
  266 +
  267 +---
  268 +
  269 +### Task 5: AuthService — 登录核心逻辑
  270 +
  271 +**Files:**
  272 +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java`
  273 +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java`
  274 +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java`
  275 +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java`
  276 +- 创建: `backend/src/main/java/com/example/erp/config/BeanConfig.java`
  277 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java`
  278 +
  279 +**API shape:**
  280 +- `LoginReqDTO` — `@NotBlank String brandNo`,`@NotBlank String username`,`@NotBlank String password`
  281 +- `LoginVO` — `String accessToken`,`String refreshToken`,`long expiresIn`(固定值 86400),`UserInfoVO userInfo`
  282 + - `UserInfoVO (static inner class)` — `String userId`,`String username`,`String userType`,`String language`,`String brandId`
  283 +- `AuthService` — `LoginVO login(LoginReqDTO req)`;`String refresh(String refreshToken)`;`List<BrandVO> getBrands()`
  284 +- `AuthServiceImpl (@Service @Transactional)` — 注入 `BrandMapper`,`UsrUserMapper`,`JwtUtil`,`BCryptPasswordEncoder`
  285 +
  286 +**AuthServiceImpl.login 业务规则(按顺序):**
  287 +1. `brandMapper.selectOne(new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo()))` → null → `throw new BizException(40100, "用户名或密码错误")`
  288 +2. `userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId()))` → null → `throw new BizException(40100, "用户名或密码错误")`
  289 +3. `user.getBIsDisabled() == 1` → `throw new BizException(40101, "账号已被禁用,请联系管理员")`
  290 +4. `user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())` → 计算 remainMinutes = `(int) Math.ceil(ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil()) / 60.0)` → `throw new BizException(40102, "账号已被锁定,请 " + remainMinutes + " 分钟后重试")`
  291 +5. `!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())` → `int newCount = user.getILoginFailCount() + 1`
  292 + - `newCount >= 5`: `LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30)`;`userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount).set(UsrUserEntity::getTLockUntil, lockUntil))` → `throw new BizException(40102, "账号已被锁定,请 30 分钟后重试")`
  293 + - `newCount < 5`: `userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount))` → `throw new BizException(40100, "用户名或密码错误")`
  294 +6. 成功:`userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, 0).set(UsrUserEntity::getTLockUntil, null).set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now()))`;签发 tokens;返回 LoginVO
  295 +
  296 +- [ ] **Step 1: 写 7 个失败单元测试(AuthServiceTest — @ExtendWith(MockitoExtension.class),mock BrandMapper/UsrUserMapper/JwtUtil/BCryptPasswordEncoder)**
  297 + - `login_brandNotFound_throws40100` — brandMapper.selectOne → null → BizException(40100)
  298 + - `login_userNotFound_throws40100` — brand 存在,userMapper.selectOne → null → BizException(40100)
  299 + - `login_accountDisabled_throws40101` — user.bIsDisabled=1 → BizException(40101)
  300 + - `login_accountLocked_throws40102WithRemainingMinutes` — user.tLockUntil=now+20min → BizException(40102),message 含 "20 分钟"
  301 + - `login_wrongPassword_firstTime_throws40100AndIncrementsCount` — BCrypt 不匹配,iLoginFailCount=0 → 更新为 1,throw 40100
  302 + - `login_wrongPassword_5thTime_setsLockAndThrows40102` — BCrypt 不匹配,iLoginFailCount=4 → 更新为 5,设 tLockUntil,throw 40102
  303 + - `login_success_resetsCountAndReturnsTokens` — BCrypt 匹配 → reset count,issue tokens,返回 LoginVO
  304 +
  305 +- [ ] **Step 2: 实现 LoginReqDTO + LoginVO + AuthService + AuthServiceImpl.login()**
  306 + - `BCryptPasswordEncoder` 注入:在 Task 5 中同步创建 `BeanConfig.java`(`@Configuration @Bean BCryptPasswordEncoder passwordEncoder()`),使 Spring context(ApplicationContextTest)在 Task 5 之后仍能正常加载;SecurityConfig(Task 7)不重复声明此 Bean
  307 +
  308 +- [ ] **Step 3: 子会话运行 AuthServiceTest(7 个测试),确认 PASS**
  309 +
  310 +- [ ] **Step 4: Commit**
  311 + - `git commit -m "feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004"`
  312 +
  313 +---
  314 +
  315 +### Task 6: AuthService — refresh + getBrands
  316 +
  317 +**Files:**
  318 +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java`
  319 +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java`
  320 +- 修改: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java`(新增 refresh + getBrands)
  321 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java`(追加 3 个测试方法)
  322 +
  323 +**API shape:**
  324 +- `RefreshTokenReqDTO` — `@NotBlank String refreshToken`
  325 +- `BrandVO` — `String sNo`,`String sName`
  326 +- `AuthServiceImpl#refresh(String refreshToken) : String`
  327 + 1. `Claims claims = jwtUtil.parseRefreshToken(refreshToken)` — 无效/过期自动抛 BizException(40103)
  328 + 2. `String userId = claims.getSubject(); String brandId = claims.get("brandId", String.class)`
  329 + 3. `UsrUserEntity user = userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId))` → null 或 `bIsDisabled=1` → throw BizException(40103, "Refresh Token 已失效,请重新登录")
  330 + 4. 签发新 accessToken:`jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId)`;返回新 accessToken 字符串
  331 +- `AuthServiceImpl#getBrands() : List<BrandVO>`
  332 + - `brandMapper.selectList(new QueryWrapper<BrandEntity>().select("sNo", "sName").orderByAsc("sName"))` → 映射为 BrandVO 列表
  333 +
  334 +- [ ] **Step 1: 写 3 个失败测试(追加到 AuthServiceTest)**
  335 + - `refresh_validRefreshToken_returnsNewAccessToken` — parseRefreshToken 成功,查库返回有效 user → generateAccessToken 被调用,返回新 token
  336 + - `refresh_invalidRefreshToken_throws40103` — parseRefreshToken 抛 BizException(40103)
  337 + - `getBrands_returnsListSortedByName` — brandMapper.selectList 返回 [b1, b2] → 结果 List<BrandVO> 包含对应 sNo/sName
  338 +
  339 +- [ ] **Step 2: 实现 RefreshTokenReqDTO + BrandVO + AuthServiceImpl#refresh() + AuthServiceImpl#getBrands()**
  340 +
  341 +- [ ] **Step 3: 子会话运行 AuthServiceTest(新增 3 个测试),确认 PASS**
  342 +
  343 +- [ ] **Step 4: Commit**
  344 + - `git commit -m "feat(usr): AuthService.refresh + getBrands REQ-USR-004"`
  345 +
  346 +---
  347 +
  348 +### Task 7: SecurityConfig + JwtAuthenticationFilter + AuthController
  349 +
  350 +**Files:**
  351 +- 创建: `backend/src/main/java/com/example/erp/config/SecurityConfig.java`
  352 +- 创建: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  353 +- 创建: `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java`
  354 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java`
  355 +
  356 +**API shape:**
  357 +- `SecurityConfig (@Configuration @EnableWebSecurity)`:
  358 + - `@Bean SecurityFilterChain`: `csrf().disable()`;`sessionManagement(STATELESS)`;`authorizeHttpRequests`: `permitAll` for `/api/auth/**`,其余 `authenticated`
  359 + - `addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)`
  360 + - 不再声明 BCryptPasswordEncoder @Bean(已在 BeanConfig.java 声明)
  361 +- `JwtAuthenticationFilter extends OncePerRequestFilter`:
  362 + - 读 `Authorization` header,提取 Bearer token
  363 + - `jwtUtil.parseAccessToken(token)` → 设 `UsernamePasswordAuthenticationToken` 入 SecurityContextHolder
  364 + - token 无效 → 不设 context(Spring Security 后续返回 401);请求路径匹配 `/api/auth/**` → 直接放行不解析
  365 +- `AuthController (@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor)`:
  366 + - `@PostMapping("/login") Result<LoginVO> login(@Valid @RequestBody LoginReqDTO req)` → `Result.ok(authService.login(req))`
  367 + - `@PostMapping("/refresh") Result<Map<String,String>> refresh(@Valid @RequestBody RefreshTokenReqDTO req)` → `Result.ok(Map.of("accessToken", authService.refresh(req.getRefreshToken())))`
  368 + - `@GetMapping("/brands") Result<List<BrandVO>> brands()` → `Result.ok(authService.getBrands())`
  369 +
  370 +- [ ] **Step 1: 写 4 个 MockMvc 失败测试(AuthControllerTest — @WebMvcTest + @MockBean AuthService)**
  371 + - `login_wrongPassword_returns40100` — authService.login 抛 BizException(40100) → 响应 JSON code=40100
  372 + - `login_validCredentials_returns200AndTokens` — authService.login 返回 LoginVO → 响应 JSON code=200,accessToken 非空
  373 + - `refresh_invalidToken_returns40103` — authService.refresh 抛 BizException(40103) → code=40103
  374 + - `getBrands_returns200AndList` — authService.getBrands 返回 [BrandVO{sNo="STD", sName="标准版"}] → code=200,list 含该项
  375 +
  376 +- [ ] **Step 2: 实现 SecurityConfig + JwtAuthenticationFilter + AuthController**
  377 +
  378 +- [ ] **Step 3: 子会话运行 AuthControllerTest(4 个测试),确认 PASS**
  379 +
  380 +- [ ] **Step 4: 手动 smoke test(如果后端可本地运行)**
  381 + - `cd backend && mvn spring-boot:run -Dspring-boot.run.profiles=dev`(需 .env.local 在 backend/ 父目录可找到)
  382 + - `curl -s -X GET http://localhost:8080/api/auth/brands | jq .`
  383 + - `curl -s -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"brandNo":"STD","username":"admin","password":"666666"}' | jq .`
  384 +
  385 +- [ ] **Step 5: Commit**
  386 + - `git commit -m "feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004"`
  387 +
  388 +---
  389 +
  390 +### Task 8: 前端项目骨架
  391 +
  392 +**Files:**
  393 +- 创建: `frontend/package.json`
  394 +- 创建: `frontend/vite.config.ts`
  395 +- 创建: `frontend/tsconfig.json`
  396 +- 创建: `frontend/index.html`
  397 +- 创建: `frontend/src/main.tsx`
  398 +- 创建: `frontend/src/App.tsx`
  399 +- 创建: `frontend/src/styles/tokens.css`
  400 +
  401 +**package.json 关键依赖:**
  402 +```json
  403 +"dependencies": {
  404 + "react": "^18.3.0", "react-dom": "^18.3.0",
  405 + "antd": "^5.17.0", "@ant-design/icons": "^5.3.0",
  406 + "@reduxjs/toolkit": "^2.2.0", "react-redux": "^9.1.0",
  407 + "react-router-dom": "^6.23.0",
  408 + "axios": "^1.7.0", "dayjs": "^1.11.0"
  409 +},
  410 +"devDependencies": {
  411 + "vite": "^5.2.0", "@vitejs/plugin-react": "^4.3.0",
  412 + "typescript": "^5.4.0",
  413 + "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0",
  414 + "vitest": "^1.6.0",
  415 + "@testing-library/react": "^15.0.0",
  416 + "@testing-library/jest-dom": "^6.4.0",
  417 + "@testing-library/user-event": "^14.5.0",
  418 + "jsdom": "^24.0.0"
  419 +}
  420 +```
  421 +
  422 +**vite.config.ts 关键配置:**
  423 +```ts
  424 +export default defineConfig({
  425 + plugins: [react()],
  426 + server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } },
  427 + test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts' }
  428 +})
  429 +```
  430 +
  431 +**App.tsx 骨架:** `<BrowserRouter>` 包裹路由,`/login` → `<LoginPage />`,`/` → PrivateRoute(暂时重定向 /login,后续 REQ 补充),`*` → 404
  432 +
  433 +- [ ] **Step 1: 创建前端项目文件结构**
  434 + - `mkdir -p frontend/src/{styles,api,store/slices,pages/usr,test,hooks,components,utils}`
  435 + - 写 package.json(上述依赖)
  436 + - 写 vite.config.ts、tsconfig.json、index.html、src/main.tsx、src/App.tsx(骨架)
  437 + - 复制 `src/styles/tokens.css` 内容到 `frontend/src/styles/tokens.css`
  438 + - 写 `frontend/src/test/setup.ts`(`import "@testing-library/jest-dom"`)
  439 + - `cd frontend && npm install`
  440 +
  441 +- [ ] **Step 2: 验证骨架构建**
  442 + - `cd frontend && npm run build`(应成功,0 错误)
  443 +
  444 +- [ ] **Step 3: Commit**
  445 + - `git add frontend/`
  446 + - `git commit -m "chore(frontend): init Vite React project skeleton REQ-USR-004"`
  447 +
  448 +---
  449 +
  450 +### Task 9: 前端 Auth API 层 + Redux authSlice
  451 +
  452 +**Files:**
  453 +- 创建: `frontend/src/api/request.ts`
  454 +- 创建: `frontend/src/api/auth.ts`
  455 +- 创建: `frontend/src/store/index.ts`
  456 +- 创建: `frontend/src/store/slices/authSlice.ts`
  457 +- 测试: `frontend/src/test/authSlice.test.ts`
  458 +
  459 +**API shape:**
  460 +- `request.ts` — Axios 实例 `baseURL: '/api'`,`timeout: 10000`
  461 + - 请求拦截器:从 `store.getState().auth.accessToken` 读 token,注入 `Authorization: Bearer ${token}`
  462 + - 响应拦截器(成功):`response.data.code !== 200` → 抛 `new Error(response.data.message)`
  463 + - 响应拦截器(HTTP 401):调 `auth.refresh(store.getState().auth.refreshToken)` → 更新 store → 重试原请求;refresh 失败 → `store.dispatch(clearCredentials())` → `window.location.href = '/login'`
  464 +- `auth.ts`:
  465 + - `login(params: { brandNo: string; username: string; password: string }) : Promise<LoginVO>` — POST /api/auth/login
  466 + - `refresh(refreshToken: string) : Promise<{ accessToken: string }>` — POST /api/auth/refresh
  467 + - `getBrands() : Promise<BrandVO[]>` — GET /api/auth/brands
  468 + - 类型定义(TypeScript interface):`LoginVO`(含 accessToken, refreshToken, expiresIn, userInfo: UserInfoVO),`UserInfoVO`(userId, username, userType, language, brandId),`BrandVO`(sNo, sName)
  469 +- `authSlice` — `createSlice({ name: 'auth', initialState: { accessToken: null, refreshToken: null, userInfo: null } })`
  470 + - `setCredentials(state, action: PayloadAction<{ accessToken: string; refreshToken: string; userInfo: UserInfoVO }>)` — 更新三字段
  471 + - `clearCredentials(state)` — 三字段重置 null
  472 +- `store/index.ts` — `configureStore({ reducer: { auth: authReducer } })`;导出 `RootState`、`AppDispatch`
  473 +
  474 +- [ ] **Step 1: 写 2 个失败单元测试(authSlice.test.ts)**
  475 + - `setCredentials_updatesAllStateFields` — dispatch setCredentials({accessToken:"t1", refreshToken:"r1", userInfo:{userId:"u1",...}}) → state.auth.accessToken=="t1"
  476 + - `clearCredentials_resetsToNull` — dispatch clearCredentials → state.auth.accessToken==null
  477 +
  478 +- [ ] **Step 2: 实现 request.ts + auth.ts + authSlice + store/index.ts**
  479 +
  480 +- [ ] **Step 3: 子会话运行 `cd frontend && npm run test -- --run`,确认 authSlice 2 个测试 PASS**
  481 +
  482 +- [ ] **Step 4: Commit**
  483 + - `git commit -m "feat(usr): auth API layer + Redux authSlice REQ-USR-004"`
  484 +
  485 +---
  486 +
  487 +### Task 10: 登录页 LoginPage.tsx
  488 +
  489 +**Files:**
  490 +- 创建: `frontend/src/pages/usr/LoginPage.tsx`
  491 +- 修改: `frontend/src/App.tsx`(添加 /login 路由,PrivateRoute 守卫读 authSlice)
  492 +- 测试: `frontend/src/test/LoginPage.test.tsx`
  493 +
  494 +**UI 规范(来自 docs/06 § 五):**
  495 +- 版本 `Select`(label="公司/版本",name="brandNo"):组件 mount 时调 `getBrands()`,填充选项(value=sNo, label=sName);defaultValue = sName=="标准版" 对应的 sNo;若无"标准版"则选第一项
  496 +- 用户名 `Input`(label="用户名",name="username"):必填
  497 +- 密码 `Input.Password`(label="密码",name="password"):必填
  498 +- 提交按钮(text="登录",`loading={loading}`):防重复点击
  499 +- 失败:`message.error(接口返回 message)`
  500 +- 成功:`dispatch(setCredentials({accessToken, refreshToken, userInfo}))` → `navigate('/')`
  501 +
  502 +**LoginPage 内部数据流:**
  503 +1. `useEffect(() => { getBrands().then(setBrandOptions) }, [])` — 挂载时获取 brand 列表
  504 +2. `onFinish(values)` → `setLoading(true)` → `await login(values)` → `dispatch(setCredentials(...))` → `navigate('/')` → `finally setLoading(false)`
  505 +
  506 +- [ ] **Step 1: 写 2 个失败测试(LoginPage.test.tsx)**
  507 + - `renders_brandSelect_username_and_password_fields`
  508 + - render LoginPage(with Redux Provider + MemoryRouter + mock getBrands → [{sNo:"STD",sName:"标准版"}])
  509 + - 断言:combobox(brand Select)存在,`getByPlaceholderText` 或 label "用户名" Input 存在,"密码" Input 存在,"登录" Button 存在
  510 + - `submit_withValidCredentials_dispatchesSetCredentials`
  511 + - mock `auth.login` 返回 `{accessToken:"at", refreshToken:"rt", expiresIn:86400, userInfo:{userId:"u1",username:"admin",userType:"超级管理员",language:"中文",brandId:"b1"}}`
  512 + - 填写 username + password,点击登录 → `store.getState().auth.accessToken == "at"`
  513 +
  514 +- [ ] **Step 2: 实现 LoginPage.tsx + 更新 App.tsx(/login 路由)**
  515 +
  516 +- [ ] **Step 3: 子会话运行 `npm run test -- --run`,确认所有前端测试(包括 authSlice + LoginPage)PASS**
  517 +
  518 +- [ ] **Step 4: Commit**
  519 + - `git commit -m "feat(usr): LoginPage brand selector + login form REQ-USR-004"`
  520 +
  521 +---
  522 +
  523 +## 提交计划
  524 +
  525 +| 提交消息 | 覆盖 Task |
  526 +|---|---|
  527 +| `chore(backend): init Spring Boot 3 project skeleton REQ-USR-004` | Task 1 |
  528 +| `feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004` | Task 2 |
  529 +| `feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004` | Task 3 |
  530 +| `feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004` | Task 4 |
  531 +| `feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004` | Task 5 |
  532 +| `feat(usr): AuthService.refresh + getBrands REQ-USR-004` | Task 6 |
  533 +| `feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004` | Task 7 |
  534 +| `chore(frontend): init Vite React project skeleton REQ-USR-004` | Task 8 |
  535 +| `feat(usr): auth API layer + Redux authSlice REQ-USR-004` | Task 9 |
  536 +| `feat(usr): LoginPage brand selector + login form REQ-USR-004` | Task 10 |
docs/superpowers/reviews/2026-05-08-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-08
  4 +round: 3
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-001 — round 3
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +- docs/05-API接口契约.md — GET /api/usr/users/staffs 和 GET /api/usr/users/permission-groups 两个接口未写入 docs/05,规格已标注「不在原始清单」但未补录,建议在 module-report 阶段补充
  18 +- UserServiceImpl.java — CLAUDE.md §编码行为约束 第 4 条要求关键方法带 REQ-USR-001 注释标签,当前后端文件均缺失(仅 PermButton.tsx 有一处)
  19 +
  20 +## 反例 / 测试覆盖缺口
  21 +全部三轮 must-fix 已正确修复并通过 41 用例验证。实现与 spec 完全对齐:权限校验、唯一性检查、EMPLOYEE_NOT_FOUND=40001、批量 insert(Collection)、@Transactional 边界、UserPrincipal 基础设施均符合规格。两个 nice-to-have 均为文档/注释层面的缺失,不影响功能正确性。
docs/superpowers/reviews/2026-05-08-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-08
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-002 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +Round 1 全部 must-fix 已正确修复:
  17 +- M1 ✓ `updateUser_selfAdminKeepType_doesNotThrow` 测试已添加(含 userMapper.update stub)
  18 +- M2 ✓ `UserUpdateReqDTO` 已添加 @NotBlank/@Pattern/@NotNull 校验注解
  19 +- M3 ✓ `UserFormDrawer.InitialData` 已添加 permGroupIds 字段,useEffect 用 `?? []` 初始化
  20 +- M4 ✓ `UserListItemVO` 已添加 bCanEditDoc,mapper SELECT 已加列,UserListPage 传真实值
  21 +
  22 +## Nice-to-have
  23 +- backend/.../vo/UserUpdateRespVO.java — updatedAt 建议添加 @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") 符合 docs/04 § 3.3 规范
  24 +- frontend/src/test/UserListPage.test.tsx:106 — editMode_submit_callsUpdateUser 可补 toHaveBeenCalledWith('u1', ...) 断言 userId 参数正确
  25 +- frontend/src/pages/usr/UserListPage.tsx:121-128 — initialData 未传 permGroupIds(因列表 API 无此字段),与规格 line 103 说明一致,属有意为之,建议加注释说明
  26 +
  27 +## 反例 / 测试覆盖缺口
  28 +- controller 层 BizException 40300/40400/40301 → 错误码 JSON 映射缺测试(低风险,handler 已在 createUser 路径覆盖)
  29 +- happy-path 未用 ArgumentCaptor 断言 bCanEditDoc/bIsDisabled 映射为正确 int 值(低风险)
docs/superpowers/reviews/2026-05-08-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-08
  4 +round: 4
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-003 — round 4
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +- backend/src/main/java/com/example/erp/module/usr/controller/UserController.java:35-50 — docs/05 声明了 Permission: usr:query,但当前 SecurityConfig 仅做鉴权(authenticated),未启用 @PreAuthorize,属跨切面 REQ 的存量缺口,非本 REQ 引入
  18 +- backend/src/main/java/com/example/erp/common/response/Result.java:19 — docs/04 § 1.3 示例写 code:0,实际实现与 docs/05 一致为 200;docs/04 示例是历史遗留,可单独 docs commit 修正
  19 +- backend/src/main/resources/mapper/UsrUserMapper.xml:29,43 — staffName/department equals 分支未加 IS NULL OR,因为 equals '部门名' 语义上不应匹配无职员用户,行为正确但可补注释说明
  20 +- backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java:109 — getUsers_withToken_returns200 未用 ArgumentCaptor 验证五个参数均正确转发给 service
  21 +
  22 +## 反例 / 测试覆盖缺口
  23 +- 无 XML 动态查询路径(含/不含/等于、disabled 是否、lastLoginDate DATE 过滤、pageSize cap)的集成/切片测试;mapper 被 mock 后 XML 逻辑未被单元覆盖
  24 +- UserControllerTest 未断言响应中不含 sPasswordHash / iLoginFailCount / tLockUntil
  25 +- 前端测试未覆盖翻页 onChange 回调(load 以正确页码调用)