Commit 98ab7454a264409d7cfba786ac53d1ff3078cf95
Merge branch 'module-module_usr'
Showing
103 changed files
with
8198 additions
and
632 deletions
CLAUDE.md
| ... | ... | @@ -132,7 +132,7 @@ B 阶段分两段,**全部固化到 skills**。入口:`/erp-workflow:coding- |
| 132 | 132 | |
| 133 | 133 | ### 你禁止做的 🚫 |
| 134 | 134 | |
| 135 | -1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约 | |
| 135 | +1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试 / A4 `db-init` 首次 apply V1 验证除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约。**A4 例外**:`db-init` 在 A 阶段 setup-test-db 后会一次性手工 `mysql < V1__initial_schema.sql` 把 V1 灌入测试库,并校验 `SHOW TABLES` 行数 = docs/03 表数量,用于 DDL 自检;B 阶段(Spring Boot 启动后)Flyway 会重建 schema 并 apply 全部 migration(包括 V1),手工 apply 不会污染 Flyway 历史。 | |
| 136 | 136 | 2. **手动 Edit `docs/08 § 二/§ 三` 的 `MR:` / `整体 MR:` 字段**,必须要由 `mr-create` 自动回写 |
| 137 | 137 | |
| 138 | 138 | ### Schema 演化规约(Flyway migration) | ... | ... |
backend/pom.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
| 5 | + <modelVersion>4.0.0</modelVersion> | |
| 6 | + | |
| 7 | + <parent> | |
| 8 | + <groupId>org.springframework.boot</groupId> | |
| 9 | + <artifactId>spring-boot-starter-parent</artifactId> | |
| 10 | + <version>3.3.4</version> | |
| 11 | + <relativePath/> | |
| 12 | + </parent> | |
| 13 | + | |
| 14 | + <groupId>com.xly.erp</groupId> | |
| 15 | + <artifactId>xly-erp-backend</artifactId> | |
| 16 | + <version>0.0.1-SNAPSHOT</version> | |
| 17 | + <packaging>jar</packaging> | |
| 18 | + <name>xly-erp-backend</name> | |
| 19 | + <description>小羚羊 ERP 后端</description> | |
| 20 | + | |
| 21 | + <properties> | |
| 22 | + <java.version>17</java.version> | |
| 23 | + <maven.compiler.source>17</maven.compiler.source> | |
| 24 | + <maven.compiler.target>17</maven.compiler.target> | |
| 25 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
| 26 | + <mybatis-plus.version>3.5.7</mybatis-plus.version> | |
| 27 | + <jjwt.version>0.12.5</jjwt.version> | |
| 28 | + <lombok.version>1.18.40</lombok.version> | |
| 29 | + </properties> | |
| 30 | + | |
| 31 | + <dependencies> | |
| 32 | + <dependency> | |
| 33 | + <groupId>org.springframework.boot</groupId> | |
| 34 | + <artifactId>spring-boot-starter-web</artifactId> | |
| 35 | + </dependency> | |
| 36 | + <dependency> | |
| 37 | + <groupId>org.springframework.boot</groupId> | |
| 38 | + <artifactId>spring-boot-starter-validation</artifactId> | |
| 39 | + </dependency> | |
| 40 | + <dependency> | |
| 41 | + <groupId>org.springframework.security</groupId> | |
| 42 | + <artifactId>spring-security-crypto</artifactId> | |
| 43 | + </dependency> | |
| 44 | + | |
| 45 | + <dependency> | |
| 46 | + <groupId>com.baomidou</groupId> | |
| 47 | + <artifactId>mybatis-plus-spring-boot3-starter</artifactId> | |
| 48 | + <version>${mybatis-plus.version}</version> | |
| 49 | + </dependency> | |
| 50 | + | |
| 51 | + <dependency> | |
| 52 | + <groupId>com.mysql</groupId> | |
| 53 | + <artifactId>mysql-connector-j</artifactId> | |
| 54 | + <scope>runtime</scope> | |
| 55 | + </dependency> | |
| 56 | + | |
| 57 | + <dependency> | |
| 58 | + <groupId>org.flywaydb</groupId> | |
| 59 | + <artifactId>flyway-core</artifactId> | |
| 60 | + </dependency> | |
| 61 | + <dependency> | |
| 62 | + <groupId>org.flywaydb</groupId> | |
| 63 | + <artifactId>flyway-mysql</artifactId> | |
| 64 | + </dependency> | |
| 65 | + | |
| 66 | + <dependency> | |
| 67 | + <groupId>io.jsonwebtoken</groupId> | |
| 68 | + <artifactId>jjwt-api</artifactId> | |
| 69 | + <version>${jjwt.version}</version> | |
| 70 | + </dependency> | |
| 71 | + <dependency> | |
| 72 | + <groupId>io.jsonwebtoken</groupId> | |
| 73 | + <artifactId>jjwt-impl</artifactId> | |
| 74 | + <version>${jjwt.version}</version> | |
| 75 | + <scope>runtime</scope> | |
| 76 | + </dependency> | |
| 77 | + <dependency> | |
| 78 | + <groupId>io.jsonwebtoken</groupId> | |
| 79 | + <artifactId>jjwt-jackson</artifactId> | |
| 80 | + <version>${jjwt.version}</version> | |
| 81 | + <scope>runtime</scope> | |
| 82 | + </dependency> | |
| 83 | + | |
| 84 | + <dependency> | |
| 85 | + <groupId>org.projectlombok</groupId> | |
| 86 | + <artifactId>lombok</artifactId> | |
| 87 | + <version>${lombok.version}</version> | |
| 88 | + <optional>true</optional> | |
| 89 | + </dependency> | |
| 90 | + | |
| 91 | + <dependency> | |
| 92 | + <groupId>org.springframework.boot</groupId> | |
| 93 | + <artifactId>spring-boot-starter-test</artifactId> | |
| 94 | + <scope>test</scope> | |
| 95 | + </dependency> | |
| 96 | + </dependencies> | |
| 97 | + | |
| 98 | + <build> | |
| 99 | + <plugins> | |
| 100 | + <plugin> | |
| 101 | + <groupId>org.apache.maven.plugins</groupId> | |
| 102 | + <artifactId>maven-compiler-plugin</artifactId> | |
| 103 | + <configuration> | |
| 104 | + <annotationProcessorPaths> | |
| 105 | + <path> | |
| 106 | + <groupId>org.projectlombok</groupId> | |
| 107 | + <artifactId>lombok</artifactId> | |
| 108 | + <version>${lombok.version}</version> | |
| 109 | + </path> | |
| 110 | + </annotationProcessorPaths> | |
| 111 | + </configuration> | |
| 112 | + </plugin> | |
| 113 | + <plugin> | |
| 114 | + <groupId>org.springframework.boot</groupId> | |
| 115 | + <artifactId>spring-boot-maven-plugin</artifactId> | |
| 116 | + <configuration> | |
| 117 | + <excludes> | |
| 118 | + <exclude> | |
| 119 | + <groupId>org.projectlombok</groupId> | |
| 120 | + <artifactId>lombok</artifactId> | |
| 121 | + </exclude> | |
| 122 | + </excludes> | |
| 123 | + </configuration> | |
| 124 | + </plugin> | |
| 125 | + <plugin> | |
| 126 | + <groupId>org.apache.maven.plugins</groupId> | |
| 127 | + <artifactId>maven-surefire-plugin</artifactId> | |
| 128 | + <configuration> | |
| 129 | + <environmentVariables> | |
| 130 | + <DB_HOST>${env.DB_HOST}</DB_HOST> | |
| 131 | + <DB_PORT>${env.DB_PORT}</DB_PORT> | |
| 132 | + <DB_USER>${env.DB_USER}</DB_USER> | |
| 133 | + <DB_PASSWORD>${env.DB_PASSWORD}</DB_PASSWORD> | |
| 134 | + <DB_SCHEMA>${env.DB_SCHEMA}</DB_SCHEMA> | |
| 135 | + <JWT_SECRET>${env.JWT_SECRET}</JWT_SECRET> | |
| 136 | + </environmentVariables> | |
| 137 | + </configuration> | |
| 138 | + </plugin> | |
| 139 | + </plugins> | |
| 140 | + </build> | |
| 141 | +</project> | ... | ... |
backend/src/main/java/com/xly/erp/Application.java
0 → 100644
| 1 | +package com.xly.erp; | |
| 2 | + | |
| 3 | +import org.mybatis.spring.annotation.MapperScan; | |
| 4 | +import org.springframework.boot.SpringApplication; | |
| 5 | +import org.springframework.boot.autoconfigure.SpringBootApplication; | |
| 6 | + | |
| 7 | +@SpringBootApplication | |
| 8 | +@MapperScan("com.xly.erp.module.*.mapper") | |
| 9 | +public class Application { | |
| 10 | + public static void main(String[] args) { | |
| 11 | + SpringApplication.run(Application.class, args); | |
| 12 | + } | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import org.springframework.context.annotation.Bean; | |
| 4 | +import org.springframework.context.annotation.Configuration; | |
| 5 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * BCrypt 密码编码器 Bean。strength=10(Spring Security 默认)。 | |
| 9 | + * docs/03 sys_user.sPasswordHash + docs/04 § 1.6。 | |
| 10 | + */ | |
| 11 | +@Configuration | |
| 12 | +public class PasswordEncoderConfig { | |
| 13 | + | |
| 14 | + @Bean | |
| 15 | + public BCryptPasswordEncoder passwordEncoder() { | |
| 16 | + return new BCryptPasswordEncoder(10); | |
| 17 | + } | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.security.JwtHandlerInterceptor; | |
| 4 | +import lombok.RequiredArgsConstructor; | |
| 5 | +import org.springframework.context.annotation.Configuration; | |
| 6 | +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | |
| 7 | +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | |
| 8 | + | |
| 9 | +@Configuration | |
| 10 | +@RequiredArgsConstructor | |
| 11 | +public class WebMvcConfig implements WebMvcConfigurer { | |
| 12 | + | |
| 13 | + private final JwtHandlerInterceptor jwtInterceptor; | |
| 14 | + | |
| 15 | + @Override | |
| 16 | + public void addInterceptors(InterceptorRegistry registry) { | |
| 17 | + registry.addInterceptor(jwtInterceptor) | |
| 18 | + .addPathPatterns("/api/v1/**") | |
| 19 | + .excludePathPatterns("/api/v1/auth/login"); | |
| 20 | + } | |
| 21 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/exception/BizException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import lombok.Getter; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 业务异常 — 由 service 层抛出,由 GlobalExceptionHandler 统一转 Result.fail。 | |
| 7 | + * docs/04 § 1.4。 | |
| 8 | + */ | |
| 9 | +@Getter | |
| 10 | +public class BizException extends RuntimeException { | |
| 11 | + private final int code; | |
| 12 | + /** 可选附带的响应数据(例如 42301 锁定返 lockUntil)。null 表示无 data 字段。 */ | |
| 13 | + private final Object data; | |
| 14 | + | |
| 15 | + public BizException(int code, String message) { | |
| 16 | + this(code, message, (Object) null); | |
| 17 | + } | |
| 18 | + | |
| 19 | + public BizException(int code, String message, Object data) { | |
| 20 | + super(message); | |
| 21 | + this.code = code; | |
| 22 | + this.data = data; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public BizException(int code, String message, Throwable cause) { | |
| 26 | + super(message, cause); | |
| 27 | + this.code = code; | |
| 28 | + this.data = null; | |
| 29 | + } | |
| 30 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ErrorCode; | |
| 4 | +import com.xly.erp.common.response.Result; | |
| 5 | +import jakarta.validation.ConstraintViolationException; | |
| 6 | +import lombok.extern.slf4j.Slf4j; | |
| 7 | +import org.springframework.http.ResponseEntity; | |
| 8 | +import org.springframework.http.converter.HttpMessageNotReadableException; | |
| 9 | +import org.springframework.web.bind.MethodArgumentNotValidException; | |
| 10 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 11 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 全局异常处理器。 | |
| 15 | + * 把 BizException / 参数校验异常 / 兜底异常转 Result.fail 统一响应。 | |
| 16 | + * docs/04 § 1.4。 | |
| 17 | + */ | |
| 18 | +@RestControllerAdvice | |
| 19 | +@Slf4j | |
| 20 | +public class GlobalExceptionHandler { | |
| 21 | + | |
| 22 | + @ExceptionHandler(BizException.class) | |
| 23 | + public ResponseEntity<Result<Object>> handleBiz(BizException e) { | |
| 24 | + log.warn("[BizException] code={} message={} hasData={}", e.getCode(), e.getMessage(), e.getData() != null); | |
| 25 | + Result<Object> body = e.getData() != null | |
| 26 | + ? Result.fail(e.getCode(), e.getMessage(), e.getData()) | |
| 27 | + : Result.fail(e.getCode(), e.getMessage()); | |
| 28 | + return ResponseEntity | |
| 29 | + .status(ErrorCode.toHttpStatus(e.getCode())) | |
| 30 | + .body(body); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @ExceptionHandler(MethodArgumentNotValidException.class) | |
| 34 | + public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException e) { | |
| 35 | + String msg = e.getBindingResult().getFieldErrors().stream() | |
| 36 | + .findFirst() | |
| 37 | + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) | |
| 38 | + .orElse("参数校验失败"); | |
| 39 | + return ResponseEntity | |
| 40 | + .status(400) | |
| 41 | + .body(Result.fail(ErrorCode.BAD_REQUEST, msg)); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @ExceptionHandler(ConstraintViolationException.class) | |
| 45 | + public ResponseEntity<Result<Void>> handleConstraint(ConstraintViolationException e) { | |
| 46 | + return ResponseEntity | |
| 47 | + .status(400) | |
| 48 | + .body(Result.fail(ErrorCode.BAD_REQUEST, e.getMessage())); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @ExceptionHandler(HttpMessageNotReadableException.class) | |
| 52 | + public ResponseEntity<Result<Void>> handleNotReadable(HttpMessageNotReadableException e) { | |
| 53 | + log.warn("[HttpMessageNotReadable] {}", e.getMessage()); | |
| 54 | + return ResponseEntity | |
| 55 | + .status(400) | |
| 56 | + .body(Result.fail(ErrorCode.BAD_REQUEST, "请求体格式不合法或包含未知字段")); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @ExceptionHandler(Exception.class) | |
| 60 | + public ResponseEntity<Result<Void>> handleFallback(Exception e) { | |
| 61 | + log.error("[Unhandled] {}", e.getMessage(), e); | |
| 62 | + return ResponseEntity | |
| 63 | + .status(500) | |
| 64 | + .body(Result.fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误")); | |
| 65 | + } | |
| 66 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 全局错误码定义。 | |
| 5 | + * 段位约定见 docs/04 § 1.3。 | |
| 6 | + */ | |
| 7 | +public final class ErrorCode { | |
| 8 | + | |
| 9 | + private ErrorCode() {} | |
| 10 | + | |
| 11 | + public static final int OK = 200; | |
| 12 | + | |
| 13 | + public static final int BAD_REQUEST = 40001; | |
| 14 | + public static final int INVALID_ENUM_PARAM = 40003; | |
| 15 | + public static final int COMPANY_NOT_FOUND = 40004; | |
| 16 | + | |
| 17 | + public static final int BAD_CREDENTIALS = 40101; | |
| 18 | + public static final int ACCOUNT_DELETED = 40103; | |
| 19 | + | |
| 20 | + public static final int FORBIDDEN = 40301; | |
| 21 | + public static final int USER_FORBIDDEN_SELF_DEACTIVATE = 40302; | |
| 22 | + | |
| 23 | + public static final int USER_NOT_FOUND = 40401; | |
| 24 | + | |
| 25 | + public static final int ACCOUNT_LOCKED = 42301; | |
| 26 | + | |
| 27 | + public static final int CONFLICT_USERNAME = 40901; | |
| 28 | + public static final int CONFLICT_USERCODE = 40902; | |
| 29 | + | |
| 30 | + public static final int INTERNAL_ERROR = 50000; | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 业务 code → HTTP 状态码映射。 | |
| 34 | + */ | |
| 35 | + public static int toHttpStatus(int code) { | |
| 36 | + if (code == OK) return 200; | |
| 37 | + if (code == ACCOUNT_LOCKED) return 423; | |
| 38 | + int hundreds = code / 100; | |
| 39 | + if (hundreds == 400) return 400; | |
| 40 | + if (hundreds == 401) return 401; | |
| 41 | + if (hundreds == 403) return 403; | |
| 42 | + if (hundreds == 404) return 404; | |
| 43 | + if (hundreds == 409) return 409; | |
| 44 | + if (hundreds == 423) return 423; | |
| 45 | + if (hundreds == 500) return 500; | |
| 46 | + return 500; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/PageResult.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import lombok.Builder; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 通用分页响应包装。docs/04 § 3.2。 | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +@Builder | |
| 13 | +public class PageResult<T> { | |
| 14 | + private List<T> records; | |
| 15 | + private long total; | |
| 16 | + private int page; | |
| 17 | + private int size; | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/Result.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import lombok.Getter; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 统一响应包装。 | |
| 7 | + * docs/04 § 1.3。 | |
| 8 | + */ | |
| 9 | +@Getter | |
| 10 | +public class Result<T> { | |
| 11 | + private final int code; | |
| 12 | + private final String message; | |
| 13 | + private final T data; | |
| 14 | + private final long timestamp; | |
| 15 | + | |
| 16 | + private Result(int code, String message, T data) { | |
| 17 | + this.code = code; | |
| 18 | + this.message = message; | |
| 19 | + this.data = data; | |
| 20 | + this.timestamp = System.currentTimeMillis(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + public static <T> Result<T> ok(T data) { | |
| 24 | + return new Result<>(ErrorCode.OK, "操作成功", data); | |
| 25 | + } | |
| 26 | + | |
| 27 | + public static Result<Void> ok() { | |
| 28 | + return new Result<>(ErrorCode.OK, "操作成功", null); | |
| 29 | + } | |
| 30 | + | |
| 31 | + public static <T> Result<T> fail(int code, String message) { | |
| 32 | + return new Result<>(code, message, null); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @SuppressWarnings("unchecked") | |
| 36 | + public static <T> Result<T> fail(int code, String message, T data) { | |
| 37 | + return new Result<>(code, message, data); | |
| 38 | + } | |
| 39 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 6 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 7 | +import jakarta.servlet.http.HttpServletRequest; | |
| 8 | +import jakarta.servlet.http.HttpServletResponse; | |
| 9 | +import lombok.RequiredArgsConstructor; | |
| 10 | +import lombok.extern.slf4j.Slf4j; | |
| 11 | +import org.springframework.stereotype.Component; | |
| 12 | +import org.springframework.web.method.HandlerMethod; | |
| 13 | +import org.springframework.web.servlet.HandlerInterceptor; | |
| 14 | + | |
| 15 | +import java.time.LocalDateTime; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * REQ-USR-002 鉴权与角色守卫拦截器。 | |
| 20 | + * - 解析 Authorization Bearer → 校验 user 状态 → set LoginContext | |
| 21 | + * - 若 handler 标注 @RequireSuperAdmin,强制 userType == SUPER_ADMIN | |
| 22 | + * - afterCompletion 清理 ThreadLocal | |
| 23 | + */ | |
| 24 | +@Component | |
| 25 | +@RequiredArgsConstructor | |
| 26 | +@Slf4j | |
| 27 | +public class JwtHandlerInterceptor implements HandlerInterceptor { | |
| 28 | + | |
| 29 | + private static final String BEARER_PREFIX = "Bearer "; | |
| 30 | + | |
| 31 | + private final JwtUtil jwtUtil; | |
| 32 | + private final SysUserMapper userMapper; | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { | |
| 36 | + String authHeader = request.getHeader("Authorization"); | |
| 37 | + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { | |
| 38 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "未携带 token"); | |
| 39 | + } | |
| 40 | + String token = authHeader.substring(BEARER_PREFIX.length()); | |
| 41 | + | |
| 42 | + Map<String, Object> claims = jwtUtil.parse(token); | |
| 43 | + String username = (String) claims.get("username"); | |
| 44 | + if (username == null || username.isBlank()) { | |
| 45 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 缺 username claim"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + SysUser user = userMapper.selectByUsername(username); | |
| 49 | + if (user == null) { | |
| 50 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户不存在"); | |
| 51 | + } | |
| 52 | + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { | |
| 53 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已作废"); | |
| 54 | + } | |
| 55 | + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { | |
| 56 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已锁定"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + String companyCode = (String) claims.get("companyCode"); | |
| 60 | + LoginContext.set(new LoginContext.LoginUser( | |
| 61 | + user.getIIncrement(), | |
| 62 | + user.getSUsername(), | |
| 63 | + user.getSUserType(), | |
| 64 | + companyCode)); | |
| 65 | + | |
| 66 | + if (handler instanceof HandlerMethod hm) { | |
| 67 | + if (hm.getMethodAnnotation(RequireSuperAdmin.class) != null | |
| 68 | + && !"SUPER_ADMIN".equals(user.getSUserType())) { | |
| 69 | + throw new BizException(ErrorCode.FORBIDDEN, "权限不足,仅超级管理员可调用"); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return true; | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Override | |
| 76 | + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, | |
| 77 | + Object handler, Exception ex) { | |
| 78 | + LoginContext.clear(); | |
| 79 | + } | |
| 80 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import io.jsonwebtoken.Claims; | |
| 6 | +import io.jsonwebtoken.JwtException; | |
| 7 | +import io.jsonwebtoken.Jwts; | |
| 8 | +import io.jsonwebtoken.security.Keys; | |
| 9 | +import jakarta.annotation.PostConstruct; | |
| 10 | +import org.springframework.beans.factory.annotation.Value; | |
| 11 | +import org.springframework.stereotype.Component; | |
| 12 | + | |
| 13 | +import javax.crypto.SecretKey; | |
| 14 | +import java.nio.charset.StandardCharsets; | |
| 15 | +import java.util.Date; | |
| 16 | +import java.util.HashMap; | |
| 17 | +import java.util.Map; | |
| 18 | +import java.util.UUID; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * JWT 签发与验证工具。HS256,密钥来自 ${JWT_SECRET}。 | |
| 22 | + * docs/04 § 1.6。 | |
| 23 | + */ | |
| 24 | +@Component | |
| 25 | +public class JwtUtil { | |
| 26 | + | |
| 27 | + @Value("${jwt.secret}") | |
| 28 | + private String secret; | |
| 29 | + | |
| 30 | + private SecretKey key; | |
| 31 | + | |
| 32 | + @PostConstruct | |
| 33 | + void init() { | |
| 34 | + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); | |
| 35 | + if (bytes.length < 32) { | |
| 36 | + throw new IllegalStateException( | |
| 37 | + "JWT_SECRET 长度不足 32 字节(HS256 要求),实际 " + bytes.length | |
| 38 | + + " 字节。请在 .env.local 配置至少 256 位的随机字符串。"); | |
| 39 | + } | |
| 40 | + this.key = Keys.hmacShaKeyFor(bytes); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public String issue(Map<String, Object> claims, long ttlSec) { | |
| 44 | + long now = System.currentTimeMillis(); | |
| 45 | + Map<String, Object> all = new HashMap<>(claims); | |
| 46 | + String sub = String.valueOf(all.remove("sub")); | |
| 47 | + String jti = UUID.randomUUID().toString(); | |
| 48 | + return Jwts.builder() | |
| 49 | + .subject(sub) | |
| 50 | + .claims(all) | |
| 51 | + .id(jti) | |
| 52 | + .issuedAt(new Date(now)) | |
| 53 | + .expiration(new Date(now + ttlSec * 1000L)) | |
| 54 | + .signWith(key) | |
| 55 | + .compact(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public Map<String, Object> parse(String token) { | |
| 59 | + try { | |
| 60 | + Claims claims = Jwts.parser() | |
| 61 | + .verifyWith(key) | |
| 62 | + .build() | |
| 63 | + .parseSignedClaims(token) | |
| 64 | + .getPayload(); | |
| 65 | + Map<String, Object> out = new HashMap<>(claims); | |
| 66 | + out.put("sub", claims.getSubject()); | |
| 67 | + out.put("jti", claims.getId()); | |
| 68 | + out.put("iat", claims.getIssuedAt() != null ? claims.getIssuedAt().getTime() / 1000 : null); | |
| 69 | + out.put("exp", claims.getExpiration() != null ? claims.getExpiration().getTime() / 1000 : null); | |
| 70 | + return out; | |
| 71 | + } catch (JwtException e) { | |
| 72 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 无效或已过期"); | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/LoginContext.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 请求级登录上下文 — JwtHandlerInterceptor 在 preHandle 时 set,afterCompletion 时 clear。 | |
| 5 | + * 用普通 ThreadLocal(不用 InheritableThreadLocal)避免子线程意外继承。 | |
| 6 | + */ | |
| 7 | +public final class LoginContext { | |
| 8 | + | |
| 9 | + private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); | |
| 10 | + | |
| 11 | + private LoginContext() {} | |
| 12 | + | |
| 13 | + public static void set(LoginUser user) { | |
| 14 | + HOLDER.set(user); | |
| 15 | + } | |
| 16 | + | |
| 17 | + public static LoginUser current() { | |
| 18 | + return HOLDER.get(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public static void clear() { | |
| 22 | + HOLDER.remove(); | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** 当前登录用户上下文。userType 取值 NORMAL / SUPER_ADMIN。 */ | |
| 26 | + public record LoginUser(Integer userId, String username, String userType, String companyCode) {} | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import java.lang.annotation.ElementType; | |
| 4 | +import java.lang.annotation.Retention; | |
| 5 | +import java.lang.annotation.RetentionPolicy; | |
| 6 | +import java.lang.annotation.Target; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 标注在 controller 方法上,要求当前登录用户 userType == SUPER_ADMIN。 | |
| 10 | + * 由 JwtHandlerInterceptor 在 preHandle 时校验。 | |
| 11 | + */ | |
| 12 | +@Target(ElementType.METHOD) | |
| 13 | +@Retention(RetentionPolicy.RUNTIME) | |
| 14 | +public @interface RequireSuperAdmin { | |
| 15 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.module.usr.dto.LoginReq; | |
| 5 | +import com.xly.erp.module.usr.service.LoginService; | |
| 6 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 7 | +import jakarta.validation.Valid; | |
| 8 | +import lombok.RequiredArgsConstructor; | |
| 9 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 11 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 12 | +import org.springframework.web.bind.annotation.RestController; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 认证入口。REQ-USR-001:POST /api/v1/auth/login。 | |
| 16 | + */ | |
| 17 | +@RestController | |
| 18 | +@RequestMapping("/api/v1/auth") | |
| 19 | +@RequiredArgsConstructor | |
| 20 | +public class AuthController { | |
| 21 | + | |
| 22 | + private final LoginService loginService; | |
| 23 | + | |
| 24 | + @PostMapping("/login") | |
| 25 | + public Result<LoginVo> login(@RequestBody @Valid LoginReq req) { | |
| 26 | + LoginVo vo = loginService.login(req.getUsername(), req.getPassword(), req.getCompanyCode()); | |
| 27 | + return Result.ok(vo); | |
| 28 | + } | |
| 29 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.PageResult; | |
| 4 | +import com.xly.erp.common.response.Result; | |
| 5 | +import com.xly.erp.common.security.LoginContext; | |
| 6 | +import com.xly.erp.common.security.RequireSuperAdmin; | |
| 7 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 8 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 9 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 10 | +import com.xly.erp.module.usr.service.UserCreateService; | |
| 11 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 12 | +import com.xly.erp.module.usr.service.UserListService; | |
| 13 | +import com.xly.erp.module.usr.service.UserUpdateService; | |
| 14 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 15 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 16 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 17 | +import jakarta.validation.Valid; | |
| 18 | +import lombok.RequiredArgsConstructor; | |
| 19 | +import org.springframework.http.HttpStatus; | |
| 20 | +import org.springframework.http.ResponseEntity; | |
| 21 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 22 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 23 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 24 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 25 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 26 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 27 | +import org.springframework.web.bind.annotation.RestController; | |
| 28 | + | |
| 29 | +@RestController | |
| 30 | +@RequestMapping("/api/v1/users") | |
| 31 | +@RequiredArgsConstructor | |
| 32 | +public class UserController { | |
| 33 | + | |
| 34 | + private final UserCreateService userCreateService; | |
| 35 | + private final UserDetailService userDetailService; | |
| 36 | + private final UserUpdateService userUpdateService; | |
| 37 | + private final UserListService userListService; | |
| 38 | + | |
| 39 | + @PostMapping | |
| 40 | + @RequireSuperAdmin | |
| 41 | + public ResponseEntity<Result<CreateUserVo>> create(@RequestBody @Valid CreateUserReq req) { | |
| 42 | + String operator = LoginContext.current().username(); | |
| 43 | + CreateUserVo vo = userCreateService.create(req, operator); | |
| 44 | + return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo)); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @GetMapping | |
| 48 | + @RequireSuperAdmin | |
| 49 | + public Result<PageResult<UserListItemVo>> list(@Valid UserQueryReq req) { | |
| 50 | + return Result.ok(userListService.list(req)); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @GetMapping("/{userId}") | |
| 54 | + @RequireSuperAdmin | |
| 55 | + public Result<UserDetailVo> getById(@PathVariable Integer userId) { | |
| 56 | + return Result.ok(userDetailService.getById(userId)); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @PutMapping("/{userId}") | |
| 60 | + @RequireSuperAdmin | |
| 61 | + public Result<UserDetailVo> update(@PathVariable Integer userId, | |
| 62 | + @RequestBody @Valid UpdateUserReq req) { | |
| 63 | + LoginContext.LoginUser cur = LoginContext.current(); | |
| 64 | + UserDetailVo vo = userUpdateService.update(userId, req, cur.userId(), cur.username()); | |
| 65 | + return Result.ok(vo); | |
| 66 | + } | |
| 67 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.NotNull; | |
| 5 | +import jakarta.validation.constraints.Pattern; | |
| 6 | +import jakarta.validation.constraints.Size; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +@Data | |
| 12 | +public class CreateUserReq { | |
| 13 | + | |
| 14 | + @NotBlank | |
| 15 | + @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", | |
| 16 | + message = "用户名必须为 3-20 位字母数字下划线") | |
| 17 | + private String username; | |
| 18 | + | |
| 19 | + @NotBlank | |
| 20 | + @Size(max = 50) | |
| 21 | + private String userCode; | |
| 22 | + | |
| 23 | + @NotBlank | |
| 24 | + @Pattern(regexp = "NORMAL|SUPER_ADMIN", | |
| 25 | + message = "userType 必须为 NORMAL 或 SUPER_ADMIN") | |
| 26 | + private String userType; | |
| 27 | + | |
| 28 | + @NotBlank | |
| 29 | + @Pattern(regexp = "zh-CN|en-US|zh-TW", | |
| 30 | + message = "language 必须为 zh-CN / en-US / zh-TW") | |
| 31 | + private String language; | |
| 32 | + | |
| 33 | + @NotNull | |
| 34 | + private Boolean canEditDocument; | |
| 35 | + | |
| 36 | + /** 可选;非空则必须命中 sys_employee.iIncrement 且 iIsDeleted=0 */ | |
| 37 | + private Integer employeeId; | |
| 38 | + | |
| 39 | + /** 可选;空数组 / null 都允许;非空时每个 ID 必须命中 sys_permission_category */ | |
| 40 | + private List<Integer> permissionCategoryIds; | |
| 41 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +@Data | |
| 8 | +public class LoginReq { | |
| 9 | + | |
| 10 | + @NotBlank | |
| 11 | + @Size(max = 50) | |
| 12 | + private String username; | |
| 13 | + | |
| 14 | + @NotBlank | |
| 15 | + @Size(max = 128) | |
| 16 | + private String password; | |
| 17 | + | |
| 18 | + @NotBlank | |
| 19 | + @Size(max = 50) | |
| 20 | + private String companyCode; | |
| 21 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Min; | |
| 4 | +import jakarta.validation.constraints.Pattern; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * PATCH 语义:所有字段都可选;缺省 / 显式 null 视为不变。 | |
| 12 | + * 特例:employeeId == 0 视为解除关联(DB 写 NULL)。 | |
| 13 | + */ | |
| 14 | +@Data | |
| 15 | +public class UpdateUserReq { | |
| 16 | + | |
| 17 | + @Size(max = 50) | |
| 18 | + @Pattern(regexp = "^\\S+$", message = "userCode 不可为空白") | |
| 19 | + private String userCode; | |
| 20 | + | |
| 21 | + @Pattern(regexp = "NORMAL|SUPER_ADMIN", | |
| 22 | + message = "userType 必须为 NORMAL 或 SUPER_ADMIN") | |
| 23 | + private String userType; | |
| 24 | + | |
| 25 | + @Pattern(regexp = "zh-CN|en-US|zh-TW", | |
| 26 | + message = "language 必须为 zh-CN / en-US / zh-TW") | |
| 27 | + private String language; | |
| 28 | + | |
| 29 | + private Boolean canEditDocument; | |
| 30 | + | |
| 31 | + @Min(value = 0, message = "employeeId 必须 >= 0;0 表示解除关联") | |
| 32 | + private Integer employeeId; | |
| 33 | + | |
| 34 | + private Boolean isDeleted; | |
| 35 | + | |
| 36 | + private List<Integer> permissionCategoryIds; | |
| 37 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Max; | |
| 4 | +import jakarta.validation.constraints.Min; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 用户列表查询请求。所有字段可选;枚举值白名单由 service 层校验。 | |
| 9 | + * REQ-USR-004。 | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +public class UserQueryReq { | |
| 13 | + | |
| 14 | + @Min(value = 1, message = "page 必须 >= 1") | |
| 15 | + private Integer page; | |
| 16 | + | |
| 17 | + @Min(value = 1, message = "size 必须 >= 1") | |
| 18 | + @Max(value = 100, message = "size 不能超过 100") | |
| 19 | + private Integer size; | |
| 20 | + | |
| 21 | + private String sortField; | |
| 22 | + private String sortOrder; | |
| 23 | + private String queryField; | |
| 24 | + private String matchMode; | |
| 25 | + private String queryValue; | |
| 26 | + private String userType; | |
| 27 | + private Boolean isDeleted; | |
| 28 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 公司表实体(只需登录用到的字段)。docs/03 § sys_company。 | |
| 12 | + */ | |
| 13 | +@Data | |
| 14 | +@TableName("sys_company") | |
| 15 | +public class SysCompany { | |
| 16 | + | |
| 17 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 18 | + private Integer iIncrement; | |
| 19 | + | |
| 20 | + private String sId; | |
| 21 | + private String sBrandsId; | |
| 22 | + private String sSubsidiaryId; | |
| 23 | + private LocalDateTime tCreateDate; | |
| 24 | + | |
| 25 | + private String sCompanyName; | |
| 26 | + private String sCompanyCode; | |
| 27 | + private Integer iSortOrder; | |
| 28 | + private Integer iIsDeleted; | |
| 29 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 职员表实体(只读 join,含登录返回 employeeName 所需的最小字段)。 | |
| 12 | + * docs/03 § sys_employee。 | |
| 13 | + */ | |
| 14 | +@Data | |
| 15 | +@TableName("sys_employee") | |
| 16 | +public class SysEmployee { | |
| 17 | + | |
| 18 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 19 | + private Integer iIncrement; | |
| 20 | + | |
| 21 | + private String sId; | |
| 22 | + private String sBrandsId; | |
| 23 | + private String sSubsidiaryId; | |
| 24 | + private LocalDateTime tCreateDate; | |
| 25 | + | |
| 26 | + private String sEmployeeName; | |
| 27 | + private String sEmployeeCode; | |
| 28 | + private Integer iDepartmentId; | |
| 29 | + private String sPhone; | |
| 30 | + private String sEmail; | |
| 31 | + private Integer iIsDeleted; | |
| 32 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@Data | |
| 11 | +@TableName("sys_permission_category") | |
| 12 | +public class SysPermissionCategory { | |
| 13 | + | |
| 14 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 15 | + private Integer iIncrement; | |
| 16 | + | |
| 17 | + private String sId; | |
| 18 | + private String sBrandsId; | |
| 19 | + private String sSubsidiaryId; | |
| 20 | + private LocalDateTime tCreateDate; | |
| 21 | + | |
| 22 | + private String sCategoryName; | |
| 23 | + private String sCategoryCode; | |
| 24 | + private String sCategoryDesc; | |
| 25 | + private Integer iSortOrder; | |
| 26 | + private Integer iIsDeleted; | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 用户表实体。docs/03 § sys_user。 | |
| 12 | + */ | |
| 13 | +@Data | |
| 14 | +@TableName("sys_user") | |
| 15 | +public class SysUser { | |
| 16 | + | |
| 17 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 18 | + private Integer iIncrement; | |
| 19 | + | |
| 20 | + private String sId; | |
| 21 | + private String sBrandsId; | |
| 22 | + private String sSubsidiaryId; | |
| 23 | + private LocalDateTime tCreateDate; | |
| 24 | + | |
| 25 | + private String sUsername; | |
| 26 | + private String sUserCode; | |
| 27 | + private String sPasswordHash; | |
| 28 | + | |
| 29 | + private Integer iEmployeeId; | |
| 30 | + private String sUserType; | |
| 31 | + private String sLanguage; | |
| 32 | + private Integer iCanEditDocument; | |
| 33 | + private Integer iIsDeleted; | |
| 34 | + private Integer iFailedLoginCount; | |
| 35 | + private LocalDateTime tLockUntil; | |
| 36 | + private LocalDateTime tLastLoginDate; | |
| 37 | + private String sCreatedBy; | |
| 38 | + private String sUpdatedBy; | |
| 39 | + private LocalDateTime tUpdatedDate; | |
| 40 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@Data | |
| 11 | +@TableName("sys_user_permission_category") | |
| 12 | +public class SysUserPermissionCategory { | |
| 13 | + | |
| 14 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 15 | + private Integer iIncrement; | |
| 16 | + | |
| 17 | + private String sId; | |
| 18 | + private String sBrandsId; | |
| 19 | + private String sSubsidiaryId; | |
| 20 | + private LocalDateTime tCreateDate; | |
| 21 | + | |
| 22 | + private Integer iUserId; | |
| 23 | + private Integer iPermissionCategoryId; | |
| 24 | + private String sGrantedBy; | |
| 25 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | +import org.apache.ibatis.annotations.Select; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface SysCompanyMapper extends BaseMapper<SysCompany> { | |
| 10 | + | |
| 11 | + @Select("SELECT iIncrement, sCompanyCode, sCompanyName, iIsDeleted " + | |
| 12 | + "FROM sys_company WHERE sCompanyCode = #{code} LIMIT 1") | |
| 13 | + SysCompany selectByCode(String code); | |
| 14 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface SysEmployeeMapper extends BaseMapper<SysEmployee> { | |
| 9 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysPermissionCategory; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | +import org.apache.ibatis.annotations.Param; | |
| 7 | +import org.apache.ibatis.annotations.Select; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +@Mapper | |
| 12 | +public interface SysPermissionCategoryMapper extends BaseMapper<SysPermissionCategory> { | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 计算给定 ID 集合中有多少行未删除(iIsDeleted=0)。 | |
| 16 | + * 用于 REQ-USR-002 批量校验 permissionCategoryIds 是否全部存在。 | |
| 17 | + */ | |
| 18 | + @Select({ | |
| 19 | + "<script>", | |
| 20 | + "SELECT COUNT(*) FROM sys_permission_category ", | |
| 21 | + "WHERE iIsDeleted = 0 AND iIncrement IN ", | |
| 22 | + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>", | |
| 23 | + "</script>" | |
| 24 | + }) | |
| 25 | + int countActiveByIds(@Param("ids") List<Integer> ids); | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 5 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 6 | +import org.apache.ibatis.annotations.Mapper; | |
| 7 | +import org.apache.ibatis.annotations.Param; | |
| 8 | +import org.apache.ibatis.annotations.Select; | |
| 9 | +import org.apache.ibatis.annotations.Update; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +@Mapper | |
| 14 | +public interface SysUserMapper extends BaseMapper<SysUser> { | |
| 15 | + | |
| 16 | + String LOGIN_COLUMNS = "iIncrement, sUsername, sUserCode, sPasswordHash, iEmployeeId, " + | |
| 17 | + "sUserType, sLanguage, iCanEditDocument, iIsDeleted, iFailedLoginCount, " + | |
| 18 | + "tLockUntil, tLastLoginDate"; | |
| 19 | + | |
| 20 | + @Select("SELECT " + LOGIN_COLUMNS + " FROM sys_user WHERE sUsername = #{username} LIMIT 1") | |
| 21 | + SysUser selectByUsername(String username); | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 原子累加失败登录次数;达到阈值 maxCount 时同步写 tLockUntil = NOW() + lockMinutes 分钟。 | |
| 25 | + * 单 SQL,DB 层保证并发安全。返回受影响行数(应为 1)。 | |
| 26 | + * MySQL 按 SET 子句从左到右求值,所以放在 +1 之后的引用看到的是新值。 | |
| 27 | + */ | |
| 28 | + @Update("UPDATE sys_user " + | |
| 29 | + "SET iFailedLoginCount = iFailedLoginCount + 1, " + | |
| 30 | + " tLockUntil = IF(iFailedLoginCount >= #{maxCount}, " + | |
| 31 | + " DATE_ADD(NOW(), INTERVAL #{lockMinutes} MINUTE), " + | |
| 32 | + " tLockUntil) " + | |
| 33 | + "WHERE iIncrement = #{userId}") | |
| 34 | + int incrementFailedLoginCountAtomic(@Param("userId") Integer userId, | |
| 35 | + @Param("maxCount") int maxCount, | |
| 36 | + @Param("lockMinutes") long lockMinutes); | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 成功登录写入:清零计数 + 清空锁定 + 更新登录时间。一次 UPDATE。 | |
| 40 | + */ | |
| 41 | + @Update("UPDATE sys_user " + | |
| 42 | + "SET iFailedLoginCount = 0, tLockUntil = NULL, tLastLoginDate = NOW() " + | |
| 43 | + "WHERE iIncrement = #{userId}") | |
| 44 | + int markLoginSuccess(@Param("userId") Integer userId); | |
| 45 | + | |
| 46 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUsername = #{username})") | |
| 47 | + boolean existsByUsername(@Param("username") String username); | |
| 48 | + | |
| 49 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode})") | |
| 50 | + boolean existsByUserCode(@Param("userCode") String userCode); | |
| 51 | + | |
| 52 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user " + | |
| 53 | + "WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})") | |
| 54 | + boolean existsByUserCodeExcludingId(@Param("userCode") String userCode, | |
| 55 | + @Param("excludedUserId") Integer excludedUserId); | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * REQ-USR-004 动态查询。SQL 在 SysUserMapper.xml 定义。 | |
| 59 | + * QueryParams 必须已通过 service 层白名单校验。 | |
| 60 | + */ | |
| 61 | + List<UserListItemVo> selectByQuery(@Param("p") UserQueryParams p); | |
| 62 | + | |
| 63 | + long countByQuery(@Param("p") UserQueryParams p); | |
| 64 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 5 | +import org.apache.ibatis.annotations.Delete; | |
| 6 | +import org.apache.ibatis.annotations.Mapper; | |
| 7 | +import org.apache.ibatis.annotations.Param; | |
| 8 | +import org.apache.ibatis.annotations.Select; | |
| 9 | + | |
| 10 | +import java.util.List; | |
| 11 | + | |
| 12 | +@Mapper | |
| 13 | +public interface SysUserPermissionCategoryMapper extends BaseMapper<SysUserPermissionCategory> { | |
| 14 | + | |
| 15 | + @Select("SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}") | |
| 16 | + List<Integer> selectPermissionCategoryIdsByUserId(@Param("userId") Integer userId); | |
| 17 | + | |
| 18 | + @Delete({ | |
| 19 | + "<script>", | |
| 20 | + "DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN ", | |
| 21 | + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>", | |
| 22 | + "</script>" | |
| 23 | + }) | |
| 24 | + int deleteByUserAndCategoryIds(@Param("userId") Integer userId, | |
| 25 | + @Param("ids") List<Integer> categoryIds); | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/UserQueryParams.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * SysUserMapper.selectByQuery / countByQuery 入参(service 层规范化白名单后填入)。 | |
| 5 | + * sqlSortField / sqlSortOrder / sqlQueryColumn 必须已通过白名单校验; | |
| 6 | + * mapper XML 直接用 ${} 拼接到 SQL。 | |
| 7 | + */ | |
| 8 | +public class UserQueryParams { | |
| 9 | + public String sqlSortField; | |
| 10 | + public String sqlSortOrder; | |
| 11 | + public String sqlQueryColumn; // null 表示无 queryField 条件 | |
| 12 | + public String matchMode; // contains / notContains / equals | |
| 13 | + public String queryValue; // null/"" 表示跳过该条件 | |
| 14 | + public String userType; // null 表示不过滤 | |
| 15 | + public Integer isDeleted; // null 不过滤;0 / 1 过滤 | |
| 16 | + public Integer offset; | |
| 17 | + public Integer limit; | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 4 | + | |
| 5 | +public interface LoginService { | |
| 6 | + /** | |
| 7 | + * 校验用户名 + 密码 + 公司编码并签发 access token。 | |
| 8 | + * REQ-USR-001。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException | |
| 11 | + * 40004 公司不存在 / 40101 凭据错误 / 40103 账号作废 / 42301 账号锁定 | |
| 12 | + */ | |
| 13 | + LoginVo login(String username, String password, String companyCode); | |
| 14 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 4 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 5 | + | |
| 6 | +public interface UserCreateService { | |
| 7 | + /** | |
| 8 | + * 新建用户 + 权限分类授权。 | |
| 9 | + * REQ-USR-002。 | |
| 10 | + * | |
| 11 | + * @param req 已通过 jakarta 校验的请求体 | |
| 12 | + * @param operatorUsername 当前登录用户(写入 sCreatedBy / sGrantedBy) | |
| 13 | + * @throws com.xly.erp.common.exception.BizException | |
| 14 | + * 40004 employee / permissionCategory 不存在 / 40901 用户名重复 / 40902 用户号重复 | |
| 15 | + */ | |
| 16 | + CreateUserVo create(CreateUserReq req, String operatorUsername); | |
| 17 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 4 | + | |
| 5 | +public interface UserDetailService { | |
| 6 | + /** | |
| 7 | + * REQ-USR-003 GET /api/v1/users/{userId} 详情。 | |
| 8 | + * 包含作废用户(不过滤 iIsDeleted)。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException 40401 用户不存在 | |
| 11 | + */ | |
| 12 | + UserDetailVo getById(Integer userId); | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.PageResult; | |
| 4 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 5 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 6 | + | |
| 7 | +public interface UserListService { | |
| 8 | + /** | |
| 9 | + * REQ-USR-004 GET /api/v1/users — 分页 + 多字段筛选 + 排序。 | |
| 10 | + * 白名单校验、越界矫正均由实现层完成。 | |
| 11 | + */ | |
| 12 | + PageResult<UserListItemVo> list(UserQueryReq req); | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 4 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 5 | + | |
| 6 | +public interface UserUpdateService { | |
| 7 | + /** | |
| 8 | + * REQ-USR-003 PUT /api/v1/users/{userId}:部分字段更新 + 权限分类增量差集。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException | |
| 11 | + * 40004 employee/permissionCategory 不存在 / 40302 自我停用 / 40401 用户不存在 / 40902 用户号冲突 | |
| 12 | + */ | |
| 13 | + UserDetailVo update(Integer userId, UpdateUserReq req, | |
| 14 | + Integer operatorUserId, String operatorUsername); | |
| 15 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.security.JwtUtil; | |
| 6 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 7 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysCompanyMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 12 | +import com.xly.erp.module.usr.service.LoginService; | |
| 13 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 14 | +import com.xly.erp.module.usr.vo.UserInfoVo; | |
| 15 | +import lombok.RequiredArgsConstructor; | |
| 16 | +import lombok.extern.slf4j.Slf4j; | |
| 17 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 18 | +import org.springframework.stereotype.Service; | |
| 19 | + | |
| 20 | +import java.time.LocalDateTime; | |
| 21 | +import java.time.format.DateTimeFormatter; | |
| 22 | +import java.util.HashMap; | |
| 23 | +import java.util.Map; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +@RequiredArgsConstructor | |
| 27 | +@Slf4j | |
| 28 | +public class LoginServiceImpl implements LoginService { | |
| 29 | + | |
| 30 | + static final int MAX_FAILED_LOGIN_COUNT = 5; | |
| 31 | + static final long LOCK_DURATION_MINUTES = 30L; | |
| 32 | + static final long TOKEN_TTL_SEC = 7200L; | |
| 33 | + | |
| 34 | + private final SysUserMapper userMapper; | |
| 35 | + private final SysCompanyMapper companyMapper; | |
| 36 | + private final SysEmployeeMapper employeeMapper; | |
| 37 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 38 | + private final JwtUtil jwtUtil; | |
| 39 | + | |
| 40 | + @Override | |
| 41 | + public LoginVo login(String username, String password, String companyCode) { | |
| 42 | + // 1. 公司校验(只读,不需事务) | |
| 43 | + SysCompany company = companyMapper.selectByCode(companyCode); | |
| 44 | + if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) { | |
| 45 | + log.warn("[login] companyCode={} 不存在或已删除", companyCode); | |
| 46 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 2. 用户查找 | |
| 50 | + SysUser user = userMapper.selectByUsername(username); | |
| 51 | + if (user == null) { | |
| 52 | + log.warn("[login] username={} 不存在(统一返 40101)", username); | |
| 53 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 3. 作废校验(不计入失败次数) | |
| 57 | + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { | |
| 58 | + log.warn("[login] username={} 已作废", username); | |
| 59 | + throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录"); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 4. 锁定校验(不计入失败次数;过期锁定视为已解锁) | |
| 63 | + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { | |
| 64 | + log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil()); | |
| 65 | + Map<String, Object> data = new HashMap<>(); | |
| 66 | + data.put("lockUntil", user.getTLockUntil() | |
| 67 | + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); | |
| 68 | + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试", data); | |
| 69 | + } | |
| 70 | + | |
| 71 | + // 5. 密码校验 | |
| 72 | + if (!passwordEncoder.matches(password, user.getSPasswordHash())) { | |
| 73 | + int rows = userMapper.incrementFailedLoginCountAtomic( | |
| 74 | + user.getIIncrement(), MAX_FAILED_LOGIN_COUNT, LOCK_DURATION_MINUTES); | |
| 75 | + log.warn("[login] username={} 密码错误,原子累加失败次数 rows={}", username, rows); | |
| 76 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 77 | + } | |
| 78 | + | |
| 79 | + // 6. 成功路径 | |
| 80 | + return loginSuccess(user, companyCode); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private LoginVo loginSuccess(SysUser user, String companyCode) { | |
| 84 | + userMapper.markLoginSuccess(user.getIIncrement()); | |
| 85 | + | |
| 86 | + String employeeName = null; | |
| 87 | + if (user.getIEmployeeId() != null) { | |
| 88 | + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); | |
| 89 | + if (emp != null) { | |
| 90 | + employeeName = emp.getSEmployeeName(); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + Map<String, Object> claims = new HashMap<>(); | |
| 95 | + claims.put("sub", user.getIIncrement()); | |
| 96 | + claims.put("username", user.getSUsername()); | |
| 97 | + claims.put("userType", user.getSUserType()); | |
| 98 | + claims.put("companyCode", companyCode); | |
| 99 | + claims.put("language", user.getSLanguage()); | |
| 100 | + | |
| 101 | + String token = jwtUtil.issue(claims, TOKEN_TTL_SEC); | |
| 102 | + | |
| 103 | + log.info("[login] username={} 登录成功", user.getSUsername()); | |
| 104 | + | |
| 105 | + return LoginVo.builder() | |
| 106 | + .accessToken(token) | |
| 107 | + .tokenType("Bearer") | |
| 108 | + .expiresInSec(TOKEN_TTL_SEC) | |
| 109 | + .userInfo(UserInfoVo.builder() | |
| 110 | + .userId(user.getIIncrement()) | |
| 111 | + .username(user.getSUsername()) | |
| 112 | + .userType(user.getSUserType()) | |
| 113 | + .language(user.getSLanguage()) | |
| 114 | + .companyCode(companyCode) | |
| 115 | + .employeeName(employeeName) | |
| 116 | + .build()) | |
| 117 | + .build(); | |
| 118 | + } | |
| 119 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 6 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 7 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 12 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 13 | +import com.xly.erp.module.usr.service.UserCreateService; | |
| 14 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 15 | +import lombok.RequiredArgsConstructor; | |
| 16 | +import lombok.extern.slf4j.Slf4j; | |
| 17 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 18 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 19 | +import org.springframework.stereotype.Service; | |
| 20 | +import org.springframework.transaction.annotation.Transactional; | |
| 21 | + | |
| 22 | +import java.util.List; | |
| 23 | + | |
| 24 | +@Service | |
| 25 | +@RequiredArgsConstructor | |
| 26 | +@Slf4j | |
| 27 | +public class UserCreateServiceImpl implements UserCreateService { | |
| 28 | + | |
| 29 | + static final String INITIAL_PASSWORD = "666666"; | |
| 30 | + | |
| 31 | + private final SysUserMapper userMapper; | |
| 32 | + private final SysEmployeeMapper employeeMapper; | |
| 33 | + private final SysPermissionCategoryMapper permissionCategoryMapper; | |
| 34 | + private final SysUserPermissionCategoryMapper userPermissionCategoryMapper; | |
| 35 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 36 | + | |
| 37 | + @Override | |
| 38 | + @Transactional | |
| 39 | + public CreateUserVo create(CreateUserReq req, String operatorUsername) { | |
| 40 | + // 1. 唯一性预检(返友好错误码;DB 唯一索引兜底并发场景) | |
| 41 | + if (userMapper.existsByUsername(req.getUsername())) { | |
| 42 | + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在"); | |
| 43 | + } | |
| 44 | + if (userMapper.existsByUserCode(req.getUserCode())) { | |
| 45 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 2. employee 外键校验 | |
| 49 | + if (req.getEmployeeId() != null) { | |
| 50 | + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId()); | |
| 51 | + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) { | |
| 52 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除"); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 3. permissionCategory 外键校验(批量) | |
| 57 | + List<Integer> pcIds = req.getPermissionCategoryIds(); | |
| 58 | + if (pcIds != null && !pcIds.isEmpty()) { | |
| 59 | + int active = permissionCategoryMapper.countActiveByIds(pcIds); | |
| 60 | + if (active != pcIds.size()) { | |
| 61 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, | |
| 62 | + "指定的权限分类含不存在或已删除项"); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + // 4. 写入 sys_user | |
| 67 | + SysUser user = new SysUser(); | |
| 68 | + user.setSUsername(req.getUsername()); | |
| 69 | + user.setSUserCode(req.getUserCode()); | |
| 70 | + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD)); | |
| 71 | + user.setIEmployeeId(req.getEmployeeId()); | |
| 72 | + user.setSUserType(req.getUserType()); | |
| 73 | + user.setSLanguage(req.getLanguage()); | |
| 74 | + user.setICanEditDocument(Boolean.TRUE.equals(req.getCanEditDocument()) ? 1 : 0); | |
| 75 | + user.setIIsDeleted(0); | |
| 76 | + user.setIFailedLoginCount(0); | |
| 77 | + user.setSCreatedBy(operatorUsername); | |
| 78 | + try { | |
| 79 | + userMapper.insert(user); | |
| 80 | + } catch (DataIntegrityViolationException e) { | |
| 81 | + String msg = e.getMessage() == null ? "" : e.getMessage(); | |
| 82 | + if (msg.contains("uk_sys_user_username")) { | |
| 83 | + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在"); | |
| 84 | + } | |
| 85 | + if (msg.contains("uk_sys_user_code")) { | |
| 86 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在"); | |
| 87 | + } | |
| 88 | + throw e; | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 5. 写入 sys_user_permission_category(如有) | |
| 92 | + if (pcIds != null && !pcIds.isEmpty()) { | |
| 93 | + for (Integer pcId : pcIds) { | |
| 94 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 95 | + link.setIUserId(user.getIIncrement()); | |
| 96 | + link.setIPermissionCategoryId(pcId); | |
| 97 | + link.setSGrantedBy(operatorUsername); | |
| 98 | + userPermissionCategoryMapper.insert(link); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + log.info("[user-create] username={} userCode={} byOperator={} permissionCount={}", | |
| 103 | + user.getSUsername(), user.getSUserCode(), operatorUsername, | |
| 104 | + pcIds == null ? 0 : pcIds.size()); | |
| 105 | + | |
| 106 | + return CreateUserVo.builder() | |
| 107 | + .userId(user.getIIncrement()) | |
| 108 | + .username(user.getSUsername()) | |
| 109 | + .userCode(user.getSUserCode()) | |
| 110 | + .build(); | |
| 111 | + } | |
| 112 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 8 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 11 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 12 | +import lombok.RequiredArgsConstructor; | |
| 13 | +import org.springframework.stereotype.Service; | |
| 14 | + | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +@Service | |
| 18 | +@RequiredArgsConstructor | |
| 19 | +public class UserDetailServiceImpl implements UserDetailService { | |
| 20 | + | |
| 21 | + private final SysUserMapper userMapper; | |
| 22 | + private final SysEmployeeMapper employeeMapper; | |
| 23 | + private final SysUserPermissionCategoryMapper upcMapper; | |
| 24 | + | |
| 25 | + @Override | |
| 26 | + public UserDetailVo getById(Integer userId) { | |
| 27 | + SysUser user = userMapper.selectById(userId); | |
| 28 | + if (user == null) { | |
| 29 | + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + String employeeName = null; | |
| 33 | + if (user.getIEmployeeId() != null) { | |
| 34 | + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); | |
| 35 | + if (emp != null) { | |
| 36 | + employeeName = emp.getSEmployeeName(); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + List<Integer> pcIds = upcMapper.selectPermissionCategoryIdsByUserId(userId); | |
| 41 | + | |
| 42 | + return UserDetailVo.builder() | |
| 43 | + .userId(user.getIIncrement()) | |
| 44 | + .username(user.getSUsername()) | |
| 45 | + .userCode(user.getSUserCode()) | |
| 46 | + .userType(user.getSUserType()) | |
| 47 | + .language(user.getSLanguage()) | |
| 48 | + .canEditDocument(Integer.valueOf(1).equals(user.getICanEditDocument())) | |
| 49 | + .isDeleted(Integer.valueOf(1).equals(user.getIIsDeleted())) | |
| 50 | + .employeeId(user.getIEmployeeId()) | |
| 51 | + .employeeName(employeeName) | |
| 52 | + .permissionCategoryIds(pcIds) | |
| 53 | + .updatedBy(user.getSUpdatedBy()) | |
| 54 | + .updatedDate(user.getTUpdatedDate()) | |
| 55 | + .build(); | |
| 56 | + } | |
| 57 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.response.PageResult; | |
| 6 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 8 | +import com.xly.erp.module.usr.mapper.UserQueryParams; | |
| 9 | +import com.xly.erp.module.usr.service.UserListService; | |
| 10 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 11 | +import lombok.RequiredArgsConstructor; | |
| 12 | +import org.springframework.stereotype.Service; | |
| 13 | + | |
| 14 | +import java.time.LocalDateTime; | |
| 15 | +import java.time.format.DateTimeFormatter; | |
| 16 | +import java.time.format.DateTimeParseException; | |
| 17 | +import java.util.List; | |
| 18 | +import java.util.Map; | |
| 19 | +import java.util.Set; | |
| 20 | + | |
| 21 | +@Service | |
| 22 | +@RequiredArgsConstructor | |
| 23 | +public class UserListServiceImpl implements UserListService { | |
| 24 | + | |
| 25 | + static final int DEFAULT_PAGE = 1; | |
| 26 | + static final int DEFAULT_SIZE = 20; | |
| 27 | + static final String DEFAULT_SORT_FIELD = "tCreateDate"; | |
| 28 | + static final String DEFAULT_SORT_ORDER = "desc"; | |
| 29 | + static final String DEFAULT_MATCH_MODE = "contains"; | |
| 30 | + | |
| 31 | + static final Set<String> SORT_FIELDS = Set.of( | |
| 32 | + "tCreateDate", "tLastLoginDate", "sUsername", "sUserCode"); | |
| 33 | + | |
| 34 | + static final Set<String> SORT_ORDERS = Set.of("asc", "desc"); | |
| 35 | + | |
| 36 | + static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals"); | |
| 37 | + | |
| 38 | + /** spec § 业务规则 3:非字符串列(int/datetime)一律按 equals 处理。 */ | |
| 39 | + static final Set<String> EQUALS_ONLY_FIELDS = Set.of("isDeleted", "lastLoginDate"); | |
| 40 | + | |
| 41 | + static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN"); | |
| 42 | + | |
| 43 | + static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries( | |
| 44 | + Map.entry("username", "u.sUsername"), | |
| 45 | + Map.entry("employeeName", "e.sEmployeeName"), | |
| 46 | + Map.entry("userCode", "u.sUserCode"), | |
| 47 | + Map.entry("departmentName", "d.sDepartmentName"), | |
| 48 | + Map.entry("userType", "u.sUserType"), | |
| 49 | + Map.entry("isDeleted", "u.iIsDeleted"), | |
| 50 | + Map.entry("lastLoginDate", "u.tLastLoginDate"), | |
| 51 | + Map.entry("createdBy", "u.sCreatedBy")); | |
| 52 | + | |
| 53 | + private final SysUserMapper userMapper; | |
| 54 | + | |
| 55 | + @Override | |
| 56 | + public PageResult<UserListItemVo> list(UserQueryReq req) { | |
| 57 | + // 应用默认值 | |
| 58 | + int page = req.getPage() == null ? DEFAULT_PAGE : req.getPage(); | |
| 59 | + int size = req.getSize() == null ? DEFAULT_SIZE : req.getSize(); | |
| 60 | + String sortField = req.getSortField() == null ? DEFAULT_SORT_FIELD : req.getSortField(); | |
| 61 | + String sortOrder = req.getSortOrder() == null ? DEFAULT_SORT_ORDER : req.getSortOrder(); | |
| 62 | + String matchMode = req.getMatchMode() == null ? DEFAULT_MATCH_MODE : req.getMatchMode(); | |
| 63 | + | |
| 64 | + // 白名单校验 | |
| 65 | + if (!SORT_FIELDS.contains(sortField)) { | |
| 66 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "sortField 不在白名单"); | |
| 67 | + } | |
| 68 | + if (!SORT_ORDERS.contains(sortOrder)) { | |
| 69 | + throw new BizException(ErrorCode.BAD_REQUEST, "sortOrder 必须为 asc 或 desc"); | |
| 70 | + } | |
| 71 | + if (!MATCH_MODES.contains(matchMode)) { | |
| 72 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "matchMode 不在白名单"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + String sqlQueryColumn = null; | |
| 76 | + String normalizedQueryValue = null; | |
| 77 | + if (req.getQueryField() != null && !req.getQueryField().isBlank()) { | |
| 78 | + sqlQueryColumn = QUERY_FIELD_TO_SQL.get(req.getQueryField()); | |
| 79 | + if (sqlQueryColumn == null) { | |
| 80 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单"); | |
| 81 | + } | |
| 82 | + // 只有 queryField + queryValue 都提供且非空才应用条件 | |
| 83 | + if (req.getQueryValue() != null && !req.getQueryValue().isBlank()) { | |
| 84 | + normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue()); | |
| 85 | + // spec § 业务规则 3:非字符串列一律强制 equals | |
| 86 | + if (EQUALS_ONLY_FIELDS.contains(req.getQueryField())) { | |
| 87 | + matchMode = "equals"; | |
| 88 | + } | |
| 89 | + } else { | |
| 90 | + sqlQueryColumn = null; // 缺 queryValue 跳过条件 | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + if (req.getUserType() != null && !USER_TYPES.contains(req.getUserType())) { | |
| 95 | + throw new BizException(ErrorCode.BAD_REQUEST, "userType 必须为 NORMAL 或 SUPER_ADMIN"); | |
| 96 | + } | |
| 97 | + | |
| 98 | + UserQueryParams p = new UserQueryParams(); | |
| 99 | + p.sqlSortField = sortField; | |
| 100 | + p.sqlSortOrder = sortOrder; | |
| 101 | + p.sqlQueryColumn = sqlQueryColumn; | |
| 102 | + p.matchMode = matchMode; | |
| 103 | + p.queryValue = normalizedQueryValue; | |
| 104 | + p.userType = req.getUserType(); | |
| 105 | + p.isDeleted = req.getIsDeleted() == null ? null : (req.getIsDeleted() ? 1 : 0); | |
| 106 | + p.limit = size; | |
| 107 | + p.offset = (page - 1) * size; | |
| 108 | + | |
| 109 | + long total = userMapper.countByQuery(p); | |
| 110 | + List<UserListItemVo> records = userMapper.selectByQuery(p); | |
| 111 | + | |
| 112 | + // 越界矫正:当前页空但 total>0 → 重算最后一页 | |
| 113 | + int actualPage = page; | |
| 114 | + if (records.isEmpty() && total > 0) { | |
| 115 | + int lastPage = (int) ((total + size - 1) / size); | |
| 116 | + p.offset = (lastPage - 1) * size; | |
| 117 | + records = userMapper.selectByQuery(p); | |
| 118 | + actualPage = lastPage; | |
| 119 | + } | |
| 120 | + | |
| 121 | + return PageResult.<UserListItemVo>builder() | |
| 122 | + .records(records) | |
| 123 | + .total(total) | |
| 124 | + .page(actualPage) | |
| 125 | + .size(size) | |
| 126 | + .build(); | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 对非字符串列做规范化(spec § 业务规则 3): | |
| 131 | + * - isDeleted: true/1 → "1";false/0 → "0";其他抛 40001 | |
| 132 | + * - lastLoginDate: 解析 ISO LOCAL_DATE_TIME 或 ISO LOCAL_DATE,统一格式为 'yyyy-MM-dd HH:mm:ss';非法抛 40001 | |
| 133 | + * - 其他列返回原值 | |
| 134 | + */ | |
| 135 | + private String normalizeQueryValue(String queryField, String raw) { | |
| 136 | + if ("isDeleted".equals(queryField)) { | |
| 137 | + if ("true".equalsIgnoreCase(raw) || "1".equals(raw)) return "1"; | |
| 138 | + if ("false".equalsIgnoreCase(raw) || "0".equals(raw)) return "0"; | |
| 139 | + throw new BizException(ErrorCode.BAD_REQUEST, | |
| 140 | + "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1"); | |
| 141 | + } | |
| 142 | + if ("lastLoginDate".equals(queryField)) { | |
| 143 | + try { | |
| 144 | + LocalDateTime dt; | |
| 145 | + if (raw.contains("T") || raw.contains(" ")) { | |
| 146 | + dt = LocalDateTime.parse(raw.replace(' ', 'T')); | |
| 147 | + } else { | |
| 148 | + dt = java.time.LocalDate.parse(raw).atStartOfDay(); | |
| 149 | + } | |
| 150 | + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); | |
| 151 | + } catch (DateTimeParseException e) { | |
| 152 | + throw new BizException(ErrorCode.BAD_REQUEST, | |
| 153 | + "queryField=lastLoginDate 时 queryValue 必须为 ISO 日期或日期时间"); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return raw; | |
| 157 | + } | |
| 158 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; | |
| 4 | +import com.xly.erp.common.exception.BizException; | |
| 5 | +import com.xly.erp.common.response.ErrorCode; | |
| 6 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 7 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 9 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; | |
| 12 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 13 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 14 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 15 | +import com.xly.erp.module.usr.service.UserUpdateService; | |
| 16 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 17 | +import lombok.RequiredArgsConstructor; | |
| 18 | +import lombok.extern.slf4j.Slf4j; | |
| 19 | +import org.springframework.stereotype.Service; | |
| 20 | +import org.springframework.transaction.annotation.Transactional; | |
| 21 | + | |
| 22 | +import java.time.LocalDateTime; | |
| 23 | +import java.util.HashSet; | |
| 24 | +import java.util.LinkedHashSet; | |
| 25 | +import java.util.List; | |
| 26 | +import java.util.Set; | |
| 27 | + | |
| 28 | +@Service | |
| 29 | +@RequiredArgsConstructor | |
| 30 | +@Slf4j | |
| 31 | +public class UserUpdateServiceImpl implements UserUpdateService { | |
| 32 | + | |
| 33 | + private final SysUserMapper userMapper; | |
| 34 | + private final SysEmployeeMapper employeeMapper; | |
| 35 | + private final SysPermissionCategoryMapper permissionCategoryMapper; | |
| 36 | + private final SysUserPermissionCategoryMapper upcMapper; | |
| 37 | + private final UserDetailService userDetailService; | |
| 38 | + | |
| 39 | + @Override | |
| 40 | + @Transactional | |
| 41 | + public UserDetailVo update(Integer userId, UpdateUserReq req, | |
| 42 | + Integer operatorUserId, String operatorUsername) { | |
| 43 | + // 1. 存在性 | |
| 44 | + SysUser existing = userMapper.selectById(userId); | |
| 45 | + if (existing == null) { | |
| 46 | + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 2. 自我停用守卫 | |
| 50 | + if (Boolean.TRUE.equals(req.getIsDeleted()) && userId.equals(operatorUserId)) { | |
| 51 | + throw new BizException(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE, | |
| 52 | + "不允许停用当前登录用户自己"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + // 3. userCode 唯一(排除自身) | |
| 56 | + if (req.getUserCode() != null | |
| 57 | + && !req.getUserCode().equals(existing.getSUserCode()) | |
| 58 | + && userMapper.existsByUserCodeExcludingId(req.getUserCode(), userId)) { | |
| 59 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已被占用"); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 4. employeeId 外键(仅正整数才查) | |
| 63 | + if (req.getEmployeeId() != null && req.getEmployeeId() > 0) { | |
| 64 | + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId()); | |
| 65 | + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) { | |
| 66 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除"); | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 5. permissionCategoryIds 外键(dedup 后校验) | |
| 71 | + Set<Integer> targetPcSet = null; | |
| 72 | + if (req.getPermissionCategoryIds() != null) { | |
| 73 | + targetPcSet = new LinkedHashSet<>(req.getPermissionCategoryIds()); | |
| 74 | + if (!targetPcSet.isEmpty()) { | |
| 75 | + int active = permissionCategoryMapper.countActiveByIds( | |
| 76 | + new java.util.ArrayList<>(targetPcSet)); | |
| 77 | + if (active != targetPcSet.size()) { | |
| 78 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, | |
| 79 | + "指定的权限分类含不存在或已删除项"); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + // 6. 写 sys_user 字段 | |
| 85 | + UpdateWrapper<SysUser> uw = new UpdateWrapper<>(); | |
| 86 | + uw.eq("iIncrement", userId); | |
| 87 | + | |
| 88 | + if (req.getUserCode() != null) uw.set("sUserCode", req.getUserCode()); | |
| 89 | + if (req.getUserType() != null) uw.set("sUserType", req.getUserType()); | |
| 90 | + if (req.getLanguage() != null) uw.set("sLanguage", req.getLanguage()); | |
| 91 | + if (req.getCanEditDocument() != null) | |
| 92 | + uw.set("iCanEditDocument", req.getCanEditDocument() ? 1 : 0); | |
| 93 | + if (req.getIsDeleted() != null) | |
| 94 | + uw.set("iIsDeleted", req.getIsDeleted() ? 1 : 0); | |
| 95 | + if (req.getEmployeeId() != null) { | |
| 96 | + if (req.getEmployeeId() == 0) { | |
| 97 | + uw.set("iEmployeeId", null); // 解除关联 | |
| 98 | + } else { | |
| 99 | + uw.set("iEmployeeId", req.getEmployeeId()); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + uw.set("sUpdatedBy", operatorUsername); | |
| 103 | + uw.set("tUpdatedDate", LocalDateTime.now()); | |
| 104 | + | |
| 105 | + userMapper.update(null, uw); | |
| 106 | + | |
| 107 | + // 7. 权限分类增量差集 | |
| 108 | + if (targetPcSet != null) { | |
| 109 | + List<Integer> currentList = upcMapper.selectPermissionCategoryIdsByUserId(userId); | |
| 110 | + Set<Integer> currentSet = new HashSet<>(currentList); | |
| 111 | + | |
| 112 | + Set<Integer> toRemove = new HashSet<>(currentSet); | |
| 113 | + toRemove.removeAll(targetPcSet); | |
| 114 | + | |
| 115 | + Set<Integer> toAdd = new LinkedHashSet<>(targetPcSet); | |
| 116 | + toAdd.removeAll(currentSet); | |
| 117 | + | |
| 118 | + if (!toRemove.isEmpty()) { | |
| 119 | + upcMapper.deleteByUserAndCategoryIds(userId, new java.util.ArrayList<>(toRemove)); | |
| 120 | + } | |
| 121 | + for (Integer pcId : toAdd) { | |
| 122 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 123 | + link.setIUserId(userId); | |
| 124 | + link.setIPermissionCategoryId(pcId); | |
| 125 | + link.setSGrantedBy(operatorUsername); | |
| 126 | + upcMapper.insert(link); | |
| 127 | + } | |
| 128 | + log.info("[user-update] userId={} pc.toRemove={} pc.toAdd={}", | |
| 129 | + userId, toRemove.size(), toAdd.size()); | |
| 130 | + } | |
| 131 | + | |
| 132 | + log.info("[user-update] userId={} byOperator={} 完成", userId, operatorUsername); | |
| 133 | + return userDetailService.getById(userId); | |
| 134 | + } | |
| 135 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java
0 → 100644
backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonInclude; | |
| 4 | +import lombok.Builder; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +import java.time.LocalDateTime; | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +@Data | |
| 11 | +@Builder | |
| 12 | +@JsonInclude(JsonInclude.Include.NON_NULL) | |
| 13 | +public class UserDetailVo { | |
| 14 | + private Integer userId; | |
| 15 | + private String username; | |
| 16 | + private String userCode; | |
| 17 | + private String userType; | |
| 18 | + private String language; | |
| 19 | + private Boolean canEditDocument; | |
| 20 | + private Boolean isDeleted; | |
| 21 | + private Integer employeeId; | |
| 22 | + private String employeeName; | |
| 23 | + private List<Integer> permissionCategoryIds; | |
| 24 | + private String updatedBy; | |
| 25 | + private LocalDateTime updatedDate; | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonInclude; | |
| 4 | +import lombok.Builder; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +@Data | |
| 8 | +@Builder | |
| 9 | +@JsonInclude(JsonInclude.Include.NON_NULL) | |
| 10 | +public class UserInfoVo { | |
| 11 | + private Integer userId; | |
| 12 | + private String username; | |
| 13 | + private String userType; | |
| 14 | + private String language; | |
| 15 | + private String employeeName; | |
| 16 | + private String companyCode; | |
| 17 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVo.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonInclude; | |
| 4 | +import lombok.Builder; | |
| 5 | +import lombok.Data; | |
| 6 | +import lombok.NoArgsConstructor; | |
| 7 | +import lombok.AllArgsConstructor; | |
| 8 | + | |
| 9 | +import java.time.LocalDateTime; | |
| 10 | + | |
| 11 | +@Data | |
| 12 | +@Builder | |
| 13 | +@NoArgsConstructor | |
| 14 | +@AllArgsConstructor | |
| 15 | +@JsonInclude(JsonInclude.Include.ALWAYS) | |
| 16 | +public class UserListItemVo { | |
| 17 | + private Integer userId; | |
| 18 | + private String username; | |
| 19 | + private String employeeName; | |
| 20 | + private String userCode; | |
| 21 | + private String departmentName; | |
| 22 | + private String userType; | |
| 23 | + private String language; | |
| 24 | + private Boolean isDeleted; | |
| 25 | + private LocalDateTime lastLoginDate; | |
| 26 | + private String createdBy; | |
| 27 | + private LocalDateTime createdDate; | |
| 28 | +} | ... | ... |
backend/src/main/resources/application-test.yml
0 → 100644
| 1 | +spring: | |
| 2 | + jackson: | |
| 3 | + deserialization: | |
| 4 | + fail-on-unknown-properties: true | |
| 5 | + datasource: | |
| 6 | + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true | |
| 7 | + username: ${DB_USER} | |
| 8 | + password: ${DB_PASSWORD} | |
| 9 | + flyway: | |
| 10 | + enabled: true | |
| 11 | + locations: filesystem:../sql/migrations | |
| 12 | + | |
| 13 | +jwt: | |
| 14 | + secret: ${JWT_SECRET:test-secret-please-replace-with-256bit-random-string-xxxxxxx} | |
| 15 | + ttl-sec: 7200 | |
| 16 | + | |
| 17 | +logging: | |
| 18 | + level: | |
| 19 | + root: WARN | |
| 20 | + com.xly.erp: DEBUG | |
| 21 | + com.zaxxer.hikari: WARN | ... | ... |
backend/src/main/resources/application.yml
0 → 100644
| 1 | +server: | |
| 2 | + port: 9090 | |
| 3 | + | |
| 4 | +spring: | |
| 5 | + application: | |
| 6 | + name: xly-erp-backend | |
| 7 | + jackson: | |
| 8 | + deserialization: | |
| 9 | + fail-on-unknown-properties: true | |
| 10 | + datasource: | |
| 11 | + driver-class-name: com.mysql.cj.jdbc.Driver | |
| 12 | + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true | |
| 13 | + username: ${DB_USER} | |
| 14 | + password: ${DB_PASSWORD} | |
| 15 | + flyway: | |
| 16 | + enabled: true | |
| 17 | + locations: filesystem:../sql/migrations | |
| 18 | + baseline-on-migrate: true | |
| 19 | + baseline-version: 0 | |
| 20 | + validate-on-migrate: true | |
| 21 | + | |
| 22 | +mybatis-plus: | |
| 23 | + mapper-locations: classpath*:/mapper/**/*.xml | |
| 24 | + configuration: | |
| 25 | + map-underscore-to-camel-case: false | |
| 26 | + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl | |
| 27 | + global-config: | |
| 28 | + db-config: | |
| 29 | + id-type: auto | |
| 30 | + | |
| 31 | +jwt: | |
| 32 | + secret: ${JWT_SECRET} | |
| 33 | + ttl-sec: 7200 | |
| 34 | + | |
| 35 | +logging: | |
| 36 | + level: | |
| 37 | + root: INFO | |
| 38 | + com.xly.erp: DEBUG | ... | ... |
backend/src/main/resources/logback-spring.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<configuration> | |
| 3 | + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> | |
| 4 | + <encoder> | |
| 5 | + <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern> | |
| 6 | + </encoder> | |
| 7 | + </appender> | |
| 8 | + | |
| 9 | + <root level="INFO"> | |
| 10 | + <appender-ref ref="CONSOLE"/> | |
| 11 | + </root> | |
| 12 | + | |
| 13 | + <logger name="com.xly.erp" level="DEBUG"/> | |
| 14 | +</configuration> | ... | ... |
backend/src/main/resources/mapper/usr/SysUserMapper.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 3 | +<mapper namespace="com.xly.erp.module.usr.mapper.SysUserMapper"> | |
| 4 | + | |
| 5 | + <sql id="baseFrom"> | |
| 6 | + FROM sys_user u | |
| 7 | + LEFT JOIN sys_employee e ON e.iIncrement = u.iEmployeeId | |
| 8 | + LEFT JOIN sys_department d ON d.iIncrement = e.iDepartmentId | |
| 9 | + </sql> | |
| 10 | + | |
| 11 | + <sql id="whereClause"> | |
| 12 | + <where> | |
| 13 | + <if test="p.sqlQueryColumn != null and p.queryValue != null and p.queryValue != ''"> | |
| 14 | + <choose> | |
| 15 | + <when test="'contains'.equals(p.matchMode)"> | |
| 16 | + AND ${p.sqlQueryColumn} LIKE CONCAT('%', #{p.queryValue}, '%') | |
| 17 | + </when> | |
| 18 | + <when test="'notContains'.equals(p.matchMode)"> | |
| 19 | + AND (${p.sqlQueryColumn} NOT LIKE CONCAT('%', #{p.queryValue}, '%') | |
| 20 | + OR ${p.sqlQueryColumn} IS NULL) | |
| 21 | + </when> | |
| 22 | + <otherwise> | |
| 23 | + AND ${p.sqlQueryColumn} = #{p.queryValue} | |
| 24 | + </otherwise> | |
| 25 | + </choose> | |
| 26 | + </if> | |
| 27 | + <if test="p.userType != null"> | |
| 28 | + AND u.sUserType = #{p.userType} | |
| 29 | + </if> | |
| 30 | + <if test="p.isDeleted != null"> | |
| 31 | + AND u.iIsDeleted = #{p.isDeleted} | |
| 32 | + </if> | |
| 33 | + </where> | |
| 34 | + </sql> | |
| 35 | + | |
| 36 | + <resultMap id="UserListItemVoMap" type="com.xly.erp.module.usr.vo.UserListItemVo"> | |
| 37 | + <id property="userId" column="userId"/> | |
| 38 | + <result property="username" column="username"/> | |
| 39 | + <result property="employeeName" column="employeeName"/> | |
| 40 | + <result property="userCode" column="userCode"/> | |
| 41 | + <result property="departmentName" column="departmentName"/> | |
| 42 | + <result property="userType" column="userType"/> | |
| 43 | + <result property="language" column="language"/> | |
| 44 | + <result property="isDeleted" column="isDeleted" javaType="java.lang.Boolean"/> | |
| 45 | + <result property="lastLoginDate" column="lastLoginDate"/> | |
| 46 | + <result property="createdBy" column="createdBy"/> | |
| 47 | + <result property="createdDate" column="createdDate"/> | |
| 48 | + </resultMap> | |
| 49 | + | |
| 50 | + <select id="selectByQuery" resultMap="UserListItemVoMap"> | |
| 51 | + SELECT u.iIncrement AS userId, | |
| 52 | + u.sUsername AS username, | |
| 53 | + e.sEmployeeName AS employeeName, | |
| 54 | + u.sUserCode AS userCode, | |
| 55 | + d.sDepartmentName AS departmentName, | |
| 56 | + u.sUserType AS userType, | |
| 57 | + u.sLanguage AS language, | |
| 58 | + u.iIsDeleted AS isDeleted, | |
| 59 | + u.tLastLoginDate AS lastLoginDate, | |
| 60 | + u.sCreatedBy AS createdBy, | |
| 61 | + u.tCreateDate AS createdDate | |
| 62 | + <include refid="baseFrom"/> | |
| 63 | + <include refid="whereClause"/> | |
| 64 | + ORDER BY u.${p.sqlSortField} ${p.sqlSortOrder} | |
| 65 | + LIMIT #{p.offset}, #{p.limit} | |
| 66 | + </select> | |
| 67 | + | |
| 68 | + <select id="countByQuery" resultType="long"> | |
| 69 | + SELECT COUNT(*) | |
| 70 | + <include refid="baseFrom"/> | |
| 71 | + <include refid="whereClause"/> | |
| 72 | + </select> | |
| 73 | + | |
| 74 | +</mapper> | ... | ... |
backend/src/test/java/com/xly/erp/ApplicationContextTest.java
0 → 100644
| 1 | +package com.xly.erp; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 5 | +import org.springframework.context.ApplicationContext; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | + | |
| 8 | +import static org.junit.jupiter.api.Assertions.assertNotNull; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * REQ-USR-001 — Boot smoke test: | |
| 12 | + * verifies Spring Boot context starts and Flyway has applied V1 against the | |
| 13 | + * MySQL schema configured via .env.local (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_SCHEMA). | |
| 14 | + */ | |
| 15 | +@SpringBootTest | |
| 16 | +@org.springframework.test.context.ActiveProfiles("test") | |
| 17 | +class ApplicationContextTest { | |
| 18 | + | |
| 19 | + @Autowired | |
| 20 | + private ApplicationContext ctx; | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + void contextLoads() { | |
| 24 | + assertNotNull(ctx, "Spring ApplicationContext should be initialised"); | |
| 25 | + } | |
| 26 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ErrorCode; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 6 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.test.context.ActiveProfiles; | |
| 9 | +import org.springframework.test.web.servlet.MockMvc; | |
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 11 | +import org.springframework.web.bind.annotation.RestController; | |
| 12 | + | |
| 13 | +import static org.hamcrest.Matchers.containsString; | |
| 14 | +import static org.hamcrest.Matchers.not; | |
| 15 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 16 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 17 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 18 | + | |
| 19 | +@SpringBootTest | |
| 20 | +@AutoConfigureMockMvc | |
| 21 | +@ActiveProfiles("test") | |
| 22 | +@org.springframework.context.annotation.Import(GlobalExceptionHandlerTest.ThrowingTestController.class) | |
| 23 | +class GlobalExceptionHandlerTest { | |
| 24 | + | |
| 25 | + @Autowired | |
| 26 | + private MockMvc mockMvc; | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void bizException_locked_returns423_withCode42301() throws Exception { | |
| 30 | + mockMvc.perform(get("/_test/throw/locked")) | |
| 31 | + .andExpect(status().isLocked()) | |
| 32 | + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED)) | |
| 33 | + .andExpect(jsonPath("$.data").doesNotExist()); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + void bizException_badCredentials_returns401_withCode40101() throws Exception { | |
| 38 | + mockMvc.perform(get("/_test/throw/bad-credentials")) | |
| 39 | + .andExpect(status().isUnauthorized()) | |
| 40 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + void unexpectedException_returns500_doesNotLeakStackTrace() throws Exception { | |
| 45 | + mockMvc.perform(get("/_test/throw/runtime")) | |
| 46 | + .andExpect(status().isInternalServerError()) | |
| 47 | + .andExpect(jsonPath("$.code").value(ErrorCode.INTERNAL_ERROR)) | |
| 48 | + .andExpect(jsonPath("$.message").value(not(containsString("java.")))) | |
| 49 | + .andExpect(jsonPath("$.message").value(not(containsString("Exception")))); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @RestController | |
| 53 | + static class ThrowingTestController { | |
| 54 | + @GetMapping("/_test/throw/locked") | |
| 55 | + public void locked() { | |
| 56 | + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @GetMapping("/_test/throw/bad-credentials") | |
| 60 | + public void badCredentials() { | |
| 61 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 62 | + } | |
| 63 | + | |
| 64 | + @GetMapping("/_test/throw/runtime") | |
| 65 | + public void runtime() { | |
| 66 | + throw new RuntimeException("internal boom java.lang.NullPointerException"); | |
| 67 | + } | |
| 68 | + } | |
| 69 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | + | |
| 5 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 6 | + | |
| 7 | +class ErrorCodeTest { | |
| 8 | + | |
| 9 | + @Test | |
| 10 | + void httpMappings_coverNewCodes() { | |
| 11 | + assertEquals(403, ErrorCode.toHttpStatus(ErrorCode.FORBIDDEN)); | |
| 12 | + assertEquals(409, ErrorCode.toHttpStatus(ErrorCode.CONFLICT_USERNAME)); | |
| 13 | + assertEquals(409, ErrorCode.toHttpStatus(ErrorCode.CONFLICT_USERCODE)); | |
| 14 | + assertEquals(40301, ErrorCode.FORBIDDEN); | |
| 15 | + assertEquals(40901, ErrorCode.CONFLICT_USERNAME); | |
| 16 | + assertEquals(40902, ErrorCode.CONFLICT_USERCODE); | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + void httpMappings_coverNewCodes_v004() { | |
| 21 | + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.INVALID_ENUM_PARAM)); | |
| 22 | + assertEquals(40003, ErrorCode.INVALID_ENUM_PARAM); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + void httpMappings_coverNewCodes_v003() { | |
| 27 | + assertEquals(403, ErrorCode.toHttpStatus(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE)); | |
| 28 | + assertEquals(404, ErrorCode.toHttpStatus(ErrorCode.USER_NOT_FOUND)); | |
| 29 | + assertEquals(40302, ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE); | |
| 30 | + assertEquals(40401, ErrorCode.USER_NOT_FOUND); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + void httpMappings_existingCodes_unchanged() { | |
| 35 | + assertEquals(200, ErrorCode.toHttpStatus(ErrorCode.OK)); | |
| 36 | + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.BAD_REQUEST)); | |
| 37 | + assertEquals(400, ErrorCode.toHttpStatus(ErrorCode.COMPANY_NOT_FOUND)); | |
| 38 | + assertEquals(401, ErrorCode.toHttpStatus(ErrorCode.BAD_CREDENTIALS)); | |
| 39 | + assertEquals(401, ErrorCode.toHttpStatus(ErrorCode.ACCOUNT_DELETED)); | |
| 40 | + assertEquals(423, ErrorCode.toHttpStatus(ErrorCode.ACCOUNT_LOCKED)); | |
| 41 | + assertEquals(500, ErrorCode.toHttpStatus(ErrorCode.INTERNAL_ERROR)); | |
| 42 | + } | |
| 43 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ErrorCode; | |
| 4 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 5 | +import org.junit.jupiter.api.BeforeEach; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 8 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 9 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 10 | +import org.springframework.test.context.ActiveProfiles; | |
| 11 | +import org.springframework.test.web.servlet.MockMvc; | |
| 12 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 13 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 14 | +import org.springframework.web.bind.annotation.RestController; | |
| 15 | + | |
| 16 | +import java.util.HashMap; | |
| 17 | +import java.util.Map; | |
| 18 | + | |
| 19 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 21 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 23 | + | |
| 24 | +@SpringBootTest | |
| 25 | +@AutoConfigureMockMvc | |
| 26 | +@ActiveProfiles("test") | |
| 27 | +@org.springframework.context.annotation.Import(JwtHandlerInterceptorTest.GuardedTestController.class) | |
| 28 | +class JwtHandlerInterceptorTest { | |
| 29 | + | |
| 30 | + @Autowired private MockMvc mvc; | |
| 31 | + @Autowired private JwtUtil jwtUtil; | |
| 32 | + @Autowired private LoginTestSeeder seeder; | |
| 33 | + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc; | |
| 34 | + | |
| 35 | + private LoginTestSeeder.Fixture fx; | |
| 36 | + | |
| 37 | + @BeforeEach | |
| 38 | + void setUp() { | |
| 39 | + fx = seeder.reset(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + private String issueToken(String username, String userType, String companyCode) { | |
| 43 | + Map<String, Object> claims = new HashMap<>(); | |
| 44 | + claims.put("sub", 1); | |
| 45 | + claims.put("username", username); | |
| 46 | + claims.put("userType", userType); | |
| 47 | + claims.put("companyCode", companyCode); | |
| 48 | + claims.put("language", "zh-CN"); | |
| 49 | + return jwtUtil.issue(claims, 7200); | |
| 50 | + } | |
| 51 | + | |
| 52 | + // ===== 鉴权基础 ===== | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + void noAuthHeader_returns401_40101() throws Exception { | |
| 56 | + mvc.perform(get("/api/v1/_test/any-auth")) | |
| 57 | + .andExpect(status().isUnauthorized()) | |
| 58 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void invalidToken_returns401_40101() throws Exception { | |
| 63 | + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer bogus.jwt.token")) | |
| 64 | + .andExpect(status().isUnauthorized()) | |
| 65 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + void tokenForUnknownUser_returns401_40101() throws Exception { | |
| 70 | + String token = issueToken("nobody", "NORMAL", "HQ"); | |
| 71 | + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) | |
| 72 | + .andExpect(status().isUnauthorized()) | |
| 73 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void tokenForDeletedUser_returns401_40101() throws Exception { | |
| 78 | + String token = issueToken(LoginTestSeeder.USER_DELETED, "NORMAL", "HQ"); | |
| 79 | + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) | |
| 80 | + .andExpect(status().isUnauthorized()) | |
| 81 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + void tokenForLockedUser_returns401_40101() throws Exception { | |
| 86 | + jdbc.update("UPDATE sys_user SET tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", | |
| 87 | + LoginTestSeeder.USER_OK); | |
| 88 | + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); | |
| 89 | + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) | |
| 90 | + .andExpect(status().isUnauthorized()) | |
| 91 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + void validToken_normalUser_canAccessAnyAuthEndpoint() throws Exception { | |
| 96 | + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); | |
| 97 | + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) | |
| 98 | + .andExpect(status().isOk()) | |
| 99 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 100 | + .andExpect(jsonPath("$.data").value("ok-" + LoginTestSeeder.USER_OK)); | |
| 101 | + } | |
| 102 | + | |
| 103 | + // ===== @RequireSuperAdmin ===== | |
| 104 | + | |
| 105 | + @Test | |
| 106 | + void validToken_normalUser_cannotAccessAdminOnly_returns403_40301() throws Exception { | |
| 107 | + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); | |
| 108 | + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token)) | |
| 109 | + .andExpect(status().isForbidden()) | |
| 110 | + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test | |
| 114 | + void validToken_superAdmin_canAccessAdminOnly() throws Exception { | |
| 115 | + // 把 alice 升级为 SUPER_ADMIN | |
| 116 | + jdbc.update("UPDATE sys_user SET sUserType='SUPER_ADMIN' WHERE sUsername=?", LoginTestSeeder.USER_OK); | |
| 117 | + String token = issueToken(LoginTestSeeder.USER_OK, "SUPER_ADMIN", "HQ"); | |
| 118 | + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token)) | |
| 119 | + .andExpect(status().isOk()); | |
| 120 | + } | |
| 121 | + | |
| 122 | + // ===== 放行 /api/v1/auth/login ===== | |
| 123 | + | |
| 124 | + @Test | |
| 125 | + void loginEndpointPath_skipsInterceptor() throws Exception { | |
| 126 | + // 不带 auth 头调登录接口;应进入 controller 并返参数校验错(不是 40101) | |
| 127 | + mvc.perform(post("/api/v1/auth/login").contentType("application/json").content("{}")) | |
| 128 | + .andExpect(status().isBadRequest()) | |
| 129 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 130 | + } | |
| 131 | + | |
| 132 | + @RestController | |
| 133 | + @RequestMapping("/api/v1/_test") | |
| 134 | + static class GuardedTestController { | |
| 135 | + | |
| 136 | + @GetMapping("/any-auth") | |
| 137 | + public com.xly.erp.common.response.Result<String> anyAuth() { | |
| 138 | + LoginContext.LoginUser u = LoginContext.current(); | |
| 139 | + return com.xly.erp.common.response.Result.ok("ok-" + (u == null ? "null" : u.username())); | |
| 140 | + } | |
| 141 | + | |
| 142 | + @GetMapping("/admin-only") | |
| 143 | + @RequireSuperAdmin | |
| 144 | + public com.xly.erp.common.response.Result<String> adminOnly() { | |
| 145 | + return com.xly.erp.common.response.Result.ok("admin"); | |
| 146 | + } | |
| 147 | + } | |
| 148 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.test.context.ActiveProfiles; | |
| 9 | + | |
| 10 | +import java.util.HashMap; | |
| 11 | +import java.util.Map; | |
| 12 | + | |
| 13 | +import static org.junit.jupiter.api.Assertions.*; | |
| 14 | + | |
| 15 | +@SpringBootTest | |
| 16 | +@ActiveProfiles("test") | |
| 17 | +class JwtUtilTest { | |
| 18 | + | |
| 19 | + @Autowired | |
| 20 | + private JwtUtil jwtUtil; | |
| 21 | + | |
| 22 | + private Map<String, Object> sampleClaims() { | |
| 23 | + Map<String, Object> claims = new HashMap<>(); | |
| 24 | + claims.put("sub", "42"); | |
| 25 | + claims.put("username", "alice"); | |
| 26 | + claims.put("userType", "NORMAL"); | |
| 27 | + claims.put("companyCode", "HQ"); | |
| 28 | + claims.put("language", "zh-CN"); | |
| 29 | + return claims; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + void issuedToken_canBeParsedBackToClaims() { | |
| 34 | + String token = jwtUtil.issue(sampleClaims(), 7200); | |
| 35 | + assertNotNull(token); | |
| 36 | + assertFalse(token.isEmpty()); | |
| 37 | + | |
| 38 | + Map<String, Object> parsed = jwtUtil.parse(token); | |
| 39 | + assertEquals("42", parsed.get("sub")); | |
| 40 | + assertEquals("alice", parsed.get("username")); | |
| 41 | + assertEquals("NORMAL", parsed.get("userType")); | |
| 42 | + assertEquals("HQ", parsed.get("companyCode")); | |
| 43 | + assertEquals("zh-CN", parsed.get("language")); | |
| 44 | + assertNotNull(parsed.get("jti")); | |
| 45 | + assertNotNull(parsed.get("iat")); | |
| 46 | + assertNotNull(parsed.get("exp")); | |
| 47 | + | |
| 48 | + long iat = ((Number) parsed.get("iat")).longValue(); | |
| 49 | + long exp = ((Number) parsed.get("exp")).longValue(); | |
| 50 | + assertEquals(7200L, exp - iat, "exp - iat 必须严格等于 ttlSec(spec § 验收 § 2)"); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + void tamperedToken_throwsBizException() { | |
| 55 | + String token = jwtUtil.issue(sampleClaims(), 7200); | |
| 56 | + String tampered = token.substring(0, token.length() - 4) + "XXXX"; | |
| 57 | + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(tampered)); | |
| 58 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void expiredToken_throwsBizException() { | |
| 63 | + String token = jwtUtil.issue(sampleClaims(), 0L); | |
| 64 | + try { Thread.sleep(1100); } catch (InterruptedException ignored) {} | |
| 65 | + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(token)); | |
| 66 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 67 | + } | |
| 68 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/security/LoginContextTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.AfterEach; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | + | |
| 6 | +import java.util.concurrent.CountDownLatch; | |
| 7 | +import java.util.concurrent.atomic.AtomicReference; | |
| 8 | + | |
| 9 | +import static org.junit.jupiter.api.Assertions.*; | |
| 10 | + | |
| 11 | +class LoginContextTest { | |
| 12 | + | |
| 13 | + @AfterEach | |
| 14 | + void tearDown() { | |
| 15 | + LoginContext.clear(); | |
| 16 | + } | |
| 17 | + | |
| 18 | + @Test | |
| 19 | + void setAndCurrent_returnsSameUser() { | |
| 20 | + LoginContext.LoginUser u = new LoginContext.LoginUser(42, "alice", "NORMAL", "HQ"); | |
| 21 | + LoginContext.set(u); | |
| 22 | + assertSame(u, LoginContext.current()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + void clear_returnsNullForCurrent() { | |
| 27 | + LoginContext.set(new LoginContext.LoginUser(1, "x", "NORMAL", "HQ")); | |
| 28 | + LoginContext.clear(); | |
| 29 | + assertNull(LoginContext.current()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + void setAndCurrent_isolatedPerThread() throws InterruptedException { | |
| 34 | + LoginContext.set(new LoginContext.LoginUser(1, "main", "NORMAL", "HQ")); | |
| 35 | + | |
| 36 | + AtomicReference<LoginContext.LoginUser> seen = new AtomicReference<>(); | |
| 37 | + CountDownLatch latch = new CountDownLatch(1); | |
| 38 | + Thread t = new Thread(() -> { | |
| 39 | + seen.set(LoginContext.current()); | |
| 40 | + LoginContext.set(new LoginContext.LoginUser(2, "child", "SUPER_ADMIN", "HQ")); | |
| 41 | + latch.countDown(); | |
| 42 | + }); | |
| 43 | + t.start(); | |
| 44 | + latch.await(); | |
| 45 | + t.join(); | |
| 46 | + | |
| 47 | + assertNull(seen.get(), "子线程不应继承父线程 ThreadLocal"); | |
| 48 | + assertEquals("main", LoginContext.current().username(), | |
| 49 | + "父线程上下文不应被子线程改动影响"); | |
| 50 | + } | |
| 51 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.dto.LoginReq; | |
| 6 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 12 | +import org.springframework.http.MediaType; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.test.context.ActiveProfiles; | |
| 15 | +import org.springframework.test.web.servlet.MockMvc; | |
| 16 | + | |
| 17 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 18 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | |
| 19 | + | |
| 20 | +@SpringBootTest | |
| 21 | +@AutoConfigureMockMvc | |
| 22 | +@ActiveProfiles("test") | |
| 23 | +class AuthControllerTest { | |
| 24 | + | |
| 25 | + @Autowired private MockMvc mvc; | |
| 26 | + @Autowired private ObjectMapper json; | |
| 27 | + @Autowired private LoginTestSeeder seeder; | |
| 28 | + @Autowired private JdbcTemplate jdbc; | |
| 29 | + | |
| 30 | + @BeforeEach | |
| 31 | + void setUp() { | |
| 32 | + seeder.reset(); | |
| 33 | + } | |
| 34 | + | |
| 35 | + private String body(String username, String password, String companyCode) throws Exception { | |
| 36 | + LoginReq r = new LoginReq(); | |
| 37 | + r.setUsername(username); | |
| 38 | + r.setPassword(password); | |
| 39 | + r.setCompanyCode(companyCode); | |
| 40 | + return json.writeValueAsString(r); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + void post_login_success_returns200_andLoginVo() throws Exception { | |
| 45 | + mvc.perform(post("/api/v1/auth/login") | |
| 46 | + .contentType(MediaType.APPLICATION_JSON) | |
| 47 | + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 48 | + LoginTestSeeder.COMPANY_OK))) | |
| 49 | + .andExpect(status().isOk()) | |
| 50 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 51 | + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) | |
| 52 | + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) | |
| 53 | + .andExpect(jsonPath("$.data.expiresInSec").value(7200)) | |
| 54 | + .andExpect(jsonPath("$.data.userInfo.username").value(LoginTestSeeder.USER_OK)) | |
| 55 | + .andExpect(jsonPath("$.data.userInfo.companyCode").value(LoginTestSeeder.COMPANY_OK)); | |
| 56 | + } | |
| 57 | + | |
| 58 | + @Test | |
| 59 | + void post_login_badCredentials_returns401_code40101() throws Exception { | |
| 60 | + mvc.perform(post("/api/v1/auth/login") | |
| 61 | + .contentType(MediaType.APPLICATION_JSON) | |
| 62 | + .content(body(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK))) | |
| 63 | + .andExpect(status().isUnauthorized()) | |
| 64 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void post_login_unknownUser_returns401_code40101() throws Exception { | |
| 69 | + mvc.perform(post("/api/v1/auth/login") | |
| 70 | + .contentType(MediaType.APPLICATION_JSON) | |
| 71 | + .content(body("nobody", "any", LoginTestSeeder.COMPANY_OK))) | |
| 72 | + .andExpect(status().isUnauthorized()) | |
| 73 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void post_login_lockedAccount_returns423_code42301_withLockUntil() throws Exception { | |
| 78 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", | |
| 79 | + LoginTestSeeder.USER_OK); | |
| 80 | + mvc.perform(post("/api/v1/auth/login") | |
| 81 | + .contentType(MediaType.APPLICATION_JSON) | |
| 82 | + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 83 | + LoginTestSeeder.COMPANY_OK))) | |
| 84 | + .andExpect(status().isLocked()) | |
| 85 | + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED)) | |
| 86 | + .andExpect(jsonPath("$.data.lockUntil").isNotEmpty()); | |
| 87 | + } | |
| 88 | + | |
| 89 | + @Test | |
| 90 | + void post_login_deletedAccount_returns401_code40103() throws Exception { | |
| 91 | + mvc.perform(post("/api/v1/auth/login") | |
| 92 | + .contentType(MediaType.APPLICATION_JSON) | |
| 93 | + .content(body(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 94 | + LoginTestSeeder.COMPANY_OK))) | |
| 95 | + .andExpect(status().isUnauthorized()) | |
| 96 | + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_DELETED)); | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Test | |
| 100 | + void post_login_unknownCompany_returns400_code40004() throws Exception { | |
| 101 | + mvc.perform(post("/api/v1/auth/login") | |
| 102 | + .contentType(MediaType.APPLICATION_JSON) | |
| 103 | + .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE"))) | |
| 104 | + .andExpect(status().isBadRequest()) | |
| 105 | + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + void post_login_blankUsername_returns400_code40001() throws Exception { | |
| 110 | + mvc.perform(post("/api/v1/auth/login") | |
| 111 | + .contentType(MediaType.APPLICATION_JSON) | |
| 112 | + .content(body("", LoginTestSeeder.DEFAULT_PASSWORD, LoginTestSeeder.COMPANY_OK))) | |
| 113 | + .andExpect(status().isBadRequest()) | |
| 114 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 115 | + } | |
| 116 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ErrorCode; | |
| 4 | +import com.xly.erp.common.security.JwtUtil; | |
| 5 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 10 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 11 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 12 | +import org.springframework.test.context.ActiveProfiles; | |
| 13 | +import org.springframework.test.web.servlet.MockMvc; | |
| 14 | + | |
| 15 | +import java.util.HashMap; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 19 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | |
| 20 | + | |
| 21 | +@SpringBootTest | |
| 22 | +@AutoConfigureMockMvc | |
| 23 | +@ActiveProfiles("test") | |
| 24 | +class UserControllerListTest { | |
| 25 | + | |
| 26 | + @Autowired private MockMvc mvc; | |
| 27 | + @Autowired private LoginTestSeeder seeder; | |
| 28 | + @Autowired private JwtUtil jwtUtil; | |
| 29 | + @Autowired private JdbcTemplate jdbc; | |
| 30 | + | |
| 31 | + private LoginTestSeeder.Fixture fx; | |
| 32 | + private String adminToken; | |
| 33 | + private String normalToken; | |
| 34 | + | |
| 35 | + @BeforeEach | |
| 36 | + void setUp() { | |
| 37 | + fx = seeder.reset(); | |
| 38 | + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId()); | |
| 39 | + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + private String issue(String username, String userType, Integer userId) { | |
| 43 | + Map<String, Object> c = new HashMap<>(); | |
| 44 | + c.put("sub", userId); | |
| 45 | + c.put("username", username); | |
| 46 | + c.put("userType", userType); | |
| 47 | + c.put("companyCode", LoginTestSeeder.COMPANY_OK); | |
| 48 | + c.put("language", "zh-CN"); | |
| 49 | + return jwtUtil.issue(c, 7200); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + void list_default_returnsAllUsers() throws Exception { | |
| 54 | + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) | |
| 55 | + .andExpect(status().isOk()) | |
| 56 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 57 | + .andExpect(jsonPath("$.data.total").value(3)) | |
| 58 | + .andExpect(jsonPath("$.data.records.length()").value(3)) | |
| 59 | + .andExpect(jsonPath("$.data.page").value(1)) | |
| 60 | + .andExpect(jsonPath("$.data.size").value(20)); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void list_sizeOver100_returns400_40001() throws Exception { | |
| 65 | + mvc.perform(get("/api/v1/users") | |
| 66 | + .param("size", "200") | |
| 67 | + .header("Authorization", "Bearer " + adminToken)) | |
| 68 | + .andExpect(status().isBadRequest()) | |
| 69 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + void list_pageZero_returns400_40001() throws Exception { | |
| 74 | + mvc.perform(get("/api/v1/users") | |
| 75 | + .param("page", "0") | |
| 76 | + .header("Authorization", "Bearer " + adminToken)) | |
| 77 | + .andExpect(status().isBadRequest()) | |
| 78 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + void list_sortByUsernameAsc() throws Exception { | |
| 83 | + mvc.perform(get("/api/v1/users") | |
| 84 | + .param("sortField", "sUsername") | |
| 85 | + .param("sortOrder", "asc") | |
| 86 | + .header("Authorization", "Bearer " + adminToken)) | |
| 87 | + .andExpect(status().isOk()) | |
| 88 | + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_ADMIN)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + void list_sortFieldInvalid_returns400_40003() throws Exception { | |
| 93 | + mvc.perform(get("/api/v1/users") | |
| 94 | + .param("sortField", "badField") | |
| 95 | + .header("Authorization", "Bearer " + adminToken)) | |
| 96 | + .andExpect(status().isBadRequest()) | |
| 97 | + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + void list_sortOrderInvalid_returns400_40001() throws Exception { | |
| 102 | + mvc.perform(get("/api/v1/users") | |
| 103 | + .param("sortOrder", "foo") | |
| 104 | + .header("Authorization", "Bearer " + adminToken)) | |
| 105 | + .andExpect(status().isBadRequest()) | |
| 106 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Test | |
| 110 | + void list_queryByUsernameContains() throws Exception { | |
| 111 | + mvc.perform(get("/api/v1/users") | |
| 112 | + .param("queryField", "username") | |
| 113 | + .param("matchMode", "contains") | |
| 114 | + .param("queryValue", "ali") | |
| 115 | + .header("Authorization", "Bearer " + adminToken)) | |
| 116 | + .andExpect(status().isOk()) | |
| 117 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 118 | + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK)); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + void list_queryByDepartmentName_multiJoin() throws Exception { | |
| 123 | + mvc.perform(get("/api/v1/users") | |
| 124 | + .param("queryField", "departmentName") | |
| 125 | + .param("matchMode", "equals") | |
| 126 | + .param("queryValue", "技术部") | |
| 127 | + .header("Authorization", "Bearer " + adminToken)) | |
| 128 | + .andExpect(status().isOk()) | |
| 129 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 130 | + .andExpect(jsonPath("$.data.records[0].departmentName").value("技术部")); | |
| 131 | + } | |
| 132 | + | |
| 133 | + @Test | |
| 134 | + void list_queryByIsDeletedTrue() throws Exception { | |
| 135 | + mvc.perform(get("/api/v1/users") | |
| 136 | + .param("queryField", "isDeleted") | |
| 137 | + .param("matchMode", "equals") | |
| 138 | + .param("queryValue", "true") | |
| 139 | + .header("Authorization", "Bearer " + adminToken)) | |
| 140 | + .andExpect(status().isOk()) | |
| 141 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 142 | + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_DELETED)); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void list_queryFieldInvalid_returns400_40003() throws Exception { | |
| 147 | + mvc.perform(get("/api/v1/users") | |
| 148 | + .param("queryField", "badField") | |
| 149 | + .param("queryValue", "x") | |
| 150 | + .header("Authorization", "Bearer " + adminToken)) | |
| 151 | + .andExpect(status().isBadRequest()) | |
| 152 | + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); | |
| 153 | + } | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + void list_matchModeInvalid_returns400_40003() throws Exception { | |
| 157 | + mvc.perform(get("/api/v1/users") | |
| 158 | + .param("matchMode", "startsWith") | |
| 159 | + .header("Authorization", "Bearer " + adminToken)) | |
| 160 | + .andExpect(status().isBadRequest()) | |
| 161 | + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); | |
| 162 | + } | |
| 163 | + | |
| 164 | + @Test | |
| 165 | + void list_explicitUserTypeFilter() throws Exception { | |
| 166 | + mvc.perform(get("/api/v1/users") | |
| 167 | + .param("userType", "NORMAL") | |
| 168 | + .header("Authorization", "Bearer " + adminToken)) | |
| 169 | + .andExpect(status().isOk()) | |
| 170 | + .andExpect(jsonPath("$.data.total").value(2)); // alice + bob_deleted | |
| 171 | + } | |
| 172 | + | |
| 173 | + @Test | |
| 174 | + void list_explicitUserTypeInvalid_returns400_40001() throws Exception { | |
| 175 | + mvc.perform(get("/api/v1/users") | |
| 176 | + .param("userType", "HACKER") | |
| 177 | + .header("Authorization", "Bearer " + adminToken)) | |
| 178 | + .andExpect(status().isBadRequest()) | |
| 179 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 180 | + } | |
| 181 | + | |
| 182 | + @Test | |
| 183 | + void list_explicitIsDeletedFalse_filtersActive() throws Exception { | |
| 184 | + mvc.perform(get("/api/v1/users") | |
| 185 | + .param("isDeleted", "false") | |
| 186 | + .header("Authorization", "Bearer " + adminToken)) | |
| 187 | + .andExpect(status().isOk()) | |
| 188 | + .andExpect(jsonPath("$.data.total").value(2)); // alice + admin | |
| 189 | + } | |
| 190 | + | |
| 191 | + @Test | |
| 192 | + void list_composedFilters_andSemantics() throws Exception { | |
| 193 | + mvc.perform(get("/api/v1/users") | |
| 194 | + .param("queryField", "username") | |
| 195 | + .param("queryValue", "a") | |
| 196 | + .param("userType", "NORMAL") | |
| 197 | + .param("isDeleted", "false") | |
| 198 | + .header("Authorization", "Bearer " + adminToken)) | |
| 199 | + .andExpect(status().isOk()) | |
| 200 | + .andExpect(jsonPath("$.data.total").value(1)) | |
| 201 | + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK)); | |
| 202 | + } | |
| 203 | + | |
| 204 | + @Test | |
| 205 | + void list_pageBeyondTotal_returnsLastPage() throws Exception { | |
| 206 | + mvc.perform(get("/api/v1/users") | |
| 207 | + .param("page", "999") | |
| 208 | + .param("size", "10") | |
| 209 | + .header("Authorization", "Bearer " + adminToken)) | |
| 210 | + .andExpect(status().isOk()) | |
| 211 | + .andExpect(jsonPath("$.data.total").value(3)) | |
| 212 | + .andExpect(jsonPath("$.data.page").value(1)) | |
| 213 | + .andExpect(jsonPath("$.data.records.length()").value(3)); | |
| 214 | + } | |
| 215 | + | |
| 216 | + @Test | |
| 217 | + void list_normalUserToken_returns403_40301() throws Exception { | |
| 218 | + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + normalToken)) | |
| 219 | + .andExpect(status().isForbidden()) | |
| 220 | + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); | |
| 221 | + } | |
| 222 | + | |
| 223 | + @Test | |
| 224 | + void list_noAuthHeader_returns401_40101() throws Exception { | |
| 225 | + mvc.perform(get("/api/v1/users")) | |
| 226 | + .andExpect(status().isUnauthorized()) | |
| 227 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 228 | + } | |
| 229 | + | |
| 230 | + @Test | |
| 231 | + void list_responseRecordDoesNotIncludePasswordField() throws Exception { | |
| 232 | + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) | |
| 233 | + .andExpect(status().isOk()) | |
| 234 | + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()) | |
| 235 | + .andExpect(jsonPath("$.data.records[0].passwordHash").doesNotExist()) | |
| 236 | + .andExpect(jsonPath("$.data.records[0].sPasswordHash").doesNotExist()); | |
| 237 | + } | |
| 238 | + | |
| 239 | + @Test | |
| 240 | + void list_emptyTable_returnsZeroTotal() throws Exception { | |
| 241 | + jdbc.update("DELETE FROM sys_user_permission_category"); | |
| 242 | + jdbc.update("DELETE FROM sys_user"); | |
| 243 | + // 但鉴权需要 token 关联用户存在,所以保留 admin 用户即可 | |
| 244 | + // 重做:只删 alice / bob | |
| 245 | + // 重置后用一个独立 seed | |
| 246 | + seeder.reset(); | |
| 247 | + jdbc.update("DELETE FROM sys_user WHERE sUsername IN (?, ?)", | |
| 248 | + LoginTestSeeder.USER_OK, LoginTestSeeder.USER_DELETED); | |
| 249 | + | |
| 250 | + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) | |
| 251 | + .andExpect(status().isOk()) | |
| 252 | + .andExpect(jsonPath("$.data.total").value(1)); // 只剩 admin | |
| 253 | + } | |
| 254 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.fasterxml.jackson.databind.node.ObjectNode; | |
| 5 | +import com.xly.erp.common.response.ErrorCode; | |
| 6 | +import com.xly.erp.common.security.JwtUtil; | |
| 7 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 8 | +import com.xly.erp.module.usr.dto.LoginReq; | |
| 9 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 13 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 14 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 15 | +import org.springframework.http.MediaType; | |
| 16 | +import org.springframework.test.context.ActiveProfiles; | |
| 17 | +import org.springframework.test.web.servlet.MockMvc; | |
| 18 | + | |
| 19 | +import java.util.HashMap; | |
| 20 | +import java.util.List; | |
| 21 | +import java.util.Map; | |
| 22 | + | |
| 23 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 24 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | |
| 25 | + | |
| 26 | +@SpringBootTest | |
| 27 | +@AutoConfigureMockMvc | |
| 28 | +@ActiveProfiles("test") | |
| 29 | +class UserControllerTest { | |
| 30 | + | |
| 31 | + @Autowired private MockMvc mvc; | |
| 32 | + @Autowired private ObjectMapper json; | |
| 33 | + @Autowired private LoginTestSeeder seeder; | |
| 34 | + @Autowired private JwtUtil jwtUtil; | |
| 35 | + | |
| 36 | + private LoginTestSeeder.Fixture fx; | |
| 37 | + private String adminToken; | |
| 38 | + private String normalToken; | |
| 39 | + private String deletedToken; | |
| 40 | + | |
| 41 | + @BeforeEach | |
| 42 | + void setUp() { | |
| 43 | + fx = seeder.reset(); | |
| 44 | + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN"); | |
| 45 | + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL"); | |
| 46 | + deletedToken = issue(LoginTestSeeder.USER_DELETED, "NORMAL"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + private String issue(String username, String userType) { | |
| 50 | + Map<String, Object> claims = new HashMap<>(); | |
| 51 | + claims.put("sub", 1); | |
| 52 | + claims.put("username", username); | |
| 53 | + claims.put("userType", userType); | |
| 54 | + claims.put("companyCode", LoginTestSeeder.COMPANY_OK); | |
| 55 | + claims.put("language", "zh-CN"); | |
| 56 | + return jwtUtil.issue(claims, 7200); | |
| 57 | + } | |
| 58 | + | |
| 59 | + private CreateUserReq buildReq() { | |
| 60 | + CreateUserReq r = new CreateUserReq(); | |
| 61 | + r.setUsername("newbie"); | |
| 62 | + r.setUserCode("U010"); | |
| 63 | + r.setUserType("NORMAL"); | |
| 64 | + r.setLanguage("zh-CN"); | |
| 65 | + r.setCanEditDocument(false); | |
| 66 | + return r; | |
| 67 | + } | |
| 68 | + | |
| 69 | + private String body(Object o) throws Exception { | |
| 70 | + return json.writeValueAsString(o); | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test | |
| 74 | + void post_users_success_returns201_andCreatedVo() throws Exception { | |
| 75 | + mvc.perform(post("/api/v1/users") | |
| 76 | + .header("Authorization", "Bearer " + adminToken) | |
| 77 | + .contentType(MediaType.APPLICATION_JSON) | |
| 78 | + .content(body(buildReq()))) | |
| 79 | + .andExpect(status().isCreated()) | |
| 80 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 81 | + .andExpect(jsonPath("$.data.userId").isNumber()) | |
| 82 | + .andExpect(jsonPath("$.data.username").value("newbie")) | |
| 83 | + .andExpect(jsonPath("$.data.userCode").value("U010")); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + void post_users_blankUsername_returns400_40001() throws Exception { | |
| 88 | + CreateUserReq r = buildReq(); | |
| 89 | + r.setUsername(""); | |
| 90 | + mvc.perform(post("/api/v1/users") | |
| 91 | + .header("Authorization", "Bearer " + adminToken) | |
| 92 | + .contentType(MediaType.APPLICATION_JSON) | |
| 93 | + .content(body(r))) | |
| 94 | + .andExpect(status().isBadRequest()) | |
| 95 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void post_users_invalidUserType_returns400_40001() throws Exception { | |
| 100 | + CreateUserReq r = buildReq(); | |
| 101 | + r.setUserType("ROOT"); | |
| 102 | + mvc.perform(post("/api/v1/users") | |
| 103 | + .header("Authorization", "Bearer " + adminToken) | |
| 104 | + .contentType(MediaType.APPLICATION_JSON) | |
| 105 | + .content(body(r))) | |
| 106 | + .andExpect(status().isBadRequest()) | |
| 107 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 108 | + } | |
| 109 | + | |
| 110 | + @Test | |
| 111 | + void post_users_unknownPropertyPassword_returns400_40001() throws Exception { | |
| 112 | + ObjectNode body = json.valueToTree(buildReq()); | |
| 113 | + body.put("password", "Password1!"); | |
| 114 | + mvc.perform(post("/api/v1/users") | |
| 115 | + .header("Authorization", "Bearer " + adminToken) | |
| 116 | + .contentType(MediaType.APPLICATION_JSON) | |
| 117 | + .content(body.toString())) | |
| 118 | + .andExpect(status().isBadRequest()) | |
| 119 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 120 | + } | |
| 121 | + | |
| 122 | + @Test | |
| 123 | + void post_users_noAuthHeader_returns401_40101() throws Exception { | |
| 124 | + mvc.perform(post("/api/v1/users") | |
| 125 | + .contentType(MediaType.APPLICATION_JSON) | |
| 126 | + .content(body(buildReq()))) | |
| 127 | + .andExpect(status().isUnauthorized()) | |
| 128 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 129 | + } | |
| 130 | + | |
| 131 | + @Test | |
| 132 | + void post_users_normalUserToken_returns403_40301() throws Exception { | |
| 133 | + mvc.perform(post("/api/v1/users") | |
| 134 | + .header("Authorization", "Bearer " + normalToken) | |
| 135 | + .contentType(MediaType.APPLICATION_JSON) | |
| 136 | + .content(body(buildReq()))) | |
| 137 | + .andExpect(status().isForbidden()) | |
| 138 | + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + void post_users_deletedUserToken_returns401_40101() throws Exception { | |
| 143 | + mvc.perform(post("/api/v1/users") | |
| 144 | + .header("Authorization", "Bearer " + deletedToken) | |
| 145 | + .contentType(MediaType.APPLICATION_JSON) | |
| 146 | + .content(body(buildReq()))) | |
| 147 | + .andExpect(status().isUnauthorized()) | |
| 148 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + void post_users_duplicateUsername_returns409_40901() throws Exception { | |
| 153 | + CreateUserReq r = buildReq(); | |
| 154 | + r.setUsername(LoginTestSeeder.USER_OK); | |
| 155 | + mvc.perform(post("/api/v1/users") | |
| 156 | + .header("Authorization", "Bearer " + adminToken) | |
| 157 | + .contentType(MediaType.APPLICATION_JSON) | |
| 158 | + .content(body(r))) | |
| 159 | + .andExpect(status().isConflict()) | |
| 160 | + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERNAME)); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Test | |
| 164 | + void post_users_duplicateUserCode_returns409_40902() throws Exception { | |
| 165 | + CreateUserReq r = buildReq(); | |
| 166 | + r.setUserCode("U001"); | |
| 167 | + mvc.perform(post("/api/v1/users") | |
| 168 | + .header("Authorization", "Bearer " + adminToken) | |
| 169 | + .contentType(MediaType.APPLICATION_JSON) | |
| 170 | + .content(body(r))) | |
| 171 | + .andExpect(status().isConflict()) | |
| 172 | + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE)); | |
| 173 | + } | |
| 174 | + | |
| 175 | + @Test | |
| 176 | + void post_users_unknownEmployee_returns400_40004() throws Exception { | |
| 177 | + CreateUserReq r = buildReq(); | |
| 178 | + r.setEmployeeId(99999); | |
| 179 | + mvc.perform(post("/api/v1/users") | |
| 180 | + .header("Authorization", "Bearer " + adminToken) | |
| 181 | + .contentType(MediaType.APPLICATION_JSON) | |
| 182 | + .content(body(r))) | |
| 183 | + .andExpect(status().isBadRequest()) | |
| 184 | + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); | |
| 185 | + } | |
| 186 | + | |
| 187 | + @Test | |
| 188 | + void post_users_unknownPermissionCategory_returns400_40004() throws Exception { | |
| 189 | + CreateUserReq r = buildReq(); | |
| 190 | + r.setPermissionCategoryIds(List.of(99999)); | |
| 191 | + mvc.perform(post("/api/v1/users") | |
| 192 | + .header("Authorization", "Bearer " + adminToken) | |
| 193 | + .contentType(MediaType.APPLICATION_JSON) | |
| 194 | + .content(body(r))) | |
| 195 | + .andExpect(status().isBadRequest()) | |
| 196 | + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); | |
| 197 | + } | |
| 198 | + | |
| 199 | + @Test | |
| 200 | + void post_users_success_canLoginWithInitialPassword() throws Exception { | |
| 201 | + // 1) admin 创建用户 | |
| 202 | + mvc.perform(post("/api/v1/users") | |
| 203 | + .header("Authorization", "Bearer " + adminToken) | |
| 204 | + .contentType(MediaType.APPLICATION_JSON) | |
| 205 | + .content(body(buildReq()))) | |
| 206 | + .andExpect(status().isCreated()); | |
| 207 | + | |
| 208 | + // 2) 新用户用初始密码 666666 登录应成功 | |
| 209 | + LoginReq login = new LoginReq(); | |
| 210 | + login.setUsername("newbie"); | |
| 211 | + login.setPassword("666666"); | |
| 212 | + login.setCompanyCode(LoginTestSeeder.COMPANY_OK); | |
| 213 | + mvc.perform(post("/api/v1/auth/login") | |
| 214 | + .contentType(MediaType.APPLICATION_JSON) | |
| 215 | + .content(body(login))) | |
| 216 | + .andExpect(status().isOk()) | |
| 217 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 218 | + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); | |
| 219 | + } | |
| 220 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.fasterxml.jackson.databind.node.ObjectNode; | |
| 5 | +import com.xly.erp.common.response.ErrorCode; | |
| 6 | +import com.xly.erp.common.security.JwtUtil; | |
| 7 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 11 | +import org.junit.jupiter.api.BeforeEach; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 14 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 15 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 16 | +import org.springframework.http.MediaType; | |
| 17 | +import org.springframework.test.context.ActiveProfiles; | |
| 18 | +import org.springframework.test.web.servlet.MockMvc; | |
| 19 | + | |
| 20 | +import java.util.HashMap; | |
| 21 | +import java.util.List; | |
| 22 | +import java.util.Map; | |
| 23 | + | |
| 24 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 25 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 26 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | |
| 27 | + | |
| 28 | +@SpringBootTest | |
| 29 | +@AutoConfigureMockMvc | |
| 30 | +@ActiveProfiles("test") | |
| 31 | +class UserControllerUpdateTest { | |
| 32 | + | |
| 33 | + @Autowired private MockMvc mvc; | |
| 34 | + @Autowired private ObjectMapper json; | |
| 35 | + @Autowired private LoginTestSeeder seeder; | |
| 36 | + @Autowired private JwtUtil jwtUtil; | |
| 37 | + @Autowired private SysUserPermissionCategoryMapper upcMapper; | |
| 38 | + @Autowired private com.xly.erp.module.usr.mapper.SysUserMapper userMapper; | |
| 39 | + | |
| 40 | + private LoginTestSeeder.Fixture fx; | |
| 41 | + private String adminToken; | |
| 42 | + private String normalToken; | |
| 43 | + | |
| 44 | + @BeforeEach | |
| 45 | + void setUp() { | |
| 46 | + fx = seeder.reset(); | |
| 47 | + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId()); | |
| 48 | + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId()); | |
| 49 | + } | |
| 50 | + | |
| 51 | + private String issue(String username, String userType, Integer userId) { | |
| 52 | + Map<String, Object> c = new HashMap<>(); | |
| 53 | + c.put("sub", userId); | |
| 54 | + c.put("username", username); | |
| 55 | + c.put("userType", userType); | |
| 56 | + c.put("companyCode", LoginTestSeeder.COMPANY_OK); | |
| 57 | + c.put("language", "zh-CN"); | |
| 58 | + return jwtUtil.issue(c, 7200); | |
| 59 | + } | |
| 60 | + | |
| 61 | + // ===== GET ===== | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void get_existingUser_returns200_andFullVo() throws Exception { | |
| 65 | + mvc.perform(get("/api/v1/users/" + fx.aliceId()) | |
| 66 | + .header("Authorization", "Bearer " + adminToken)) | |
| 67 | + .andExpect(status().isOk()) | |
| 68 | + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) | |
| 69 | + .andExpect(jsonPath("$.data.userId").value(fx.aliceId())) | |
| 70 | + .andExpect(jsonPath("$.data.username").value(LoginTestSeeder.USER_OK)) | |
| 71 | + .andExpect(jsonPath("$.data.employeeName").value("张三")); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + void get_unknownUser_returns404_40401() throws Exception { | |
| 76 | + mvc.perform(get("/api/v1/users/99999") | |
| 77 | + .header("Authorization", "Bearer " + adminToken)) | |
| 78 | + .andExpect(status().isNotFound()) | |
| 79 | + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + void get_normalUser_returns403_40301() throws Exception { | |
| 84 | + mvc.perform(get("/api/v1/users/" + fx.aliceId()) | |
| 85 | + .header("Authorization", "Bearer " + normalToken)) | |
| 86 | + .andExpect(status().isForbidden()) | |
| 87 | + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test | |
| 91 | + void get_noAuthHeader_returns401_40101() throws Exception { | |
| 92 | + mvc.perform(get("/api/v1/users/" + fx.aliceId())) | |
| 93 | + .andExpect(status().isUnauthorized()) | |
| 94 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void get_deletedUser_stillReturns200() throws Exception { | |
| 99 | + mvc.perform(get("/api/v1/users/" + fx.bobDeletedId()) | |
| 100 | + .header("Authorization", "Bearer " + adminToken)) | |
| 101 | + .andExpect(status().isOk()) | |
| 102 | + .andExpect(jsonPath("$.data.isDeleted").value(true)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // ===== PUT ===== | |
| 106 | + | |
| 107 | + private String body(Object o) throws Exception { | |
| 108 | + return json.writeValueAsString(o); | |
| 109 | + } | |
| 110 | + | |
| 111 | + private UpdateUserReq req() { | |
| 112 | + return new UpdateUserReq(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void put_updateUserCodeAndType_returns200() throws Exception { | |
| 117 | + UpdateUserReq r = req(); | |
| 118 | + r.setUserCode("U_NEW"); | |
| 119 | + r.setUserType("SUPER_ADMIN"); | |
| 120 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 121 | + .header("Authorization", "Bearer " + adminToken) | |
| 122 | + .contentType(MediaType.APPLICATION_JSON) | |
| 123 | + .content(body(r))) | |
| 124 | + .andExpect(status().isOk()) | |
| 125 | + .andExpect(jsonPath("$.data.userCode").value("U_NEW")) | |
| 126 | + .andExpect(jsonPath("$.data.userType").value("SUPER_ADMIN")); | |
| 127 | + } | |
| 128 | + | |
| 129 | + @Test | |
| 130 | + void put_updateEmployeeId_toAnotherEmployee_setsValue() throws Exception { | |
| 131 | + UpdateUserReq r = req(); | |
| 132 | + r.setEmployeeId(fx.employeeId()); | |
| 133 | + mvc.perform(put("/api/v1/users/" + fx.adminId()) | |
| 134 | + .header("Authorization", "Bearer " + adminToken) | |
| 135 | + .contentType(MediaType.APPLICATION_JSON) | |
| 136 | + .content(body(r))) | |
| 137 | + .andExpect(status().isOk()) | |
| 138 | + .andExpect(jsonPath("$.data.employeeId").value(fx.employeeId())); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + void put_updateEmployeeId_zero_clearsRelation() throws Exception { | |
| 143 | + UpdateUserReq r = req(); | |
| 144 | + r.setEmployeeId(0); | |
| 145 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 146 | + .header("Authorization", "Bearer " + adminToken) | |
| 147 | + .contentType(MediaType.APPLICATION_JSON) | |
| 148 | + .content(body(r))) | |
| 149 | + .andExpect(status().isOk()) | |
| 150 | + .andExpect(jsonPath("$.data.employeeId").doesNotExist()); | |
| 151 | + } | |
| 152 | + | |
| 153 | + @Test | |
| 154 | + void put_updateEmployeeId_unknown_returns400_40004() throws Exception { | |
| 155 | + UpdateUserReq r = req(); | |
| 156 | + r.setEmployeeId(99999); | |
| 157 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 158 | + .header("Authorization", "Bearer " + adminToken) | |
| 159 | + .contentType(MediaType.APPLICATION_JSON) | |
| 160 | + .content(body(r))) | |
| 161 | + .andExpect(status().isBadRequest()) | |
| 162 | + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); | |
| 163 | + } | |
| 164 | + | |
| 165 | + @Test | |
| 166 | + void put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall() throws Exception { | |
| 167 | + // 把 alice 设为作废 | |
| 168 | + UpdateUserReq r = req(); | |
| 169 | + r.setIsDeleted(true); | |
| 170 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 171 | + .header("Authorization", "Bearer " + adminToken) | |
| 172 | + .contentType(MediaType.APPLICATION_JSON) | |
| 173 | + .content(body(r))) | |
| 174 | + .andExpect(status().isOk()); | |
| 175 | + | |
| 176 | + // 用 alice 的 token 调任何 /api/v1/** 接口应 40101 | |
| 177 | + // 但 alice 是 NORMAL,连 admin 路径都会先 401(用户已作废),不是 403 | |
| 178 | + mvc.perform(get("/api/v1/users/" + fx.aliceId()) | |
| 179 | + .header("Authorization", "Bearer " + normalToken)) | |
| 180 | + .andExpect(status().isUnauthorized()) | |
| 181 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); | |
| 182 | + } | |
| 183 | + | |
| 184 | + @Test | |
| 185 | + void put_permissionCategories_subsetDelta() throws Exception { | |
| 186 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 187 | + Integer sal = fx.activePermissionCategoryIds().get(1); | |
| 188 | + // 预置 alice 有 {pur, sal} | |
| 189 | + for (Integer pcId : List.of(pur, sal)) { | |
| 190 | + SysUserPermissionCategory l = new SysUserPermissionCategory(); | |
| 191 | + l.setIUserId(fx.aliceId()); | |
| 192 | + l.setIPermissionCategoryId(pcId); | |
| 193 | + l.setSGrantedBy("system"); | |
| 194 | + upcMapper.insert(l); | |
| 195 | + } | |
| 196 | + | |
| 197 | + UpdateUserReq r = req(); | |
| 198 | + r.setPermissionCategoryIds(List.of(sal)); // 只保留 sal | |
| 199 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 200 | + .header("Authorization", "Bearer " + adminToken) | |
| 201 | + .contentType(MediaType.APPLICATION_JSON) | |
| 202 | + .content(body(r))) | |
| 203 | + .andExpect(status().isOk()) | |
| 204 | + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(1)) | |
| 205 | + .andExpect(jsonPath("$.data.permissionCategoryIds[0]").value(sal)); | |
| 206 | + } | |
| 207 | + | |
| 208 | + @Test | |
| 209 | + void put_permissionCategories_emptyArray_clearsAll() throws Exception { | |
| 210 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 211 | + SysUserPermissionCategory l = new SysUserPermissionCategory(); | |
| 212 | + l.setIUserId(fx.aliceId()); | |
| 213 | + l.setIPermissionCategoryId(pur); | |
| 214 | + l.setSGrantedBy("system"); | |
| 215 | + upcMapper.insert(l); | |
| 216 | + | |
| 217 | + UpdateUserReq r = req(); | |
| 218 | + r.setPermissionCategoryIds(List.of()); | |
| 219 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 220 | + .header("Authorization", "Bearer " + adminToken) | |
| 221 | + .contentType(MediaType.APPLICATION_JSON) | |
| 222 | + .content(body(r))) | |
| 223 | + .andExpect(status().isOk()) | |
| 224 | + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0)); | |
| 225 | + } | |
| 226 | + | |
| 227 | + @Test | |
| 228 | + void put_permissionCategories_unknownId_returns400_40004_andRollsBack() throws Exception { | |
| 229 | + UpdateUserReq r = req(); | |
| 230 | + r.setUserCode("U_NEW"); | |
| 231 | + r.setPermissionCategoryIds(List.of(99999)); | |
| 232 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 233 | + .header("Authorization", "Bearer " + adminToken) | |
| 234 | + .contentType(MediaType.APPLICATION_JSON) | |
| 235 | + .content(body(r))) | |
| 236 | + .andExpect(status().isBadRequest()) | |
| 237 | + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); | |
| 238 | + | |
| 239 | + // 验证回滚:alice 的 userCode 仍是 U001 | |
| 240 | + com.xly.erp.module.usr.entity.SysUser db = userMapper.selectById(fx.aliceId()); | |
| 241 | + org.junit.jupiter.api.Assertions.assertEquals("U001", db.getSUserCode()); | |
| 242 | + } | |
| 243 | + | |
| 244 | + @Test | |
| 245 | + void put_duplicateUserCode_returns409_40902() throws Exception { | |
| 246 | + UpdateUserReq r = req(); | |
| 247 | + r.setUserCode("U001"); // alice 的 userCode | |
| 248 | + mvc.perform(put("/api/v1/users/" + fx.adminId()) | |
| 249 | + .header("Authorization", "Bearer " + adminToken) | |
| 250 | + .contentType(MediaType.APPLICATION_JSON) | |
| 251 | + .content(body(r))) | |
| 252 | + .andExpect(status().isConflict()) | |
| 253 | + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE)); | |
| 254 | + } | |
| 255 | + | |
| 256 | + @Test | |
| 257 | + void put_userCodeUnchangedSameAsSelf_returns200() throws Exception { | |
| 258 | + UpdateUserReq r = req(); | |
| 259 | + r.setUserCode("U001"); // alice 的当前 userCode | |
| 260 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 261 | + .header("Authorization", "Bearer " + adminToken) | |
| 262 | + .contentType(MediaType.APPLICATION_JSON) | |
| 263 | + .content(body(r))) | |
| 264 | + .andExpect(status().isOk()); | |
| 265 | + } | |
| 266 | + | |
| 267 | + @Test | |
| 268 | + void put_selfDeactivate_returns403_40302() throws Exception { | |
| 269 | + UpdateUserReq r = req(); | |
| 270 | + r.setIsDeleted(true); | |
| 271 | + mvc.perform(put("/api/v1/users/" + fx.adminId()) | |
| 272 | + .header("Authorization", "Bearer " + adminToken) | |
| 273 | + .contentType(MediaType.APPLICATION_JSON) | |
| 274 | + .content(body(r))) | |
| 275 | + .andExpect(status().isForbidden()) | |
| 276 | + .andExpect(jsonPath("$.code").value(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE)); | |
| 277 | + } | |
| 278 | + | |
| 279 | + @Test | |
| 280 | + void put_unknownProperty_username_returns400_40001() throws Exception { | |
| 281 | + ObjectNode b = json.createObjectNode(); | |
| 282 | + b.put("username", "hacker"); | |
| 283 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 284 | + .header("Authorization", "Bearer " + adminToken) | |
| 285 | + .contentType(MediaType.APPLICATION_JSON) | |
| 286 | + .content(b.toString())) | |
| 287 | + .andExpect(status().isBadRequest()) | |
| 288 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 289 | + } | |
| 290 | + | |
| 291 | + @Test | |
| 292 | + void put_unknownProperty_password_returns400_40001() throws Exception { | |
| 293 | + ObjectNode b = json.createObjectNode(); | |
| 294 | + b.put("password", "newpass"); | |
| 295 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 296 | + .header("Authorization", "Bearer " + adminToken) | |
| 297 | + .contentType(MediaType.APPLICATION_JSON) | |
| 298 | + .content(b.toString())) | |
| 299 | + .andExpect(status().isBadRequest()) | |
| 300 | + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); | |
| 301 | + } | |
| 302 | + | |
| 303 | + @Test | |
| 304 | + void put_unknownUserId_returns404_40401() throws Exception { | |
| 305 | + mvc.perform(put("/api/v1/users/99999") | |
| 306 | + .header("Authorization", "Bearer " + adminToken) | |
| 307 | + .contentType(MediaType.APPLICATION_JSON) | |
| 308 | + .content(body(req()))) | |
| 309 | + .andExpect(status().isNotFound()) | |
| 310 | + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND)); | |
| 311 | + } | |
| 312 | + | |
| 313 | + @Test | |
| 314 | + void put_normalUser_returns403_40301() throws Exception { | |
| 315 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 316 | + .header("Authorization", "Bearer " + normalToken) | |
| 317 | + .contentType(MediaType.APPLICATION_JSON) | |
| 318 | + .content(body(req()))) | |
| 319 | + .andExpect(status().isForbidden()) | |
| 320 | + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); | |
| 321 | + } | |
| 322 | + | |
| 323 | + @Test | |
| 324 | + void put_emptyBody_only_updates_audit_fields() throws Exception { | |
| 325 | + mvc.perform(put("/api/v1/users/" + fx.aliceId()) | |
| 326 | + .header("Authorization", "Bearer " + adminToken) | |
| 327 | + .contentType(MediaType.APPLICATION_JSON) | |
| 328 | + .content("{}")) | |
| 329 | + .andExpect(status().isOk()) | |
| 330 | + .andExpect(jsonPath("$.data.updatedBy").value(LoginTestSeeder.USER_ADMIN)); | |
| 331 | + } | |
| 332 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/dto/CreateUserReqValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.ConstraintViolation; | |
| 4 | +import jakarta.validation.Validation; | |
| 5 | +import jakarta.validation.Validator; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | +import java.util.Set; | |
| 10 | + | |
| 11 | +import static org.junit.jupiter.api.Assertions.*; | |
| 12 | + | |
| 13 | +class CreateUserReqValidationTest { | |
| 14 | + | |
| 15 | + private static final Validator VALIDATOR = | |
| 16 | + Validation.buildDefaultValidatorFactory().getValidator(); | |
| 17 | + | |
| 18 | + private CreateUserReq build() { | |
| 19 | + CreateUserReq r = new CreateUserReq(); | |
| 20 | + r.setUsername("alice2"); | |
| 21 | + r.setUserCode("U010"); | |
| 22 | + r.setUserType("NORMAL"); | |
| 23 | + r.setLanguage("zh-CN"); | |
| 24 | + r.setCanEditDocument(false); | |
| 25 | + return r; | |
| 26 | + } | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void allRequired_passes() { | |
| 30 | + Set<ConstraintViolation<CreateUserReq>> v = VALIDATOR.validate(build()); | |
| 31 | + assertTrue(v.isEmpty()); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + void blankUsername_fails() { | |
| 36 | + CreateUserReq r = build(); | |
| 37 | + r.setUsername(""); | |
| 38 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + void usernameTooShort_fails() { | |
| 43 | + CreateUserReq r = build(); | |
| 44 | + r.setUsername("ab"); | |
| 45 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test | |
| 49 | + void usernameWithIllegalChar_fails() { | |
| 50 | + CreateUserReq r = build(); | |
| 51 | + r.setUsername("al ice"); | |
| 52 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void userCodeTooLong_fails() { | |
| 57 | + CreateUserReq r = build(); | |
| 58 | + r.setUserCode("X".repeat(51)); | |
| 59 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + void userTypeNotEnum_fails() { | |
| 64 | + CreateUserReq r = build(); | |
| 65 | + r.setUserType("ROOT"); | |
| 66 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Test | |
| 70 | + void languageNotEnum_fails() { | |
| 71 | + CreateUserReq r = build(); | |
| 72 | + r.setLanguage("ja-JP"); | |
| 73 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void canEditDocumentMissing_fails() { | |
| 78 | + CreateUserReq r = build(); | |
| 79 | + r.setCanEditDocument(null); | |
| 80 | + assertFalse(VALIDATOR.validate(r).isEmpty()); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void employeeIdNull_isAllowed() { | |
| 85 | + CreateUserReq r = build(); | |
| 86 | + r.setEmployeeId(null); | |
| 87 | + assertTrue(VALIDATOR.validate(r).isEmpty()); | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test | |
| 91 | + void permissionCategoryIdsEmpty_isAllowed() { | |
| 92 | + CreateUserReq r = build(); | |
| 93 | + r.setPermissionCategoryIds(List.of()); | |
| 94 | + assertTrue(VALIDATOR.validate(r).isEmpty()); | |
| 95 | + } | |
| 96 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.ConstraintViolation; | |
| 4 | +import jakarta.validation.Validation; | |
| 5 | +import jakarta.validation.Validator; | |
| 6 | +import jakarta.validation.ValidatorFactory; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +import java.util.Set; | |
| 10 | + | |
| 11 | +import static org.junit.jupiter.api.Assertions.*; | |
| 12 | + | |
| 13 | +class LoginReqValidationTest { | |
| 14 | + | |
| 15 | + private static final Validator VALIDATOR = | |
| 16 | + Validation.buildDefaultValidatorFactory().getValidator(); | |
| 17 | + | |
| 18 | + private LoginReq build(String u, String p, String c) { | |
| 19 | + LoginReq r = new LoginReq(); | |
| 20 | + r.setUsername(u); | |
| 21 | + r.setPassword(p); | |
| 22 | + r.setCompanyCode(c); | |
| 23 | + return r; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void blankUsername_fails() { | |
| 28 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("", "Password1!", "HQ")); | |
| 29 | + assertFalse(v.isEmpty()); | |
| 30 | + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("username"))); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + void blankPassword_fails() { | |
| 35 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "", "HQ")); | |
| 36 | + assertFalse(v.isEmpty()); | |
| 37 | + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("password"))); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Test | |
| 41 | + void blankCompanyCode_fails() { | |
| 42 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "Password1!", "")); | |
| 43 | + assertFalse(v.isEmpty()); | |
| 44 | + assertTrue(v.stream().anyMatch(c -> c.getPropertyPath().toString().equals("companyCode"))); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + void tooLongUsername_fails() { | |
| 49 | + String tooLong = "a".repeat(51); | |
| 50 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build(tooLong, "Password1!", "HQ")); | |
| 51 | + assertFalse(v.isEmpty()); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + void tooLongPassword_fails() { | |
| 56 | + String tooLong = "a".repeat(129); | |
| 57 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", tooLong, "HQ")); | |
| 58 | + assertFalse(v.isEmpty()); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void allFieldsPresent_passes() { | |
| 63 | + Set<ConstraintViolation<LoginReq>> v = VALIDATOR.validate(build("alice", "Password1!", "HQ")); | |
| 64 | + assertTrue(v.isEmpty()); | |
| 65 | + } | |
| 66 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.ConstraintViolation; | |
| 4 | +import jakarta.validation.Validation; | |
| 5 | +import jakarta.validation.Validator; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.util.Set; | |
| 9 | + | |
| 10 | +import static org.junit.jupiter.api.Assertions.*; | |
| 11 | + | |
| 12 | +class UpdateUserReqValidationTest { | |
| 13 | + | |
| 14 | + private static final Validator V = | |
| 15 | + Validation.buildDefaultValidatorFactory().getValidator(); | |
| 16 | + | |
| 17 | + @Test | |
| 18 | + void emptyBody_isValid() { | |
| 19 | + assertTrue(V.validate(new UpdateUserReq()).isEmpty()); | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + void invalidUserType_fails() { | |
| 24 | + UpdateUserReq r = new UpdateUserReq(); | |
| 25 | + r.setUserType("ROOT"); | |
| 26 | + assertFalse(V.validate(r).isEmpty()); | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + void invalidLanguage_fails() { | |
| 31 | + UpdateUserReq r = new UpdateUserReq(); | |
| 32 | + r.setLanguage("ja-JP"); | |
| 33 | + assertFalse(V.validate(r).isEmpty()); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + void userCodeTooLong_fails() { | |
| 38 | + UpdateUserReq r = new UpdateUserReq(); | |
| 39 | + r.setUserCode("X".repeat(51)); | |
| 40 | + assertFalse(V.validate(r).isEmpty()); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + void userCodeBlank_fails() { | |
| 45 | + UpdateUserReq r = new UpdateUserReq(); | |
| 46 | + r.setUserCode(" "); | |
| 47 | + assertFalse(V.validate(r).isEmpty()); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + void employeeIdNegative_fails() { | |
| 52 | + UpdateUserReq r = new UpdateUserReq(); | |
| 53 | + r.setEmployeeId(-1); | |
| 54 | + assertFalse(V.validate(r).isEmpty()); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + void employeeIdZero_isValid_meansUnsetRelation() { | |
| 59 | + UpdateUserReq r = new UpdateUserReq(); | |
| 60 | + r.setEmployeeId(0); | |
| 61 | + assertTrue(V.validate(r).isEmpty()); | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + void allValidFields_passes() { | |
| 66 | + UpdateUserReq r = new UpdateUserReq(); | |
| 67 | + r.setUserCode("U999"); | |
| 68 | + r.setUserType("NORMAL"); | |
| 69 | + r.setLanguage("zh-CN"); | |
| 70 | + r.setCanEditDocument(true); | |
| 71 | + r.setEmployeeId(7); | |
| 72 | + r.setIsDeleted(false); | |
| 73 | + r.setPermissionCategoryIds(java.util.List.of(1, 2)); | |
| 74 | + Set<ConstraintViolation<UpdateUserReq>> v = V.validate(r); | |
| 75 | + assertTrue(v.isEmpty(), () -> "should be empty but got: " + v); | |
| 76 | + } | |
| 77 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryReqValidationTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.Validation; | |
| 4 | +import jakarta.validation.Validator; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +import static org.junit.jupiter.api.Assertions.assertFalse; | |
| 8 | +import static org.junit.jupiter.api.Assertions.assertTrue; | |
| 9 | + | |
| 10 | +class UserQueryReqValidationTest { | |
| 11 | + | |
| 12 | + private static final Validator V = | |
| 13 | + Validation.buildDefaultValidatorFactory().getValidator(); | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + void emptyReq_isValid() { | |
| 17 | + assertTrue(V.validate(new UserQueryReq()).isEmpty()); | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test | |
| 21 | + void pageZero_fails() { | |
| 22 | + UserQueryReq r = new UserQueryReq(); | |
| 23 | + r.setPage(0); | |
| 24 | + assertFalse(V.validate(r).isEmpty()); | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + void sizeOver100_fails() { | |
| 29 | + UserQueryReq r = new UserQueryReq(); | |
| 30 | + r.setSize(101); | |
| 31 | + assertFalse(V.validate(r).isEmpty()); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + void sizeZero_fails() { | |
| 36 | + UserQueryReq r = new UserQueryReq(); | |
| 37 | + r.setSize(0); | |
| 38 | + assertFalse(V.validate(r).isEmpty()); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + void allValidFields_passes() { | |
| 43 | + UserQueryReq r = new UserQueryReq(); | |
| 44 | + r.setPage(2); | |
| 45 | + r.setSize(50); | |
| 46 | + r.setSortField("tCreateDate"); | |
| 47 | + r.setSortOrder("asc"); | |
| 48 | + r.setQueryField("username"); | |
| 49 | + r.setMatchMode("contains"); | |
| 50 | + r.setQueryValue("ali"); | |
| 51 | + r.setUserType("NORMAL"); | |
| 52 | + r.setIsDeleted(false); | |
| 53 | + assertTrue(V.validate(r).isEmpty()); | |
| 54 | + } | |
| 55 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapperTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 4 | +import org.junit.jupiter.api.BeforeEach; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.test.context.ActiveProfiles; | |
| 9 | + | |
| 10 | +import java.util.ArrayList; | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 14 | + | |
| 15 | +@SpringBootTest | |
| 16 | +@ActiveProfiles("test") | |
| 17 | +class SysPermissionCategoryMapperTest { | |
| 18 | + | |
| 19 | + @Autowired private SysPermissionCategoryMapper mapper; | |
| 20 | + @Autowired private LoginTestSeeder seeder; | |
| 21 | + | |
| 22 | + private LoginTestSeeder.Fixture fx; | |
| 23 | + | |
| 24 | + @BeforeEach | |
| 25 | + void setUp() { | |
| 26 | + fx = seeder.reset(); | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + void countActiveByIds_returnsAllActive() { | |
| 31 | + int n = mapper.countActiveByIds(fx.activePermissionCategoryIds()); | |
| 32 | + assertEquals(fx.activePermissionCategoryIds().size(), n); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + void countActiveByIds_excludesDeleted() { | |
| 37 | + List<Integer> mixed = new ArrayList<>(fx.activePermissionCategoryIds()); | |
| 38 | + mixed.add(fx.deletedPermissionCategoryId()); | |
| 39 | + int n = mapper.countActiveByIds(mixed); | |
| 40 | + assertEquals(fx.activePermissionCategoryIds().size(), n, | |
| 41 | + "已软删的分类不应计入"); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void countActiveByIds_excludesUnknown() { | |
| 46 | + List<Integer> mixed = new ArrayList<>(fx.activePermissionCategoryIds()); | |
| 47 | + mixed.add(99999); | |
| 48 | + int n = mapper.countActiveByIds(mixed); | |
| 49 | + assertEquals(fx.activePermissionCategoryIds().size(), n); | |
| 50 | + } | |
| 51 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperQueryTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 4 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 5 | +import org.junit.jupiter.api.BeforeEach; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.junit.jupiter.api.Assertions.*; | |
| 14 | + | |
| 15 | +@SpringBootTest | |
| 16 | +@ActiveProfiles("test") | |
| 17 | +class SysUserMapperQueryTest { | |
| 18 | + | |
| 19 | + @Autowired private SysUserMapper mapper; | |
| 20 | + @Autowired private LoginTestSeeder seeder; | |
| 21 | + | |
| 22 | + @BeforeEach | |
| 23 | + void setUp() { | |
| 24 | + seeder.reset(); | |
| 25 | + } | |
| 26 | + | |
| 27 | + private UserQueryParams baseParams() { | |
| 28 | + UserQueryParams p = new UserQueryParams(); | |
| 29 | + p.sqlSortField = "tCreateDate"; | |
| 30 | + p.sqlSortOrder = "desc"; | |
| 31 | + p.matchMode = "contains"; | |
| 32 | + p.offset = 0; | |
| 33 | + p.limit = 100; | |
| 34 | + return p; | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test | |
| 38 | + void count_noFilters_returnsAllRows() { | |
| 39 | + long total = mapper.countByQuery(baseParams()); | |
| 40 | + // seeder 插入 alice + admin + bob_deleted = 3 行 | |
| 41 | + assertEquals(3, total); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void select_withSortByUsername_ascending() { | |
| 46 | + UserQueryParams p = baseParams(); | |
| 47 | + p.sqlSortField = "sUsername"; | |
| 48 | + p.sqlSortOrder = "asc"; | |
| 49 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 50 | + assertEquals(3, rows.size()); | |
| 51 | + // 期望升序:admin / alice / bob_deleted | |
| 52 | + assertEquals(LoginTestSeeder.USER_ADMIN, rows.get(0).getUsername()); | |
| 53 | + assertEquals(LoginTestSeeder.USER_OK, rows.get(1).getUsername()); | |
| 54 | + assertEquals(LoginTestSeeder.USER_DELETED, rows.get(2).getUsername()); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + void select_withQueryFieldUsername_contains() { | |
| 59 | + UserQueryParams p = baseParams(); | |
| 60 | + p.sqlQueryColumn = "u.sUsername"; | |
| 61 | + p.matchMode = "contains"; | |
| 62 | + p.queryValue = "ali"; | |
| 63 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 64 | + assertEquals(1, rows.size()); | |
| 65 | + assertEquals(LoginTestSeeder.USER_OK, rows.get(0).getUsername()); | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + void select_joinsEmployeeAndDepartment_returnsBothNames() { | |
| 70 | + UserQueryParams p = baseParams(); | |
| 71 | + p.sqlQueryColumn = "u.sUsername"; | |
| 72 | + p.matchMode = "equals"; | |
| 73 | + p.queryValue = LoginTestSeeder.USER_OK; | |
| 74 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 75 | + assertEquals(1, rows.size()); | |
| 76 | + assertEquals("张三", rows.get(0).getEmployeeName()); | |
| 77 | + assertEquals("技术部", rows.get(0).getDepartmentName()); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + void select_withIsDeletedFilter_returnsOnlyMatching() { | |
| 82 | + UserQueryParams p = baseParams(); | |
| 83 | + p.isDeleted = 1; | |
| 84 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 85 | + assertEquals(1, rows.size()); | |
| 86 | + assertEquals(LoginTestSeeder.USER_DELETED, rows.get(0).getUsername()); | |
| 87 | + } | |
| 88 | + | |
| 89 | + @Test | |
| 90 | + void select_withUserTypeFilter_returnsOnlyAdmin() { | |
| 91 | + UserQueryParams p = baseParams(); | |
| 92 | + p.userType = "SUPER_ADMIN"; | |
| 93 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 94 | + assertEquals(1, rows.size()); | |
| 95 | + assertEquals(LoginTestSeeder.USER_ADMIN, rows.get(0).getUsername()); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void select_pagination_limitsResults() { | |
| 100 | + UserQueryParams p = baseParams(); | |
| 101 | + p.limit = 2; | |
| 102 | + List<UserListItemVo> rows = mapper.selectByQuery(p); | |
| 103 | + assertEquals(2, rows.size()); | |
| 104 | + } | |
| 105 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 4 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 5 | +import org.junit.jupiter.api.BeforeEach; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | + | |
| 11 | +import static org.junit.jupiter.api.Assertions.*; | |
| 12 | + | |
| 13 | +@SpringBootTest | |
| 14 | +@ActiveProfiles("test") | |
| 15 | +class SysUserMapperTest { | |
| 16 | + | |
| 17 | + @Autowired | |
| 18 | + private SysUserMapper userMapper; | |
| 19 | + | |
| 20 | + @Autowired | |
| 21 | + private LoginTestSeeder seeder; | |
| 22 | + | |
| 23 | + @BeforeEach | |
| 24 | + void setUp() { | |
| 25 | + seeder.reset(); | |
| 26 | + } | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void selectByUsername_returnsUserWithAllFields() { | |
| 30 | + SysUser user = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 31 | + assertNotNull(user); | |
| 32 | + assertEquals(LoginTestSeeder.USER_OK, user.getSUsername()); | |
| 33 | + assertEquals("U001", user.getSUserCode()); | |
| 34 | + assertNotNull(user.getSPasswordHash()); | |
| 35 | + assertEquals("NORMAL", user.getSUserType()); | |
| 36 | + assertEquals("zh-CN", user.getSLanguage()); | |
| 37 | + assertEquals(0, user.getIIsDeleted()); | |
| 38 | + assertEquals(0, user.getIFailedLoginCount()); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + void selectByUsername_returnsNullWhenNotFound() { | |
| 43 | + SysUser user = userMapper.selectByUsername("nobody"); | |
| 44 | + assertNull(user); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + void existsByUsername_trueForExisting() { | |
| 49 | + assertTrue(userMapper.existsByUsername(LoginTestSeeder.USER_OK)); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + void existsByUsername_falseForUnknown() { | |
| 54 | + assertFalse(userMapper.existsByUsername("nobody")); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + void existsByUserCode_trueForExisting() { | |
| 59 | + assertTrue(userMapper.existsByUserCode("U001")); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + void existsByUserCode_falseForUnknown() { | |
| 64 | + assertFalse(userMapper.existsByUserCode("UXXX")); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void existsByUserCodeExcludingId_otherUserHasCode_returnsTrue() { | |
| 69 | + // alice has U001 / admin has U000;查 U001 排除 admin → 找到 alice → true | |
| 70 | + assertTrue(userMapper.existsByUserCodeExcludingId("U001", | |
| 71 | + seeder.reset().adminId())); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + void existsByUserCodeExcludingId_selfHasCode_returnsFalse() { | |
| 76 | + LoginTestSeeder.Fixture f = seeder.reset(); | |
| 77 | + // 查 alice 的 userCode 排除 alice 本身 → false | |
| 78 | + assertFalse(userMapper.existsByUserCodeExcludingId("U001", f.aliceId())); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + void existsByUserCodeExcludingId_unknownCode_returnsFalse() { | |
| 83 | + assertFalse(userMapper.existsByUserCodeExcludingId("UXXX", 1)); | |
| 84 | + } | |
| 85 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 4 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 5 | +import org.junit.jupiter.api.BeforeEach; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.junit.jupiter.api.Assertions.*; | |
| 14 | + | |
| 15 | +@SpringBootTest | |
| 16 | +@ActiveProfiles("test") | |
| 17 | +class SysUserPermissionCategoryMapperTest { | |
| 18 | + | |
| 19 | + @Autowired private SysUserPermissionCategoryMapper mapper; | |
| 20 | + @Autowired private LoginTestSeeder seeder; | |
| 21 | + | |
| 22 | + private LoginTestSeeder.Fixture fx; | |
| 23 | + | |
| 24 | + @BeforeEach | |
| 25 | + void setUp() { | |
| 26 | + fx = seeder.reset(); | |
| 27 | + // 给 alice 授权两个活跃分类 | |
| 28 | + for (Integer pcId : fx.activePermissionCategoryIds()) { | |
| 29 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 30 | + link.setIUserId(fx.aliceId()); | |
| 31 | + link.setIPermissionCategoryId(pcId); | |
| 32 | + link.setSGrantedBy("system"); | |
| 33 | + mapper.insert(link); | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test | |
| 38 | + void selectPermissionCategoryIdsByUserId_returnsAllCurrent() { | |
| 39 | + List<Integer> ids = mapper.selectPermissionCategoryIdsByUserId(fx.aliceId()); | |
| 40 | + assertEquals(fx.activePermissionCategoryIds().size(), ids.size()); | |
| 41 | + assertTrue(ids.containsAll(fx.activePermissionCategoryIds())); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void selectPermissionCategoryIdsByUserId_emptyForNoGrants() { | |
| 46 | + List<Integer> ids = mapper.selectPermissionCategoryIdsByUserId(fx.adminId()); | |
| 47 | + assertTrue(ids.isEmpty()); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + void deleteByUserAndCategoryIds_onlyDeletesGivenSubset() { | |
| 52 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 53 | + int rows = mapper.deleteByUserAndCategoryIds(fx.aliceId(), List.of(pur)); | |
| 54 | + assertEquals(1, rows); | |
| 55 | + | |
| 56 | + List<Integer> remaining = mapper.selectPermissionCategoryIdsByUserId(fx.aliceId()); | |
| 57 | + assertEquals(fx.activePermissionCategoryIds().size() - 1, remaining.size()); | |
| 58 | + assertFalse(remaining.contains(pur)); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void deleteByUserAndCategoryIds_nonMatchingIds_returns0() { | |
| 63 | + int rows = mapper.deleteByUserAndCategoryIds(fx.aliceId(), List.of(99999)); | |
| 64 | + assertEquals(0, rows); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void deleteByUserAndCategoryIds_doesNotAffectOtherUser() { | |
| 69 | + // 给 admin 也授权一条 | |
| 70 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 71 | + link.setIUserId(fx.adminId()); | |
| 72 | + link.setIPermissionCategoryId(fx.activePermissionCategoryIds().get(0)); | |
| 73 | + link.setSGrantedBy("system"); | |
| 74 | + mapper.insert(link); | |
| 75 | + | |
| 76 | + // 删 alice 的某个分类不应影响 admin | |
| 77 | + mapper.deleteByUserAndCategoryIds(fx.aliceId(), | |
| 78 | + List.of(fx.activePermissionCategoryIds().get(0))); | |
| 79 | + | |
| 80 | + assertEquals(1, mapper.selectPermissionCategoryIdsByUserId(fx.adminId()).size()); | |
| 81 | + } | |
| 82 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.security.JwtUtil; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 8 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 9 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 13 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 14 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 15 | +import org.springframework.test.context.ActiveProfiles; | |
| 16 | + | |
| 17 | +import java.time.LocalDateTime; | |
| 18 | +import java.util.Map; | |
| 19 | + | |
| 20 | +import static org.junit.jupiter.api.Assertions.*; | |
| 21 | + | |
| 22 | +@SpringBootTest | |
| 23 | +@ActiveProfiles("test") | |
| 24 | +class LoginServiceImplTest { | |
| 25 | + | |
| 26 | + @Autowired private LoginService loginService; | |
| 27 | + @Autowired private SysUserMapper userMapper; | |
| 28 | + @Autowired private JdbcTemplate jdbc; | |
| 29 | + @Autowired private LoginTestSeeder seeder; | |
| 30 | + @Autowired private JwtUtil jwtUtil; | |
| 31 | + | |
| 32 | + private LoginTestSeeder.Fixture fx; | |
| 33 | + | |
| 34 | + @BeforeEach | |
| 35 | + void setUp() { | |
| 36 | + fx = seeder.reset(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + void contextLoads_loginServiceBean() { | |
| 41 | + assertNotNull(loginService); | |
| 42 | + } | |
| 43 | + | |
| 44 | + // ===== Task 7: company validation ===== | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void login_unknownCompany_throws40004() { | |
| 48 | + BizException e = assertThrows(BizException.class, | |
| 49 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE")); | |
| 50 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 51 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 52 | + assertEquals(0, u.getIFailedLoginCount(), "公司校验失败不应累加用户失败次数"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void login_softDeletedCompany_throws40004() { | |
| 57 | + BizException e = assertThrows(BizException.class, | |
| 58 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 59 | + LoginTestSeeder.COMPANY_DELETED)); | |
| 60 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // ===== Task 8: bad credentials ===== | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + void login_unknownUser_throws40101_noDbWrite() { | |
| 67 | + BizException e = assertThrows(BizException.class, | |
| 68 | + () -> loginService.login("nobody", "any", LoginTestSeeder.COMPANY_OK)); | |
| 69 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + void login_badPassword_throws40101_andIncrementsFailCount() { | |
| 74 | + BizException e = assertThrows(BizException.class, | |
| 75 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 76 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 77 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 78 | + assertEquals(1, u.getIFailedLoginCount()); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // ===== Task 9: locking ===== | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void login_5thBadPassword_setsLockUntil_andStillReturns40101() { | |
| 85 | + for (int i = 0; i < 4; i++) { | |
| 86 | + assertThrows(BizException.class, | |
| 87 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 88 | + } | |
| 89 | + SysUser before5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 90 | + assertEquals(4, before5.getIFailedLoginCount()); | |
| 91 | + assertNull(before5.getTLockUntil()); | |
| 92 | + | |
| 93 | + BizException e = assertThrows(BizException.class, | |
| 94 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 95 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode(), | |
| 96 | + "第 5 次错误仍返 40101(不暴露阈值)"); | |
| 97 | + | |
| 98 | + SysUser after5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 99 | + assertEquals(5, after5.getIFailedLoginCount()); | |
| 100 | + assertNotNull(after5.getTLockUntil(), "第 5 次错误应设置锁定截止"); | |
| 101 | + assertTrue(after5.getTLockUntil().isAfter(LocalDateTime.now().plusMinutes(29)), | |
| 102 | + "锁定时长应 ~30 分钟"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Test | |
| 106 | + void login_duringLockWindow_throws42301_noCountIncrement() { | |
| 107 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", | |
| 108 | + LoginTestSeeder.USER_OK); | |
| 109 | + | |
| 110 | + BizException e = assertThrows(BizException.class, | |
| 111 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 112 | + LoginTestSeeder.COMPANY_OK)); | |
| 113 | + assertEquals(ErrorCode.ACCOUNT_LOCKED, e.getCode()); | |
| 114 | + | |
| 115 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 116 | + assertEquals(5, u.getIFailedLoginCount(), "锁定期间任何登录尝试不应改变计数"); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + void login_afterLockExpired_allowsNewAttempt() { | |
| 121 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_SUB(NOW(), INTERVAL 1 MINUTE) WHERE sUsername=?", | |
| 122 | + LoginTestSeeder.USER_OK); | |
| 123 | + | |
| 124 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 125 | + LoginTestSeeder.COMPANY_OK); | |
| 126 | + assertNotNull(vo.getAccessToken()); | |
| 127 | + | |
| 128 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 129 | + assertEquals(0, u.getIFailedLoginCount(), "锁定过期 + 成功登录应清零"); | |
| 130 | + assertNull(u.getTLockUntil(), "成功登录应清空 tLockUntil"); | |
| 131 | + } | |
| 132 | + | |
| 133 | + // ===== Task 10: deleted + success ===== | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + void login_deletedUser_throws40103_noCountIncrement() { | |
| 137 | + BizException e = assertThrows(BizException.class, | |
| 138 | + () -> loginService.login(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 139 | + LoginTestSeeder.COMPANY_OK)); | |
| 140 | + assertEquals(ErrorCode.ACCOUNT_DELETED, e.getCode()); | |
| 141 | + | |
| 142 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_DELETED); | |
| 143 | + assertEquals(0, u.getIFailedLoginCount()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + void login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin() { | |
| 148 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=2 WHERE sUsername=?", LoginTestSeeder.USER_OK); | |
| 149 | + | |
| 150 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 151 | + LoginTestSeeder.COMPANY_OK); | |
| 152 | + | |
| 153 | + assertNotNull(vo); | |
| 154 | + assertNotNull(vo.getAccessToken()); | |
| 155 | + assertEquals("Bearer", vo.getTokenType()); | |
| 156 | + assertEquals(7200L, vo.getExpiresInSec()); | |
| 157 | + assertNotNull(vo.getUserInfo()); | |
| 158 | + assertEquals(LoginTestSeeder.USER_OK, vo.getUserInfo().getUsername()); | |
| 159 | + assertEquals("NORMAL", vo.getUserInfo().getUserType()); | |
| 160 | + assertEquals("zh-CN", vo.getUserInfo().getLanguage()); | |
| 161 | + assertEquals(LoginTestSeeder.COMPANY_OK, vo.getUserInfo().getCompanyCode()); | |
| 162 | + assertEquals("张三", vo.getUserInfo().getEmployeeName()); | |
| 163 | + | |
| 164 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 165 | + assertEquals(0, u.getIFailedLoginCount()); | |
| 166 | + assertNull(u.getTLockUntil()); | |
| 167 | + assertNotNull(u.getTLastLoginDate()); | |
| 168 | + } | |
| 169 | + | |
| 170 | + @Test | |
| 171 | + void login_concurrentBadPassword_atomicallyIncrementsCount() throws Exception { | |
| 172 | + // 2 线程并发各跑 2 次错误密码 → 计数累加必须 == 4(低于 5 不触发锁定, | |
| 173 | + // 专注验证原子性。锁定路径在 login_5thBadPassword_* 单线程测试中验证) | |
| 174 | + int perThread = 2; | |
| 175 | + int threadCount = 2; | |
| 176 | + Thread t1 = new Thread(() -> { | |
| 177 | + for (int i = 0; i < perThread; i++) { | |
| 178 | + try { | |
| 179 | + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK); | |
| 180 | + } catch (BizException ignored) {} | |
| 181 | + } | |
| 182 | + }); | |
| 183 | + Thread t2 = new Thread(() -> { | |
| 184 | + for (int i = 0; i < perThread; i++) { | |
| 185 | + try { | |
| 186 | + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK); | |
| 187 | + } catch (BizException ignored) {} | |
| 188 | + } | |
| 189 | + }); | |
| 190 | + t1.start(); t2.start(); | |
| 191 | + t1.join(); t2.join(); | |
| 192 | + | |
| 193 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 194 | + assertEquals(perThread * threadCount, u.getIFailedLoginCount(), | |
| 195 | + "并发失败累加必须 == 总次数(原子 UPDATE 保证;非原子实现会丢失累加)"); | |
| 196 | + assertNull(u.getTLockUntil(), "总次数低于阈值不应触发锁定"); | |
| 197 | + } | |
| 198 | + | |
| 199 | + @Test | |
| 200 | + void login_success_jwtParsesBack_with_sub_username_companyCode() { | |
| 201 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 202 | + LoginTestSeeder.COMPANY_OK); | |
| 203 | + Map<String, Object> claims = jwtUtil.parse(vo.getAccessToken()); | |
| 204 | + assertEquals(String.valueOf(fx.aliceId()), claims.get("sub")); | |
| 205 | + assertEquals(LoginTestSeeder.USER_OK, claims.get("username")); | |
| 206 | + assertEquals(LoginTestSeeder.COMPANY_OK, claims.get("companyCode")); | |
| 207 | + assertEquals("NORMAL", claims.get("userType")); | |
| 208 | + assertEquals("zh-CN", claims.get("language")); | |
| 209 | + assertNotNull(claims.get("jti")); | |
| 210 | + long iat = ((Number) claims.get("iat")).longValue(); | |
| 211 | + long exp = ((Number) claims.get("exp")).longValue(); | |
| 212 | + assertEquals(7200L, exp - iat, "exp - iat 必须 == TOKEN_TTL_SEC"); | |
| 213 | + } | |
| 214 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 8 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 11 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 12 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | |
| 13 | +import org.junit.jupiter.api.BeforeEach; | |
| 14 | +import org.junit.jupiter.api.Test; | |
| 15 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 16 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 17 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 18 | +import org.springframework.test.context.ActiveProfiles; | |
| 19 | + | |
| 20 | +import java.util.List; | |
| 21 | + | |
| 22 | +import static org.junit.jupiter.api.Assertions.*; | |
| 23 | + | |
| 24 | +@SpringBootTest | |
| 25 | +@ActiveProfiles("test") | |
| 26 | +class UserCreateServiceImplTest { | |
| 27 | + | |
| 28 | + @Autowired private UserCreateService service; | |
| 29 | + @Autowired private SysUserMapper userMapper; | |
| 30 | + @Autowired private SysUserPermissionCategoryMapper upcMapper; | |
| 31 | + @Autowired private BCryptPasswordEncoder encoder; | |
| 32 | + @Autowired private LoginTestSeeder seeder; | |
| 33 | + | |
| 34 | + private LoginTestSeeder.Fixture fx; | |
| 35 | + | |
| 36 | + @BeforeEach | |
| 37 | + void setUp() { | |
| 38 | + fx = seeder.reset(); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private CreateUserReq buildReq(String username, String userCode) { | |
| 42 | + CreateUserReq r = new CreateUserReq(); | |
| 43 | + r.setUsername(username); | |
| 44 | + r.setUserCode(userCode); | |
| 45 | + r.setUserType("NORMAL"); | |
| 46 | + r.setLanguage("zh-CN"); | |
| 47 | + r.setCanEditDocument(false); | |
| 48 | + return r; | |
| 49 | + } | |
| 50 | + | |
| 51 | + // ===== 唯一性 / 外键校验(Task 7) ===== | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + void create_usernameExists_throws40901() { | |
| 55 | + CreateUserReq r = buildReq(LoginTestSeeder.USER_OK, "U999"); | |
| 56 | + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin")); | |
| 57 | + assertEquals(ErrorCode.CONFLICT_USERNAME, e.getCode()); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Test | |
| 61 | + void create_userCodeExists_throws40902() { | |
| 62 | + CreateUserReq r = buildReq("brandnew", "U001"); | |
| 63 | + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin")); | |
| 64 | + assertEquals(ErrorCode.CONFLICT_USERCODE, e.getCode()); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void create_employeeIdNotFound_throws40004() { | |
| 69 | + CreateUserReq r = buildReq("brandnew", "U999"); | |
| 70 | + r.setEmployeeId(99999); | |
| 71 | + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin")); | |
| 72 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Test | |
| 76 | + void create_permissionCategoryNotFound_throws40004() { | |
| 77 | + CreateUserReq r = buildReq("brandnew", "U999"); | |
| 78 | + List<Integer> bad = new java.util.ArrayList<>(fx.activePermissionCategoryIds()); | |
| 79 | + bad.add(99999); | |
| 80 | + r.setPermissionCategoryIds(bad); | |
| 81 | + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin")); | |
| 82 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + void create_permissionCategorySoftDeleted_throws40004() { | |
| 87 | + CreateUserReq r = buildReq("brandnew", "U999"); | |
| 88 | + r.setPermissionCategoryIds(List.of(fx.deletedPermissionCategoryId())); | |
| 89 | + BizException e = assertThrows(BizException.class, () -> service.create(r, "admin")); | |
| 90 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 91 | + } | |
| 92 | + | |
| 93 | + // ===== 写入路径(Task 8) ===== | |
| 94 | + | |
| 95 | + @Test | |
| 96 | + void create_minimalFields_persistsUserWithInitialPassword() { | |
| 97 | + CreateUserReq r = buildReq("newbie", "U010"); | |
| 98 | + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN); | |
| 99 | + | |
| 100 | + assertNotNull(vo.getUserId()); | |
| 101 | + assertEquals("newbie", vo.getUsername()); | |
| 102 | + assertEquals("U010", vo.getUserCode()); | |
| 103 | + | |
| 104 | + SysUser db = userMapper.selectByUsername("newbie"); | |
| 105 | + assertNotNull(db); | |
| 106 | + assertEquals("NORMAL", db.getSUserType()); | |
| 107 | + assertEquals("zh-CN", db.getSLanguage()); | |
| 108 | + assertEquals(0, db.getICanEditDocument()); | |
| 109 | + assertEquals(0, db.getIIsDeleted()); | |
| 110 | + assertEquals(0, db.getIFailedLoginCount()); | |
| 111 | + assertTrue(encoder.matches("666666", db.getSPasswordHash()), | |
| 112 | + "初始密码必须 BCrypt 哈希为 666666"); | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Test | |
| 116 | + void create_fullFields_persistsUserAndPermissionMappings() { | |
| 117 | + CreateUserReq r = buildReq("manager", "U020"); | |
| 118 | + r.setUserType("SUPER_ADMIN"); | |
| 119 | + r.setLanguage("en-US"); | |
| 120 | + r.setCanEditDocument(true); | |
| 121 | + r.setEmployeeId(fx.employeeId()); | |
| 122 | + r.setPermissionCategoryIds(fx.activePermissionCategoryIds()); | |
| 123 | + | |
| 124 | + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN); | |
| 125 | + | |
| 126 | + SysUser db = userMapper.selectByUsername("manager"); | |
| 127 | + assertNotNull(db); | |
| 128 | + assertEquals("SUPER_ADMIN", db.getSUserType()); | |
| 129 | + assertEquals("en-US", db.getSLanguage()); | |
| 130 | + assertEquals(1, db.getICanEditDocument()); | |
| 131 | + assertEquals(fx.employeeId(), db.getIEmployeeId()); | |
| 132 | + | |
| 133 | + List<SysUserPermissionCategory> links = upcMapper.selectList( | |
| 134 | + new LambdaQueryWrapper<SysUserPermissionCategory>() | |
| 135 | + .eq(SysUserPermissionCategory::getIUserId, vo.getUserId())); | |
| 136 | + assertEquals(fx.activePermissionCategoryIds().size(), links.size()); | |
| 137 | + for (SysUserPermissionCategory link : links) { | |
| 138 | + assertEquals(LoginTestSeeder.USER_ADMIN, link.getSGrantedBy()); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + @Test | |
| 143 | + void create_emptyPermissionCategories_persistsUserOnly() { | |
| 144 | + CreateUserReq r = buildReq("solo", "U030"); | |
| 145 | + r.setPermissionCategoryIds(List.of()); | |
| 146 | + CreateUserVo vo = service.create(r, LoginTestSeeder.USER_ADMIN); | |
| 147 | + | |
| 148 | + SysUser db = userMapper.selectByUsername("solo"); | |
| 149 | + assertNotNull(db); | |
| 150 | + | |
| 151 | + List<SysUserPermissionCategory> links = upcMapper.selectList( | |
| 152 | + new LambdaQueryWrapper<SysUserPermissionCategory>() | |
| 153 | + .eq(SysUserPermissionCategory::getIUserId, vo.getUserId())); | |
| 154 | + assertTrue(links.isEmpty()); | |
| 155 | + } | |
| 156 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 6 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 7 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 8 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 9 | +import org.junit.jupiter.api.BeforeEach; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 12 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 13 | +import org.springframework.test.context.ActiveProfiles; | |
| 14 | + | |
| 15 | +import static org.junit.jupiter.api.Assertions.*; | |
| 16 | + | |
| 17 | +@SpringBootTest | |
| 18 | +@ActiveProfiles("test") | |
| 19 | +class UserDetailServiceImplTest { | |
| 20 | + | |
| 21 | + @Autowired private UserDetailService service; | |
| 22 | + @Autowired private LoginTestSeeder seeder; | |
| 23 | + @Autowired private SysUserPermissionCategoryMapper upcMapper; | |
| 24 | + | |
| 25 | + private LoginTestSeeder.Fixture fx; | |
| 26 | + | |
| 27 | + @BeforeEach | |
| 28 | + void setUp() { | |
| 29 | + fx = seeder.reset(); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + void getById_existingActiveUser_returnsFullVo() { | |
| 34 | + // 给 alice 加 2 个权限分类授权 | |
| 35 | + for (Integer pcId : fx.activePermissionCategoryIds()) { | |
| 36 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 37 | + link.setIUserId(fx.aliceId()); | |
| 38 | + link.setIPermissionCategoryId(pcId); | |
| 39 | + link.setSGrantedBy("system"); | |
| 40 | + upcMapper.insert(link); | |
| 41 | + } | |
| 42 | + | |
| 43 | + UserDetailVo vo = service.getById(fx.aliceId()); | |
| 44 | + assertEquals(fx.aliceId(), vo.getUserId()); | |
| 45 | + assertEquals(LoginTestSeeder.USER_OK, vo.getUsername()); | |
| 46 | + assertEquals("U001", vo.getUserCode()); | |
| 47 | + assertEquals("NORMAL", vo.getUserType()); | |
| 48 | + assertEquals("zh-CN", vo.getLanguage()); | |
| 49 | + assertEquals(false, vo.getCanEditDocument()); | |
| 50 | + assertEquals(false, vo.getIsDeleted()); | |
| 51 | + assertEquals(fx.employeeId(), vo.getEmployeeId()); | |
| 52 | + assertEquals("张三", vo.getEmployeeName()); | |
| 53 | + assertEquals(2, vo.getPermissionCategoryIds().size()); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + void getById_userWithoutEmployee_employeeNameIsNull() { | |
| 58 | + UserDetailVo vo = service.getById(fx.adminId()); | |
| 59 | + assertNull(vo.getEmployeeId()); | |
| 60 | + assertNull(vo.getEmployeeName()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void getById_userWithoutPermissions_emptyList() { | |
| 65 | + UserDetailVo vo = service.getById(fx.aliceId()); | |
| 66 | + assertNotNull(vo.getPermissionCategoryIds()); | |
| 67 | + assertTrue(vo.getPermissionCategoryIds().isEmpty()); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + void getById_deletedUser_stillReturned() { | |
| 72 | + UserDetailVo vo = service.getById(fx.bobDeletedId()); | |
| 73 | + assertEquals(LoginTestSeeder.USER_DELETED, vo.getUsername()); | |
| 74 | + assertEquals(true, vo.getIsDeleted()); | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + void getById_unknownId_throws40401() { | |
| 79 | + BizException e = assertThrows(BizException.class, () -> service.getById(99999)); | |
| 80 | + assertEquals(ErrorCode.USER_NOT_FOUND, e.getCode()); | |
| 81 | + } | |
| 82 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.response.PageResult; | |
| 6 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 7 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 8 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 9 | +import org.junit.jupiter.api.BeforeEach; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 12 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 13 | +import org.springframework.test.context.ActiveProfiles; | |
| 14 | + | |
| 15 | +import static org.junit.jupiter.api.Assertions.*; | |
| 16 | + | |
| 17 | +@SpringBootTest | |
| 18 | +@ActiveProfiles("test") | |
| 19 | +class UserListServiceImplTest { | |
| 20 | + | |
| 21 | + @Autowired private UserListService service; | |
| 22 | + @Autowired private LoginTestSeeder seeder; | |
| 23 | + | |
| 24 | + @BeforeEach | |
| 25 | + void setUp() { | |
| 26 | + seeder.reset(); | |
| 27 | + } | |
| 28 | + | |
| 29 | + private UserQueryReq req() { | |
| 30 | + return new UserQueryReq(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + void list_default_returnsAllUsers() { | |
| 35 | + PageResult<UserListItemVo> result = service.list(req()); | |
| 36 | + assertEquals(3, result.getTotal()); | |
| 37 | + assertEquals(3, result.getRecords().size()); | |
| 38 | + assertEquals(1, result.getPage()); | |
| 39 | + assertEquals(20, result.getSize()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + void list_sortByUsernameAsc() { | |
| 44 | + UserQueryReq r = req(); | |
| 45 | + r.setSortField("sUsername"); | |
| 46 | + r.setSortOrder("asc"); | |
| 47 | + PageResult<UserListItemVo> result = service.list(r); | |
| 48 | + assertEquals(LoginTestSeeder.USER_ADMIN, result.getRecords().get(0).getUsername()); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test | |
| 52 | + void list_sortFieldInvalid_throws40003() { | |
| 53 | + UserQueryReq r = req(); | |
| 54 | + r.setSortField("badField"); | |
| 55 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 56 | + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + void list_sortOrderInvalid_throws40001() { | |
| 61 | + UserQueryReq r = req(); | |
| 62 | + r.setSortOrder("foo"); | |
| 63 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 64 | + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void list_queryFieldInvalid_throws40003() { | |
| 69 | + UserQueryReq r = req(); | |
| 70 | + r.setQueryField("badField"); | |
| 71 | + r.setQueryValue("x"); | |
| 72 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 73 | + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + void list_matchModeInvalid_throws40003() { | |
| 78 | + UserQueryReq r = req(); | |
| 79 | + r.setMatchMode("startsWith"); | |
| 80 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 81 | + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + void list_queryByUsernameContains() { | |
| 86 | + UserQueryReq r = req(); | |
| 87 | + r.setQueryField("username"); | |
| 88 | + r.setMatchMode("contains"); | |
| 89 | + r.setQueryValue("ali"); | |
| 90 | + PageResult<UserListItemVo> result = service.list(r); | |
| 91 | + assertEquals(1, result.getTotal()); | |
| 92 | + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername()); | |
| 93 | + } | |
| 94 | + | |
| 95 | + @Test | |
| 96 | + void list_queryByEmployeeName_joinsCorrectly() { | |
| 97 | + UserQueryReq r = req(); | |
| 98 | + r.setQueryField("employeeName"); | |
| 99 | + r.setMatchMode("contains"); | |
| 100 | + r.setQueryValue("张"); | |
| 101 | + PageResult<UserListItemVo> result = service.list(r); | |
| 102 | + assertEquals(1, result.getTotal()); | |
| 103 | + assertEquals("张三", result.getRecords().get(0).getEmployeeName()); | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Test | |
| 107 | + void list_queryByDepartmentName_multiLevelJoin() { | |
| 108 | + UserQueryReq r = req(); | |
| 109 | + r.setQueryField("departmentName"); | |
| 110 | + r.setMatchMode("equals"); | |
| 111 | + r.setQueryValue("技术部"); | |
| 112 | + PageResult<UserListItemVo> result = service.list(r); | |
| 113 | + assertEquals(1, result.getTotal()); | |
| 114 | + assertEquals("技术部", result.getRecords().get(0).getDepartmentName()); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void list_queryByIsDeleted_true_returnsDeleted() { | |
| 119 | + UserQueryReq r = req(); | |
| 120 | + r.setQueryField("isDeleted"); | |
| 121 | + r.setMatchMode("equals"); | |
| 122 | + r.setQueryValue("true"); | |
| 123 | + PageResult<UserListItemVo> result = service.list(r); | |
| 124 | + assertEquals(1, result.getTotal()); | |
| 125 | + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername()); | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + void list_queryByIsDeleted_invalidValue_throws40001() { | |
| 130 | + UserQueryReq r = req(); | |
| 131 | + r.setQueryField("isDeleted"); | |
| 132 | + r.setQueryValue("maybe"); | |
| 133 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 134 | + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + void list_queryFieldWithoutValue_skipsCondition() { | |
| 139 | + UserQueryReq r = req(); | |
| 140 | + r.setQueryField("username"); | |
| 141 | + // no queryValue | |
| 142 | + PageResult<UserListItemVo> result = service.list(r); | |
| 143 | + assertEquals(3, result.getTotal()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + void list_explicitUserTypeFilter() { | |
| 148 | + UserQueryReq r = req(); | |
| 149 | + r.setUserType("NORMAL"); | |
| 150 | + PageResult<UserListItemVo> result = service.list(r); | |
| 151 | + // alice (NORMAL active) + bob_deleted (NORMAL deleted) = 2 | |
| 152 | + assertEquals(2, result.getTotal()); | |
| 153 | + } | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + void list_explicitUserTypeInvalid_throws40001() { | |
| 157 | + UserQueryReq r = req(); | |
| 158 | + r.setUserType("HACKER"); | |
| 159 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 160 | + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Test | |
| 164 | + void list_explicitIsDeletedFalse_filtersActive() { | |
| 165 | + UserQueryReq r = req(); | |
| 166 | + r.setIsDeleted(false); | |
| 167 | + PageResult<UserListItemVo> result = service.list(r); | |
| 168 | + // alice + admin | |
| 169 | + assertEquals(2, result.getTotal()); | |
| 170 | + } | |
| 171 | + | |
| 172 | + @Test | |
| 173 | + void list_composedFilters_andSemantics() { | |
| 174 | + UserQueryReq r = req(); | |
| 175 | + r.setQueryField("username"); | |
| 176 | + r.setQueryValue("a"); | |
| 177 | + r.setUserType("NORMAL"); | |
| 178 | + r.setIsDeleted(false); | |
| 179 | + PageResult<UserListItemVo> result = service.list(r); | |
| 180 | + // alice 是唯一 NORMAL + 启用 + 名字含 a 的 | |
| 181 | + assertEquals(1, result.getTotal()); | |
| 182 | + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername()); | |
| 183 | + } | |
| 184 | + | |
| 185 | + @Test | |
| 186 | + void list_pageBeyondTotal_returnsLastPage() { | |
| 187 | + UserQueryReq r = req(); | |
| 188 | + r.setPage(999); | |
| 189 | + r.setSize(10); | |
| 190 | + PageResult<UserListItemVo> result = service.list(r); | |
| 191 | + assertEquals(3, result.getTotal()); | |
| 192 | + assertFalse(result.getRecords().isEmpty(), "越界应返回最后一页数据"); | |
| 193 | + assertEquals(1, result.getPage(), "actualPage 应矫正为最后一页 (3/10 → 1)"); | |
| 194 | + } | |
| 195 | + | |
| 196 | + @Test | |
| 197 | + void list_queryByIsDeleted_matchModeContains_isForcedToEquals() { | |
| 198 | + // spec § 业务规则 3:isDeleted matchMode 强制 equals | |
| 199 | + UserQueryReq r = req(); | |
| 200 | + r.setQueryField("isDeleted"); | |
| 201 | + r.setMatchMode("contains"); | |
| 202 | + r.setQueryValue("true"); | |
| 203 | + PageResult<UserListItemVo> result = service.list(r); | |
| 204 | + // 应该等同于 equals true → 仅 bob_deleted | |
| 205 | + assertEquals(1, result.getTotal()); | |
| 206 | + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername()); | |
| 207 | + } | |
| 208 | + | |
| 209 | + @Test | |
| 210 | + void list_queryByLastLoginDate_matchModeContains_isForcedToEquals_andDateNormalized() { | |
| 211 | + // 给 alice 写一个明确的 lastLoginDate | |
| 212 | + jdbc.update("UPDATE sys_user SET tLastLoginDate='2026-05-15 10:00:00' WHERE sUsername=?", | |
| 213 | + LoginTestSeeder.USER_OK); | |
| 214 | + | |
| 215 | + UserQueryReq r = req(); | |
| 216 | + r.setQueryField("lastLoginDate"); | |
| 217 | + r.setMatchMode("contains"); | |
| 218 | + r.setQueryValue("2026-05-15 10:00:00"); | |
| 219 | + PageResult<UserListItemVo> result = service.list(r); | |
| 220 | + assertEquals(1, result.getTotal()); | |
| 221 | + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername()); | |
| 222 | + } | |
| 223 | + | |
| 224 | + @Test | |
| 225 | + void list_queryByLastLoginDate_invalidValue_throws40001() { | |
| 226 | + UserQueryReq r = req(); | |
| 227 | + r.setQueryField("lastLoginDate"); | |
| 228 | + r.setQueryValue("not-a-date"); | |
| 229 | + BizException e = assertThrows(BizException.class, () -> service.list(r)); | |
| 230 | + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); | |
| 231 | + } | |
| 232 | + | |
| 233 | + @org.springframework.beans.factory.annotation.Autowired | |
| 234 | + private org.springframework.jdbc.core.JdbcTemplate jdbc; | |
| 235 | + | |
| 236 | + @Test | |
| 237 | + void list_responseDoesNotIncludePasswordField() { | |
| 238 | + PageResult<UserListItemVo> result = service.list(req()); | |
| 239 | + UserListItemVo vo = result.getRecords().get(0); | |
| 240 | + // UserListItemVo 不应有 password 相关字段——通过反射验证 | |
| 241 | + for (java.lang.reflect.Field f : vo.getClass().getDeclaredFields()) { | |
| 242 | + assertFalse(f.getName().toLowerCase().contains("password"), | |
| 243 | + "VO 字段不应含 password: " + f.getName()); | |
| 244 | + } | |
| 245 | + } | |
| 246 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 8 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 11 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 12 | +import org.junit.jupiter.api.BeforeEach; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 15 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 16 | +import org.springframework.test.context.ActiveProfiles; | |
| 17 | + | |
| 18 | +import java.util.ArrayList; | |
| 19 | +import java.util.HashSet; | |
| 20 | +import java.util.List; | |
| 21 | +import java.util.Set; | |
| 22 | + | |
| 23 | +import static org.junit.jupiter.api.Assertions.*; | |
| 24 | + | |
| 25 | +@SpringBootTest | |
| 26 | +@ActiveProfiles("test") | |
| 27 | +class UserUpdateServiceImplTest { | |
| 28 | + | |
| 29 | + @Autowired private UserUpdateService service; | |
| 30 | + @Autowired private SysUserMapper userMapper; | |
| 31 | + @Autowired private SysUserPermissionCategoryMapper upcMapper; | |
| 32 | + @Autowired private LoginTestSeeder seeder; | |
| 33 | + | |
| 34 | + private LoginTestSeeder.Fixture fx; | |
| 35 | + | |
| 36 | + @BeforeEach | |
| 37 | + void setUp() { | |
| 38 | + fx = seeder.reset(); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private UpdateUserReq req() { | |
| 42 | + return new UpdateUserReq(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + // ===== 校验路径(Task 6) ===== | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + void update_unknownUserId_throws40401() { | |
| 49 | + BizException e = assertThrows(BizException.class, | |
| 50 | + () -> service.update(99999, req(), fx.adminId(), LoginTestSeeder.USER_ADMIN)); | |
| 51 | + assertEquals(ErrorCode.USER_NOT_FOUND, e.getCode()); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + void update_selfDeactivate_throws40302() { | |
| 56 | + UpdateUserReq r = req(); | |
| 57 | + r.setIsDeleted(true); | |
| 58 | + BizException e = assertThrows(BizException.class, | |
| 59 | + () -> service.update(fx.adminId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); | |
| 60 | + assertEquals(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE, e.getCode()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void update_userCodeConflict_throws40902() { | |
| 65 | + UpdateUserReq r = req(); | |
| 66 | + r.setUserCode("U001"); // alice's code | |
| 67 | + BizException e = assertThrows(BizException.class, | |
| 68 | + () -> service.update(fx.adminId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); | |
| 69 | + assertEquals(ErrorCode.CONFLICT_USERCODE, e.getCode()); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + void update_employeeIdNotFound_throws40004() { | |
| 74 | + UpdateUserReq r = req(); | |
| 75 | + r.setEmployeeId(99999); | |
| 76 | + BizException e = assertThrows(BizException.class, | |
| 77 | + () -> service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); | |
| 78 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + void update_permissionCategoryNotFound_throws40004_rollsBack() { | |
| 83 | + UpdateUserReq r = req(); | |
| 84 | + r.setUserCode("U_NEW"); | |
| 85 | + r.setPermissionCategoryIds(List.of(99999)); | |
| 86 | + BizException e = assertThrows(BizException.class, | |
| 87 | + () -> service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); | |
| 88 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 89 | + // 回滚:sys_user.sUserCode 不应被改 | |
| 90 | + SysUser after = userMapper.selectById(fx.aliceId()); | |
| 91 | + assertEquals("U001", after.getSUserCode()); | |
| 92 | + } | |
| 93 | + | |
| 94 | + // ===== 字段写入(Task 7) ===== | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + void update_userCode_only_persisted_otherFieldsUnchanged() { | |
| 98 | + UpdateUserReq r = req(); | |
| 99 | + r.setUserCode("U_NEW"); | |
| 100 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 101 | + | |
| 102 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 103 | + assertEquals("U_NEW", db.getSUserCode()); | |
| 104 | + assertEquals(LoginTestSeeder.USER_OK, db.getSUsername(), "用户名不变"); | |
| 105 | + assertEquals(LoginTestSeeder.USER_ADMIN, db.getSUpdatedBy()); | |
| 106 | + assertNotNull(db.getTUpdatedDate()); | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Test | |
| 110 | + void update_userType_language_canEditDocument() { | |
| 111 | + UpdateUserReq r = req(); | |
| 112 | + r.setUserType("SUPER_ADMIN"); | |
| 113 | + r.setLanguage("en-US"); | |
| 114 | + r.setCanEditDocument(true); | |
| 115 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 116 | + | |
| 117 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 118 | + assertEquals("SUPER_ADMIN", db.getSUserType()); | |
| 119 | + assertEquals("en-US", db.getSLanguage()); | |
| 120 | + assertEquals(1, db.getICanEditDocument()); | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + void update_employeeId_positiveInteger_setsToValue() { | |
| 125 | + UpdateUserReq r = req(); | |
| 126 | + r.setEmployeeId(fx.employeeId()); | |
| 127 | + service.update(fx.adminId(), r, fx.adminId() + 1000, LoginTestSeeder.USER_ADMIN); | |
| 128 | + // operatorUserId 不等于 adminId 才能避开自我停用守卫(本测试未触发,但保持参数有效) | |
| 129 | + SysUser db = userMapper.selectById(fx.adminId()); | |
| 130 | + assertEquals(fx.employeeId(), db.getIEmployeeId()); | |
| 131 | + } | |
| 132 | + | |
| 133 | + @Test | |
| 134 | + void update_employeeId_zero_setsToNull() { | |
| 135 | + UpdateUserReq r = req(); | |
| 136 | + r.setEmployeeId(0); | |
| 137 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 138 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 139 | + assertNull(db.getIEmployeeId()); | |
| 140 | + } | |
| 141 | + | |
| 142 | + @Test | |
| 143 | + void update_employeeId_unchanged_preservesOriginalValue() { | |
| 144 | + UpdateUserReq r = req(); | |
| 145 | + // 不设 employeeId | |
| 146 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 147 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 148 | + assertEquals(fx.employeeId(), db.getIEmployeeId()); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + void update_isDeleted_true_marksUserDeleted() { | |
| 153 | + UpdateUserReq r = req(); | |
| 154 | + r.setIsDeleted(true); | |
| 155 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 156 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 157 | + assertEquals(1, db.getIIsDeleted()); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test | |
| 161 | + void update_isDeleted_false_revivesUser() { | |
| 162 | + UpdateUserReq r = req(); | |
| 163 | + r.setIsDeleted(false); | |
| 164 | + service.update(fx.bobDeletedId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 165 | + SysUser db = userMapper.selectById(fx.bobDeletedId()); | |
| 166 | + assertEquals(0, db.getIIsDeleted()); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + void update_emptyRequest_onlyUpdatesAuditFields() { | |
| 171 | + service.update(fx.aliceId(), req(), fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 172 | + SysUser db = userMapper.selectById(fx.aliceId()); | |
| 173 | + assertEquals(LoginTestSeeder.USER_ADMIN, db.getSUpdatedBy()); | |
| 174 | + assertNotNull(db.getTUpdatedDate()); | |
| 175 | + // 其他字段不变 | |
| 176 | + assertEquals("U001", db.getSUserCode()); | |
| 177 | + assertEquals("NORMAL", db.getSUserType()); | |
| 178 | + } | |
| 179 | + | |
| 180 | + @Test | |
| 181 | + void update_userCodeUnchangedSameAsSelf_returns200() { | |
| 182 | + UpdateUserReq r = req(); | |
| 183 | + r.setUserCode("U001"); // alice 自己当前的 userCode | |
| 184 | + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 185 | + assertEquals("U001", vo.getUserCode()); | |
| 186 | + } | |
| 187 | + | |
| 188 | + // ===== 权限分类增量差集(Task 8) ===== | |
| 189 | + | |
| 190 | + @Test | |
| 191 | + void update_permissionCategories_emptyList_clearsAll() { | |
| 192 | + // 先给 alice 加 2 个 | |
| 193 | + for (Integer pcId : fx.activePermissionCategoryIds()) { | |
| 194 | + SysUserPermissionCategory l = new SysUserPermissionCategory(); | |
| 195 | + l.setIUserId(fx.aliceId()); | |
| 196 | + l.setIPermissionCategoryId(pcId); | |
| 197 | + l.setSGrantedBy("system"); | |
| 198 | + upcMapper.insert(l); | |
| 199 | + } | |
| 200 | + UpdateUserReq r = req(); | |
| 201 | + r.setPermissionCategoryIds(List.of()); | |
| 202 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 203 | + | |
| 204 | + assertTrue(upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId()).isEmpty()); | |
| 205 | + } | |
| 206 | + | |
| 207 | + @org.springframework.beans.factory.annotation.Autowired | |
| 208 | + private com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper permissionCategoryMapper; | |
| 209 | + | |
| 210 | + @Test | |
| 211 | + void update_permissionCategories_subsetDelta_addsAndRemoves() { | |
| 212 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 213 | + Integer sal = fx.activePermissionCategoryIds().get(1); | |
| 214 | + // 初始:alice = {PUR, SAL} | |
| 215 | + for (Integer pcId : List.of(pur, sal)) { | |
| 216 | + SysUserPermissionCategory l = new SysUserPermissionCategory(); | |
| 217 | + l.setIUserId(fx.aliceId()); | |
| 218 | + l.setIPermissionCategoryId(pcId); | |
| 219 | + l.setSGrantedBy("system"); | |
| 220 | + upcMapper.insert(l); | |
| 221 | + } | |
| 222 | + Integer salRowId = upcMapper.selectList( | |
| 223 | + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysUserPermissionCategory>() | |
| 224 | + .eq("iUserId", fx.aliceId()) | |
| 225 | + .eq("iPermissionCategoryId", sal)) | |
| 226 | + .get(0).getIIncrement(); | |
| 227 | + | |
| 228 | + // 新增分类 X(活跃) | |
| 229 | + com.xly.erp.module.usr.entity.SysPermissionCategory x = | |
| 230 | + new com.xly.erp.module.usr.entity.SysPermissionCategory(); | |
| 231 | + x.setSCategoryName("X"); | |
| 232 | + x.setSCategoryCode("X"); | |
| 233 | + x.setISortOrder(99); | |
| 234 | + x.setIIsDeleted(0); | |
| 235 | + permissionCategoryMapper.insert(x); | |
| 236 | + | |
| 237 | + UpdateUserReq r = req(); | |
| 238 | + r.setPermissionCategoryIds(List.of(sal, x.getIIncrement())); | |
| 239 | + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 240 | + | |
| 241 | + // 最终 alice 应有 {SAL, X} | |
| 242 | + Set<Integer> finalSet = new HashSet<>( | |
| 243 | + upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId())); | |
| 244 | + assertEquals(Set.of(sal, x.getIIncrement()), finalSet); | |
| 245 | + | |
| 246 | + // SAL 行 iIncrement 不变(差集而非全量替换) | |
| 247 | + Integer salRowIdAfter = upcMapper.selectList( | |
| 248 | + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysUserPermissionCategory>() | |
| 249 | + .eq("iUserId", fx.aliceId()) | |
| 250 | + .eq("iPermissionCategoryId", sal)) | |
| 251 | + .get(0).getIIncrement(); | |
| 252 | + assertEquals(salRowId, salRowIdAfter, "保留项的 iIncrement 应不变"); | |
| 253 | + } | |
| 254 | + | |
| 255 | + @Test | |
| 256 | + void update_permissionCategories_omitted_preservesExisting() { | |
| 257 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 258 | + SysUserPermissionCategory l = new SysUserPermissionCategory(); | |
| 259 | + l.setIUserId(fx.aliceId()); | |
| 260 | + l.setIPermissionCategoryId(pur); | |
| 261 | + l.setSGrantedBy("system"); | |
| 262 | + upcMapper.insert(l); | |
| 263 | + | |
| 264 | + // 请求不含 permissionCategoryIds | |
| 265 | + UpdateUserReq r = req(); | |
| 266 | + r.setUserCode("U_NEW"); | |
| 267 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 268 | + | |
| 269 | + assertEquals(1, upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId()).size()); | |
| 270 | + } | |
| 271 | + | |
| 272 | + @Test | |
| 273 | + void update_permissionCategories_duplicateInRequest_deduped() { | |
| 274 | + Integer pur = fx.activePermissionCategoryIds().get(0); | |
| 275 | + Integer sal = fx.activePermissionCategoryIds().get(1); | |
| 276 | + UpdateUserReq r = req(); | |
| 277 | + r.setPermissionCategoryIds(new ArrayList<>(List.of(pur, pur, sal))); | |
| 278 | + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 279 | + | |
| 280 | + Set<Integer> finalSet = new HashSet<>( | |
| 281 | + upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId())); | |
| 282 | + assertEquals(Set.of(pur, sal), finalSet); | |
| 283 | + } | |
| 284 | + | |
| 285 | + @Test | |
| 286 | + void update_returnsUserDetailVoReflectingFinalState() { | |
| 287 | + UpdateUserReq r = req(); | |
| 288 | + r.setUserType("SUPER_ADMIN"); | |
| 289 | + r.setLanguage("en-US"); | |
| 290 | + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); | |
| 291 | + assertEquals("SUPER_ADMIN", vo.getUserType()); | |
| 292 | + assertEquals("en-US", vo.getLanguage()); | |
| 293 | + assertEquals(LoginTestSeeder.USER_ADMIN, vo.getUpdatedBy()); | |
| 294 | + assertNotNull(vo.getUpdatedDate()); | |
| 295 | + } | |
| 296 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.support; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 4 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 5 | +import com.xly.erp.module.usr.entity.SysPermissionCategory; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysCompanyMapper; | |
| 8 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 11 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 12 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 13 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 14 | +import org.springframework.stereotype.Component; | |
| 15 | +import org.springframework.transaction.annotation.Transactional; | |
| 16 | + | |
| 17 | +import java.util.List; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 测试数据种子。在每个集成测试 @BeforeEach 调用 reset()。 | |
| 21 | + * | |
| 22 | + * 默认账号: | |
| 23 | + * - alice / Password1! / 启用 / NORMAL / 关联员工"张三" | |
| 24 | + * - admin / Password1! / 启用 / SUPER_ADMIN | |
| 25 | + * - bob_deleted / Password1! / 作废(iIsDeleted=1) | |
| 26 | + * | |
| 27 | + * 默认公司:HQ(启用)/ DEL_CO(软删)。 | |
| 28 | + * 默认权限分类:PUR-采购管理 / SAL-销售管理(启用)/ HR-人事(软删)。 | |
| 29 | + */ | |
| 30 | +@Component | |
| 31 | +public class LoginTestSeeder { | |
| 32 | + | |
| 33 | + public static final String DEFAULT_PASSWORD = "Password1!"; | |
| 34 | + public static final String COMPANY_OK = "HQ"; | |
| 35 | + public static final String COMPANY_DELETED = "DEL_CO"; | |
| 36 | + public static final String USER_OK = "alice"; | |
| 37 | + public static final String USER_ADMIN = "admin"; | |
| 38 | + public static final String USER_DELETED = "bob_deleted"; | |
| 39 | + public static final String PERMISSION_CATEGORY_PUR = "PUR"; | |
| 40 | + public static final String PERMISSION_CATEGORY_SAL = "SAL"; | |
| 41 | + public static final String PERMISSION_CATEGORY_HR_DELETED = "HR"; | |
| 42 | + | |
| 43 | + private final JdbcTemplate jdbc; | |
| 44 | + private final SysCompanyMapper companyMapper; | |
| 45 | + private final SysEmployeeMapper employeeMapper; | |
| 46 | + private final SysUserMapper userMapper; | |
| 47 | + private final SysPermissionCategoryMapper permissionCategoryMapper; | |
| 48 | + private final BCryptPasswordEncoder encoder; | |
| 49 | + | |
| 50 | + @Autowired | |
| 51 | + public LoginTestSeeder(JdbcTemplate jdbc, | |
| 52 | + SysCompanyMapper companyMapper, | |
| 53 | + SysEmployeeMapper employeeMapper, | |
| 54 | + SysUserMapper userMapper, | |
| 55 | + SysPermissionCategoryMapper permissionCategoryMapper, | |
| 56 | + BCryptPasswordEncoder encoder) { | |
| 57 | + this.jdbc = jdbc; | |
| 58 | + this.companyMapper = companyMapper; | |
| 59 | + this.employeeMapper = employeeMapper; | |
| 60 | + this.userMapper = userMapper; | |
| 61 | + this.permissionCategoryMapper = permissionCategoryMapper; | |
| 62 | + this.encoder = encoder; | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Transactional | |
| 66 | + public Fixture reset() { | |
| 67 | + jdbc.update("DELETE FROM sys_user_permission_category"); | |
| 68 | + jdbc.update("DELETE FROM sys_user"); | |
| 69 | + jdbc.update("DELETE FROM sys_permission_category"); | |
| 70 | + jdbc.update("DELETE FROM sys_employee"); | |
| 71 | + jdbc.update("DELETE FROM sys_department"); | |
| 72 | + jdbc.update("DELETE FROM sys_company"); | |
| 73 | + | |
| 74 | + SysCompany hq = new SysCompany(); | |
| 75 | + hq.setSCompanyCode(COMPANY_OK); | |
| 76 | + hq.setSCompanyName("总部"); | |
| 77 | + hq.setIIsDeleted(0); | |
| 78 | + companyMapper.insert(hq); | |
| 79 | + | |
| 80 | + SysCompany del = new SysCompany(); | |
| 81 | + del.setSCompanyCode(COMPANY_DELETED); | |
| 82 | + del.setSCompanyName("已删公司"); | |
| 83 | + del.setIIsDeleted(1); | |
| 84 | + companyMapper.insert(del); | |
| 85 | + | |
| 86 | + jdbc.update("INSERT INTO sys_department (sDepartmentName, sDepartmentCode, iIsDeleted) VALUES (?,?,0)", | |
| 87 | + "技术部", "TECH"); | |
| 88 | + Integer deptId = jdbc.queryForObject( | |
| 89 | + "SELECT iIncrement FROM sys_department WHERE sDepartmentCode='TECH'", | |
| 90 | + Integer.class); | |
| 91 | + | |
| 92 | + SysEmployee emp = new SysEmployee(); | |
| 93 | + emp.setSEmployeeName("张三"); | |
| 94 | + emp.setSEmployeeCode("E001"); | |
| 95 | + emp.setIDepartmentId(deptId); | |
| 96 | + emp.setIIsDeleted(0); | |
| 97 | + employeeMapper.insert(emp); | |
| 98 | + | |
| 99 | + String hash = encoder.encode(DEFAULT_PASSWORD); | |
| 100 | + | |
| 101 | + SysUser alice = new SysUser(); | |
| 102 | + alice.setSUsername(USER_OK); | |
| 103 | + alice.setSUserCode("U001"); | |
| 104 | + alice.setSPasswordHash(hash); | |
| 105 | + alice.setIEmployeeId(emp.getIIncrement()); | |
| 106 | + alice.setSUserType("NORMAL"); | |
| 107 | + alice.setSLanguage("zh-CN"); | |
| 108 | + alice.setICanEditDocument(0); | |
| 109 | + alice.setIIsDeleted(0); | |
| 110 | + alice.setIFailedLoginCount(0); | |
| 111 | + alice.setSCreatedBy("system"); | |
| 112 | + userMapper.insert(alice); | |
| 113 | + | |
| 114 | + SysUser admin = new SysUser(); | |
| 115 | + admin.setSUsername(USER_ADMIN); | |
| 116 | + admin.setSUserCode("U000"); | |
| 117 | + admin.setSPasswordHash(hash); | |
| 118 | + admin.setSUserType("SUPER_ADMIN"); | |
| 119 | + admin.setSLanguage("zh-CN"); | |
| 120 | + admin.setICanEditDocument(1); | |
| 121 | + admin.setIIsDeleted(0); | |
| 122 | + admin.setIFailedLoginCount(0); | |
| 123 | + admin.setSCreatedBy("system"); | |
| 124 | + userMapper.insert(admin); | |
| 125 | + | |
| 126 | + SysUser bob = new SysUser(); | |
| 127 | + bob.setSUsername(USER_DELETED); | |
| 128 | + bob.setSUserCode("U002"); | |
| 129 | + bob.setSPasswordHash(hash); | |
| 130 | + bob.setSUserType("NORMAL"); | |
| 131 | + bob.setSLanguage("zh-CN"); | |
| 132 | + bob.setICanEditDocument(0); | |
| 133 | + bob.setIIsDeleted(1); | |
| 134 | + bob.setIFailedLoginCount(0); | |
| 135 | + bob.setSCreatedBy("system"); | |
| 136 | + userMapper.insert(bob); | |
| 137 | + | |
| 138 | + SysPermissionCategory pur = new SysPermissionCategory(); | |
| 139 | + pur.setSCategoryCode(PERMISSION_CATEGORY_PUR); | |
| 140 | + pur.setSCategoryName("采购管理"); | |
| 141 | + pur.setISortOrder(1); | |
| 142 | + pur.setIIsDeleted(0); | |
| 143 | + permissionCategoryMapper.insert(pur); | |
| 144 | + | |
| 145 | + SysPermissionCategory sal = new SysPermissionCategory(); | |
| 146 | + sal.setSCategoryCode(PERMISSION_CATEGORY_SAL); | |
| 147 | + sal.setSCategoryName("销售管理"); | |
| 148 | + sal.setISortOrder(2); | |
| 149 | + sal.setIIsDeleted(0); | |
| 150 | + permissionCategoryMapper.insert(sal); | |
| 151 | + | |
| 152 | + SysPermissionCategory hrDel = new SysPermissionCategory(); | |
| 153 | + hrDel.setSCategoryCode(PERMISSION_CATEGORY_HR_DELETED); | |
| 154 | + hrDel.setSCategoryName("人事(已删)"); | |
| 155 | + hrDel.setISortOrder(3); | |
| 156 | + hrDel.setIIsDeleted(1); | |
| 157 | + permissionCategoryMapper.insert(hrDel); | |
| 158 | + | |
| 159 | + return new Fixture( | |
| 160 | + alice.getIIncrement(), | |
| 161 | + admin.getIIncrement(), | |
| 162 | + bob.getIIncrement(), | |
| 163 | + emp.getIIncrement(), | |
| 164 | + List.of(pur.getIIncrement(), sal.getIIncrement()), | |
| 165 | + hrDel.getIIncrement()); | |
| 166 | + } | |
| 167 | + | |
| 168 | + public record Fixture( | |
| 169 | + Integer aliceId, | |
| 170 | + Integer adminId, | |
| 171 | + Integer bobDeletedId, | |
| 172 | + Integer employeeId, | |
| 173 | + List<Integer> activePermissionCategoryIds, | |
| 174 | + Integer deletedPermissionCategoryId) {} | |
| 175 | +} | ... | ... |
docs/01-需求清单/USR-用户管理/REQ-USR-001.md
| 1 | 1 | ### REQ-USR-001 用户登录 |
| 2 | 2 | |
| 3 | - | |
| 4 | 3 | **目标**: 用户通过用户名+密码完成身份认证,获取 JWT Token 用于后续接口鉴权 |
| 5 | 4 | |
| 6 | 5 | - **输入**: |
| ... | ... | @@ -11,12 +10,12 @@ |
| 11 | 10 | | --- | ---- | --- | ---- | ------- | ----- | --- | ----------- | |
| 12 | 11 | | 用户名 | 文本 | 是 | 手工输入 | — | — | — | — | |
| 13 | 12 | | 密码 | 文本 | 是 | 手工输入 | — | — | — | 输入显示星号 | |
| 14 | - | 版本 | 文本 | 是 | 下拉单选 | `公司表` | 页面加载时 | 标准版 | | | |
| 13 | + | 版本 | 文本 | 是 | 下拉单选 | `公司表` | 页面加载时 | — | | | |
| 15 | 14 | |
| 16 | 15 | - **输出**: 成功/失败 |
| 17 | 16 | |
| 18 | 17 | - **跨字段规则**: 校验用户名 + 密码哈希;连续失败达到阈值临时锁定账号;登录成功签发限时 token 并返回基本用户信息 |
| 19 | 18 | - **边界**: token 设置合理过期时间;接口需具备防暴力破解保护 |
| 20 | 19 | - **验收**: 正确凭据返回 Token 且可通过鉴权接口验证;错误密码返回通用错误消息(不区分用户名或密码错误);锁定账号返回锁定提示 |
| 21 | -- **依赖表**: `t_user`(认证主体 + 失败计数 + 锁定时间 + 登录时间)/ `t_company`(「版本」下拉数据源) | |
| 22 | -- **依赖接口**: `POST /api/usr/auth/login`(本 REQ 提供);后续配套 `POST /api/usr/auth/refresh`、`POST /api/usr/auth/logout` 由同模块衍生接口(不另立 REQ) | |
| 20 | +- **依赖表**: `sys_user`(读: 校验账号 / 写: 更新 `iFailedLoginCount`、`tLockUntil`、`tLastLoginDate`), `sys_company`(读: 登录页"版本"下拉) | |
| 21 | +- **依赖接口**: `POST /api/v1/auth/login`(本 REQ 提供);`GET /api/v1/companies`(登录页公司下拉,后续运营模块提供) | ... | ... |
docs/01-需求清单/USR-用户管理/REQ-USR-002.md
| ... | ... | @@ -37,5 +37,5 @@ |
| 37 | 37 | - **跨字段规则**: 用户名在系统内全局唯一;角色取值受系统配置约束 |
| 38 | 38 | - **边界**: 密码以哈希形式存储 |
| 39 | 39 | - **验收**: 提交合法数据后用户记录出现在列表;重复用户名返回错误提示;普通账号无权访问此功能 |
| 40 | -- **依赖表**: `t_user`(新建主记录)/ `t_employee`(员工名下拉 + 关联)/ `t_permission`(权限分类下拉)/ `t_user_permission`(写入权限组关联) | |
| 41 | -- **依赖接口**: `POST /api/usr/users`(本 REQ 提供);前置 `POST /api/usr/auth/login`(REQ-USR-001)获取 JWT | |
| 40 | +- **依赖表**: `sys_user`(写: 新增账号), `sys_employee`(读: 关联职员下拉), `sys_permission_category`(读: 权限分类列表), `sys_user_permission_category`(写: 写入勾选授权) | |
| 41 | +- **依赖接口**: `POST /api/v1/users`(本 REQ 提供);下游调用:`GET /api/v1/employees`(员工下拉,后续 HR 模块提供);`GET /api/v1/permission-categories`(权限分类下拉,后续运营模块提供) | ... | ... |
docs/01-需求清单/USR-用户管理/REQ-USR-003.md
| ... | ... | @@ -36,5 +36,5 @@ |
| 36 | 36 | - **跨字段规则**: 密码不在该接口修改;角色变更需具备相应权限 |
| 37 | 37 | - **边界**: 必须传入有效用户 id;字段格式与新增一致 |
| 38 | 38 | - **验收**: 修改角色或状态后立即反映在用户列表;被禁用账号无法登录并收到明确提示 |
| 39 | -- **依赖表**: `t_user`(更新主记录)/ `t_employee`(员工名下拉 + 关联)/ `t_permission`(权限分类下拉)/ `t_user_permission`(删旧 + 写新权限组关联) | |
| 40 | -- **依赖接口**: `PUT /api/usr/users/{iIncrement}`(本 REQ 提供);前置 `POST /api/usr/auth/login`(REQ-USR-001)+ 数据来源 `POST /api/usr/users`(REQ-USR-002) | |
| 39 | +- **依赖表**: `sys_user`(读 + 写: 更新非密码字段), `sys_employee`(读: 关联职员下拉), `sys_permission_category`(读: 权限分类列表), `sys_user_permission_category`(读 + 写: 增量增删授权差集) | |
| 40 | +- **依赖接口**: `PUT /api/v1/users/{userId}`(本 REQ 提供);`GET /api/v1/users/{userId}`(详情回显,可在本 REQ 实现或复用 REQ-USR-004 列表点击进入);下游调用:`GET /api/v1/employees`、`GET /api/v1/permission-categories`(同 REQ-USR-002) | ... | ... |
docs/01-需求清单/USR-用户管理/REQ-USR-004.md
| ... | ... | @@ -33,5 +33,5 @@ |
| 33 | 33 | - **跨字段规则**: - |
| 34 | 34 | - **边界**: 单页最大条数受限(默认 100);密码与敏感字段不返回;查询为只读,不产生写副作用 |
| 35 | 35 | - **验收**: 按条件筛选返回正确结果集;无匹配时返回空列表而非报错;分页参数越界时返回最后一页 |
| 36 | -- **依赖表**: `t_user`(列表主表 + 类型 / 作废 / 登录日期 / 制单人)/ `t_employee`(员工名)/ `t_department`(部门) | |
| 37 | -- **依赖接口**: `GET /api/usr/users`(本 REQ 提供);前置 `POST /api/usr/auth/login`(REQ-USR-001) | |
| 36 | +- **依赖表**: `sys_user`(读: 分页 + 筛选), `sys_employee`(读: JOIN 取员工名), `sys_department`(读: JOIN 经 employee 取部门名) | |
| 37 | +- **依赖接口**: `GET /api/v1/users`(本 REQ 提供) | ... | ... |
docs/01-需求清单/USR-用户管理/_module.md
| 1 | 1 | # USR-用户管理 |
| 2 | 2 | |
| 3 | -- **模块简述**: 提供用户全生命周期管理,含登录认证、新增、修改、查询等业务操作 | |
| 4 | -- **依赖模块**: —(基础模块) | |
| 5 | -- **涉及表**: `t_user` / `t_employee` / `t_department` / `t_permission` / `t_user_permission` / `t_company` | |
| 3 | +- **模块简述**: 用户管理模块负责用户账号的全生命周期管理,包括登录认证、新增 / 修改 / 查询用户、角色分配与账号启停 | |
| 4 | +- **依赖模块**: —(无) | |
| 5 | +- **涉及表**: `sys_user`, `sys_employee`, `sys_department`, `sys_company`, `sys_permission_category`, `sys_user_permission_category` | ... | ... |
docs/02-开发计划.md
| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | |
| 5 | 5 | | 模块 ID | 模块名 | 依赖模块 | 依赖表 | |
| 6 | 6 | |---|---|---|---| |
| 7 | -| module_usr | USR-用户管理 | — | `t_user` / `t_employee` / `t_department` / `t_permission` / `t_user_permission` / `t_company` | | |
| 7 | +| module_usr | 用户管理 | — | `sys_user`, `sys_employee`, `sys_department`, `sys_company`, `sys_permission_category`, `sys_user_permission_category` | | |
| 8 | 8 | |
| 9 | 9 | ## 二、开发顺序清单(CC 分发权威) |
| 10 | 10 | |
| ... | ... | @@ -14,15 +14,16 @@ |
| 14 | 14 | |
| 15 | 15 | | # | REQ | 所属模块 | 选中理由 | 备注 | |
| 16 | 16 | |---|-----|---------|---------|------| |
| 17 | -| 1 | **REQ-USR-001** | module_usr | 所属模块无依赖,认证接口为基础设施(其余 REQ 接口需 JWT 鉴权) | — | | |
| 18 | -| 2 | **REQ-USR-002** | module_usr | 依赖 REQ-USR-001 已在前;新增用户为后续修改 / 查询提供数据 | — | | |
| 19 | -| 3 | **REQ-USR-003** | module_usr | 依赖 REQ-USR-002 已在前(先有用户才能修改) | — | | |
| 20 | -| 4 | **REQ-USR-004** | module_usr | 依赖 REQ-USR-002 已在前(先有用户才能查询) | — | | |
| 17 | +| 1 | **REQ-USR-001** | module_usr | 所属模块无依赖,登录是后续接口鉴权的基础 | — | | |
| 18 | +| 2 | **REQ-USR-002** | module_usr | 同模块,无前置 REQ 依赖;用户创建是 003/004 的数据源 | — | | |
| 19 | +| 3 | **REQ-USR-003** | module_usr | 同模块,逻辑上依赖 REQ-USR-002 已在前(修改基于已存在用户) | — | | |
| 20 | +| 4 | **REQ-USR-004** | module_usr | 同模块,无前置 REQ 依赖;列表查询用 002/003 产生的数据更易自测 | — | | |
| 21 | 21 | |
| 22 | 22 | > **后端模块全部 merged 后**:用户重跑 `/erp-workflow:coding-start` → coding-start 检测到 `backend_done=true && frontend_done=false` → 派发 `frontend-start`。`frontend-start` 步骤 1 自带 prototype/ 门禁(≥ 1 个 `*.html` mockup,缺失则 AskUserQuestion 提示用户补齐)。前端阶段以业务功能(不是 HTML 文件数)为粒度拆分 FE,每个 FE 跑一次 feature 循环(fe-feature-*),最后整个阶段合 1 个 MR(分支 `frontend-phase`,记录在 `docs/08 § 三 整体 MR`)。 |
| 23 | 23 | |
| 24 | 24 | ## 三、关键说明 |
| 25 | 25 | |
| 26 | -- 本期需求只覆盖 USR 用户管理一个模块;后续若新增 PUR / SAL / FIN 等模块,需重新跑 `/erp-workflow:plan-start` 让 A5 重算依赖与顺序。 | |
| 27 | -- REQ-USR-001 排第一是「基础设施先行」策略:JWT 鉴权机制建好后,其余 REQ 才能复用 `@PreAuthorize` 与统一异常处理;初始管理员账号由 V1 后续的种子 migration 注入(或人工 SQL 直插)。 | |
| 28 | -- `t_employee` / `t_department` / `t_permission` / `t_company` 四张维表在 module_usr 内部按需访问,不另起模块;后续如演化出独立的 HR / 组织架构模块,再单独拆分。 | |
| 26 | +- 当前仅有 1 个业务模块 `module_usr`,无跨模块依赖,模块级 DAG 退化为单节点,无环。 | |
| 27 | +- REQ 内部依赖只有 003 → 002 一条边,按数字序天然满足,无需破环。 | |
| 28 | +- `sys_employee` / `sys_department` / `sys_company` / `sys_permission_category` 4 张参考字典已由 V1 建好但本模块**只读使用**,初始化数据由 B 阶段开发时通过 V2/V3 seed migration 或测试 fixture 补全(不在当前 4 个 REQ 范围内)。 | |
| 29 | +- 后端阶段所有任务限定在 `backend/module/usr/` 路径下;前端实现推迟到 `frontend-start` 派发的前端阶段,按 `prototype/*.html` 拆 FE。 | ... | ... |
docs/03-数据库设计文档.md
| ... | ... | @@ -12,7 +12,7 @@ |
| 12 | 12 | |---|---|---|---|---| |
| 13 | 13 | | `iIncrement` | int | 否 | 是 | 整数主键 ID(自增方式由实现决定:DB `AUTO_INCREMENT` 或应用 / 触发器分配) | |
| 14 | 14 | | `sId` | varchar(100) | 是 | — | 业务 ID(对外暴露的字符串标识,如 UUID / 人类可读编号) | |
| 15 | -| `sBrandsId` | varchar(100) | 是 | — | 品牌 ID(多租户隔离) | | |
| 15 | +| `sBrandsId` | varchar(100) | 是 | — | 母公司 ID(多租户隔离) | | |
| 16 | 16 | | `sSubsidiaryId` | varchar(100) | 是 | — | 子公司 ID(组织层级隔离) | |
| 17 | 17 | | `tCreateDate` | datetime | 否 | — | 记录创建时间 | |
| 18 | 18 | |
| ... | ... | @@ -20,24 +20,37 @@ |
| 20 | 20 | |
| 21 | 21 | ## ER 关系概览 |
| 22 | 22 | |
| 23 | -USR 用户管理模块共 6 张表,围绕「用户」为核心: | |
| 23 | +``` | |
| 24 | +sys_department ◄──┐ sys_company(独立,登录页"版本"下拉) | |
| 25 | + │ | |
| 26 | + │ N:1 | |
| 27 | +sys_employee ─────┘ | |
| 28 | + ▲ | |
| 29 | + │ N:1 (可选, ON DELETE SET NULL) | |
| 30 | + │ | |
| 31 | +sys_user ────────► sys_user_permission_category ◄──── sys_permission_category | |
| 32 | + (用户 × 权限分类多对多, ON DELETE CASCADE) | |
| 33 | +``` | |
| 34 | + | |
| 35 | +- `sys_company`:登录页"版本"下拉来源;目前独立存在,不与 user 直接 FK,登录上下文记录公司选择即可。 | |
| 36 | +- `sys_department`:部门字典;被 `sys_employee` 引用。 | |
| 37 | +- `sys_employee`:职员档案;被 `sys_user` 通过 `iEmployeeId` 可选引用(员工先存在,用户后绑定)。 | |
| 38 | +- `sys_user`:用户账号(登录、权限、状态、登录追踪一体)。 | |
| 39 | +- `sys_permission_category`:权限分类字典(USR-002/003 表 2 "权限组"的来源)。 | |
| 40 | +- `sys_user_permission_category`:用户 × 权限分类多对多授权表,记录每个用户被授予哪些权限分类。 | |
| 24 | 41 | |
| 25 | -- `t_user`(用户主表)通过 `iEmployeeId` 关联 `t_employee`(职员),实现「用户号 / 员工名」一对一联动。 | |
| 26 | -- `t_employee`(职员表)通过 `iDepartmentId` 关联 `t_department`(部门表),支撑 REQ-USR-004 按部门筛选。 | |
| 27 | -- `t_user` 与 `t_permission`(权限分类字典)通过 `t_user_permission`(用户-权限关联表)建立多对多关系,实现 REQ-USR-002 / 003 的「权限组」分配。 | |
| 28 | -- `t_company`(公司版本字典)独立于 `t_user`,仅为 REQ-USR-001 登录入参「版本」下拉提供可选项,不与用户产生强引用。 | |
| 42 | +## 表清单 | |
| 29 | 43 | |
| 30 | -依赖方向:`t_user → t_employee → t_department`、`t_user ↔ t_permission`(多对多)、`t_company` 独立。 | |
| 44 | +- `sys_company` — 公司 / 版本字典,登录页下拉选择来源 | |
| 45 | +- `sys_department` — 部门字典,职员归属 | |
| 46 | +- `sys_employee` — 职员档案,员工基础信息 | |
| 47 | +- `sys_user` — 用户账号(登录认证 + 类型 + 语言 + 状态 + 登录追踪) | |
| 48 | +- `sys_permission_category` — 权限分类字典 | |
| 49 | +- `sys_user_permission_category` — 用户 × 权限分类授权关系 | |
| 31 | 50 | |
| 32 | -## 表清单 | |
| 33 | -- `t_user` — 系统用户主表,承载登录认证、用户类型、语言偏好、单据权限等基础属性 | |
| 34 | -- `t_employee` — 公司职员主档,与用户表通过 `iEmployeeId` 关联,提供姓名 / 工号 / 部门归属 | |
| 35 | -- `t_department` — 部门组织树,支撑职员归属与按部门筛选 | |
| 36 | -- `t_permission` — 权限分类字典,定义系统可分配的权限项 | |
| 37 | -- `t_user_permission` — 用户-权限分类多对多关联表 | |
| 38 | -- `t_company` — 公司 / 版本字典(标准版 / 专业版 / 旗舰版),登录时「版本」下拉数据源 | |
| 51 | +--- | |
| 39 | 52 | |
| 40 | -## `t_user` — 系统用户主表,承载登录认证与基础属性 | |
| 53 | +## `sys_company` — 公司 / 版本字典,登录页下拉选择来源 | |
| 41 | 54 | |
| 42 | 55 | ### 字段 |
| 43 | 56 | |
| ... | ... | @@ -48,38 +61,28 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 48 | 61 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 49 | 62 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 50 | 63 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 51 | -| `sUserNo` | varchar(50) | 否 | — | 用户号;关联职员后自动同步员工号;系统内唯一 | | |
| 52 | -| `sUserName` | varchar(50) | 否 | — | 登录用户名;系统内唯一;3-50 位 | | |
| 53 | -| `iEmployeeId` | int | 是 | NULL | 关联职员 `t_employee.iIncrement`;可空(非员工账号如系统管理员) | | |
| 54 | -| `sPasswordHash` | varchar(255) | 否 | — | 密码哈希(BCrypt / Argon2);禁止明文;初始密码 `666666` 哈希后存入 | | |
| 55 | -| `sUserType` | varchar(20) | 否 | `NORMAL` | 用户类型枚举:`NORMAL`(普通用户)/ `SUPER_ADMIN`(超级管理员) | | |
| 56 | -| `sLanguage` | varchar(10) | 否 | `zh-CN` | 语言枚举:`zh-CN`(中文)/ `en-US`(英文)/ `zh-TW`(繁体) | | |
| 57 | -| `bModifyDoc` | tinyint(1) | 否 | 0 | 单据修改权限:0 否 / 1 是 | | |
| 58 | -| `bVoid` | tinyint(1) | 否 | 0 | 作废标记(软删除):0 启用 / 1 已作废 | | |
| 59 | -| `iLoginFailCount` | int | 否 | 0 | 连续登录失败次数;达到阈值触发临时锁定;登录成功后清零 | | |
| 60 | -| `tLockUntil` | datetime | 是 | NULL | 锁定截止时间;NULL 表示未锁定 | | |
| 61 | -| `tLastLoginDate` | datetime | 是 | NULL | 最近一次登录时间 | | |
| 62 | -| `sCreator` | varchar(100) | 是 | NULL | 制单人(创建该账号的操作员用户名) | | |
| 64 | +| `sCompanyName` | varchar(100) | 否 | — | 公司 / 版本名称(登录页下拉显示文本) | | |
| 65 | +| `sCompanyCode` | varchar(50) | 否 | — | 公司编码(前端唯一识别) | | |
| 66 | +| `iSortOrder` | int | 否 | 0 | 下拉列表排序权重,升序 | | |
| 67 | +| `iIsDeleted` | tinyint(1) | 否 | 0 | 软删除标记,0=正常 1=已删 | | |
| 63 | 68 | |
| 64 | 69 | ### 索引 |
| 65 | 70 | |
| 66 | -- `uk_user_username` (UNIQUE): `sUserName` | |
| 67 | -- `uk_user_userno` (UNIQUE): `sUserNo` | |
| 68 | -- `idx_user_employee` (BTREE): `iEmployeeId` | |
| 69 | -- `idx_user_tenant` (BTREE): `sBrandsId`, `sSubsidiaryId` | |
| 70 | -- `idx_user_void` (BTREE): `bVoid` | |
| 71 | +- `pk_sys_company` (PRIMARY): `iIncrement` | |
| 72 | +- `uk_sys_company_code` (UNIQUE): `sCompanyCode` | |
| 73 | +- `idx_sys_company_is_deleted` (BTREE): `iIsDeleted, iSortOrder` | |
| 71 | 74 | |
| 72 | 75 | ### 外键 |
| 73 | 76 | |
| 74 | -- `fk_user_employee`: `iEmployeeId` → `t_employee.iIncrement` (ON DELETE SET NULL / ON UPDATE RESTRICT) | |
| 77 | +(无) | |
| 75 | 78 | |
| 76 | 79 | ### 业务注记 |
| 77 | 80 | |
| 78 | -- 登录认证主体表;密码以哈希形式存储,登录失败计数与锁定时间均落库以保证服务重启不丢;亦可由 Redis 镜像加速,但 DB 字段为权威。 | |
| 79 | -- `bVoid = 1` 视为已作废账号,不可登录、不出现在默认查询结果中(REQ-USR-004 列表默认隐藏作废)。 | |
| 80 | -- 用户名 / 用户号唯一性由 `uk_user_username` / `uk_user_userno` 保证;新增 / 修改时若冲突由 DB 抛唯一约束错误,Service 转译为 `40001 用户名已存在`。 | |
| 81 | +REQ-USR-001 登录页 "版本" 字段下拉来源。当前与 `sys_user` 不直接 FK,登录会话记录用户选择的公司即可(避免单用户跨公司绑定的复杂度)。新增公司由后续运营模块管理;本模块阶段只读使用。 | |
| 82 | + | |
| 83 | +--- | |
| 81 | 84 | |
| 82 | -## `t_employee` — 公司职员主档 | |
| 85 | +## `sys_department` — 部门字典,职员归属 | |
| 83 | 86 | |
| 84 | 87 | ### 字段 |
| 85 | 88 | |
| ... | ... | @@ -90,30 +93,26 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 90 | 93 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 91 | 94 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 92 | 95 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 93 | -| `sEmployeeNo` | varchar(50) | 否 | — | 员工号;系统内唯一 | | |
| 94 | -| `sName` | varchar(100) | 否 | — | 姓名 | | |
| 95 | -| `iDepartmentId` | int | 是 | NULL | 部门 ID,关联 `t_department.iIncrement` | | |
| 96 | -| `sPhone` | varchar(20) | 是 | NULL | 手机号 | | |
| 97 | -| `sEmail` | varchar(100) | 是 | NULL | 邮箱 | | |
| 98 | -| `bDisabled` | tinyint(1) | 否 | 0 | 是否离职:0 在职 / 1 离职 | | |
| 96 | +| `sDepartmentName` | varchar(100) | 否 | — | 部门名称 | | |
| 97 | +| `sDepartmentCode` | varchar(50) | 否 | — | 部门编码 | | |
| 98 | +| `iIsDeleted` | tinyint(1) | 否 | 0 | 软删除标记 | | |
| 99 | 99 | |
| 100 | 100 | ### 索引 |
| 101 | 101 | |
| 102 | -- `uk_employee_no` (UNIQUE): `sEmployeeNo` | |
| 103 | -- `idx_employee_dept` (BTREE): `iDepartmentId` | |
| 104 | -- `idx_employee_name` (BTREE): `sName` | |
| 105 | -- `idx_employee_tenant` (BTREE): `sBrandsId`, `sSubsidiaryId` | |
| 102 | +- `pk_sys_department` (PRIMARY): `iIncrement` | |
| 103 | +- `uk_sys_department_code` (UNIQUE): `sDepartmentCode` | |
| 106 | 104 | |
| 107 | 105 | ### 外键 |
| 108 | 106 | |
| 109 | -- `fk_employee_department`: `iDepartmentId` → `t_department.iIncrement` (ON DELETE SET NULL / ON UPDATE RESTRICT) | |
| 107 | +(无) | |
| 110 | 108 | |
| 111 | 109 | ### 业务注记 |
| 112 | 110 | |
| 113 | -- 用户表通过 `iEmployeeId` 关联本表;REQ-USR-002 / 003 选择员工时下拉数据源;REQ-USR-004 查询字段「员工名 / 部门」均联表本表。 | |
| 114 | -- 离职员工保留记录(`bDisabled = 1`),其关联用户应被自动作废(应用层逻辑,未做 DB 触发器)。 | |
| 111 | +REQ-USR-004 查询输出 "部门" 字段来源(经 `sys_employee` 关联)。本阶段是扁平字典,未来 HR 模块扩展时可加 `iParentId` 形成树状结构。 | |
| 112 | + | |
| 113 | +--- | |
| 115 | 114 | |
| 116 | -## `t_department` — 部门组织树 | |
| 115 | +## `sys_employee` — 职员档案,员工基础信息 | |
| 117 | 116 | |
| 118 | 117 | ### 字段 |
| 119 | 118 | |
| ... | ... | @@ -124,27 +123,31 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 124 | 123 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 125 | 124 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 126 | 125 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 127 | -| `sName` | varchar(100) | 否 | — | 部门名称 | | |
| 128 | -| `sCode` | varchar(50) | 否 | — | 部门编码;系统内唯一 | | |
| 129 | -| `iParentId` | int | 是 | NULL | 上级部门 ID,NULL 表示根部门 | | |
| 130 | -| `iSortOrder` | int | 否 | 0 | 排序值,小者靠前 | | |
| 126 | +| `sEmployeeName` | varchar(50) | 否 | — | 员工姓名(2-50 字符) | | |
| 127 | +| `sEmployeeCode` | varchar(50) | 否 | — | 员工工号(系统内唯一) | | |
| 128 | +| `iDepartmentId` | int | 否 | — | 所属部门 ID(FK → sys_department.iIncrement) | | |
| 129 | +| `sPhone` | varchar(20) | 是 | NULL | 手机号 | | |
| 130 | +| `sEmail` | varchar(100) | 是 | NULL | 邮箱 | | |
| 131 | +| `iIsDeleted` | tinyint(1) | 否 | 0 | 软删除标记 | | |
| 131 | 132 | |
| 132 | 133 | ### 索引 |
| 133 | 134 | |
| 134 | -- `uk_department_code` (UNIQUE): `sCode` | |
| 135 | -- `idx_department_parent` (BTREE): `iParentId` | |
| 136 | -- `idx_department_tenant` (BTREE): `sBrandsId`, `sSubsidiaryId` | |
| 135 | +- `pk_sys_employee` (PRIMARY): `iIncrement` | |
| 136 | +- `uk_sys_employee_code` (UNIQUE): `sEmployeeCode` | |
| 137 | +- `idx_sys_employee_department` (BTREE): `iDepartmentId` | |
| 138 | +- `idx_sys_employee_name` (BTREE): `sEmployeeName` | |
| 137 | 139 | |
| 138 | 140 | ### 外键 |
| 139 | 141 | |
| 140 | -- `fk_department_parent`: `iParentId` → `t_department.iIncrement` (ON DELETE RESTRICT / ON UPDATE RESTRICT) | |
| 142 | +- `fk_sys_employee_department`: `iDepartmentId` → `sys_department.iIncrement` (ON DELETE RESTRICT, ON UPDATE CASCADE) | |
| 141 | 143 | |
| 142 | 144 | ### 业务注记 |
| 143 | 145 | |
| 144 | -- 自引用形成部门树,根部门 `iParentId IS NULL`。 | |
| 145 | -- 部门删除采用「拒绝删除有下级 / 有员工的部门」策略(应用层校验);DB 外键 RESTRICT 兜底。 | |
| 146 | +REQ-USR-002 / 003 "员工名" 下拉来源;REQ-USR-004 "员工名" / "部门" 输出来源。`iDepartmentId` 用 RESTRICT 防止删除还有员工的部门。员工先于用户存在;同一员工可对应 0 或 1 个用户账号。 | |
| 147 | + | |
| 148 | +--- | |
| 146 | 149 | |
| 147 | -## `t_permission` — 权限分类字典 | |
| 150 | +## `sys_user` — 用户账号(登录认证 + 类型 + 语言 + 状态 + 登录追踪) | |
| 148 | 151 | |
| 149 | 152 | ### 字段 |
| 150 | 153 | |
| ... | ... | @@ -155,24 +158,46 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 155 | 158 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 156 | 159 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 157 | 160 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 158 | -| `sCode` | varchar(50) | 否 | — | 权限码,例如 `USR:ADD` / `USR:EDIT`;系统内唯一 | | |
| 159 | -| `sName` | varchar(100) | 否 | — | 权限分类名称(展示用) | | |
| 160 | -| `iSortOrder` | int | 否 | 0 | 同分类内排序 | | |
| 161 | +| `sUsername` | varchar(50) | 否 | — | 用户名(登录凭据,系统内全局唯一,3-20 位字母数字下划线) | | |
| 162 | +| `sUserCode` | varchar(50) | 否 | — | 用户号(业务展示用编码,系统内唯一) | | |
| 163 | +| `sPasswordHash` | varchar(255) | 否 | — | 密码哈希(BCrypt / Argon2,禁明文) | | |
| 164 | +| `iEmployeeId` | int | 是 | NULL | 关联职员 ID(可选;FK → sys_employee.iIncrement) | | |
| 165 | +| `sUserType` | varchar(20) | 否 | `NORMAL` | 用户类型枚举:`NORMAL`=普通用户 / `SUPER_ADMIN`=超级管理员 | | |
| 166 | +| `sLanguage` | varchar(10) | 否 | `zh-CN` | 语言:`zh-CN`=中文 / `en-US`=英文 / `zh-TW`=繁体 | | |
| 167 | +| `iCanEditDocument` | tinyint(1) | 否 | 0 | 单据修改权限:0=否 1=是 | | |
| 168 | +| `iIsDeleted` | tinyint(1) | 否 | 0 | 是否作废:0=启用 1=作废(停用) | | |
| 169 | +| `iFailedLoginCount` | int | 否 | 0 | 累计登录失败次数,达阈值锁定,登录成功清零 | | |
| 170 | +| `tLockUntil` | datetime | 是 | NULL | 锁定截止时间,NULL=未锁定,过期自动解锁 | | |
| 171 | +| `tLastLoginDate` | datetime | 是 | NULL | 最后一次成功登录时间,REQ-USR-004 "登录日期" 来源 | | |
| 172 | +| `sCreatedBy` | varchar(50) | 是 | NULL | 制单人(创建该用户的用户名),REQ-USR-002 "制单人" | | |
| 173 | +| `sUpdatedBy` | varchar(50) | 是 | NULL | 最后修改人用户名 | | |
| 174 | +| `tUpdatedDate` | datetime | 是 | NULL | 最后修改时间 | | |
| 161 | 175 | |
| 162 | 176 | ### 索引 |
| 163 | 177 | |
| 164 | -- `uk_permission_code` (UNIQUE): `sCode` | |
| 178 | +- `pk_sys_user` (PRIMARY): `iIncrement` | |
| 179 | +- `uk_sys_user_username` (UNIQUE): `sUsername` | |
| 180 | +- `uk_sys_user_code` (UNIQUE): `sUserCode` | |
| 181 | +- `idx_sys_user_employee` (BTREE): `iEmployeeId` | |
| 182 | +- `idx_sys_user_type` (BTREE): `sUserType` | |
| 183 | +- `idx_sys_user_is_deleted` (BTREE): `iIsDeleted` | |
| 184 | +- `idx_sys_user_created_by` (BTREE): `sCreatedBy` | |
| 165 | 185 | |
| 166 | 186 | ### 外键 |
| 167 | 187 | |
| 168 | -- 无 | |
| 188 | +- `fk_sys_user_employee`: `iEmployeeId` → `sys_employee.iIncrement` (ON DELETE SET NULL, ON UPDATE CASCADE) | |
| 169 | 189 | |
| 170 | 190 | ### 业务注记 |
| 171 | 191 | |
| 172 | -- 字典表,启动时由初始化脚本灌入;不允许业务删除,禁用通过软标记(暂未引入 `bVoid`,由 docs/01 补充时再加)。 | |
| 173 | -- 权限码格式 `<模块代码>:<动作>`,与后端 `@PreAuthorize('hasAuthority(...)')` 一一对应(docs/06 § 1.3)。 | |
| 192 | +USR 模块核心表。 | |
| 193 | +- **登录认证**(REQ-USR-001):`sUsername` + `sPasswordHash` 验证;连续失败累加 `iFailedLoginCount`,达 5 次写 `tLockUntil = now() + 30 分钟`;登录成功清零 + 更新 `tLastLoginDate`。 | |
| 194 | +- **作废**(`iIsDeleted = 1`):等价业务"停用",登录直接拒绝。删除用户不物理删,避免 FK 联动;`uk_sys_user_username` 与作废态共存的可能由应用层处理(作废后用户名释放或保留按运营决定,本阶段保留唯一)。 | |
| 195 | +- **职员关联**(`iEmployeeId`):可选;删除职员时此字段置 NULL(`ON DELETE SET NULL`),用户记录不丢。 | |
| 196 | +- **密码不在本接口修改**(REQ-USR-003 边界):修改用户走非密码字段;密码重置走独立流程(后续 REQ 扩展)。 | |
| 174 | 197 | |
| 175 | -## `t_user_permission` — 用户-权限分类关联表 | |
| 198 | +--- | |
| 199 | + | |
| 200 | +## `sys_permission_category` — 权限分类字典 | |
| 176 | 201 | |
| 177 | 202 | ### 字段 |
| 178 | 203 | |
| ... | ... | @@ -183,26 +208,29 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 183 | 208 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 184 | 209 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 185 | 210 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 186 | -| `iUserId` | int | 否 | — | 用户 ID,关联 `t_user.iIncrement` | | |
| 187 | -| `iPermissionId` | int | 否 | — | 权限分类 ID,关联 `t_permission.iIncrement` | | |
| 211 | +| `sCategoryName` | varchar(100) | 否 | — | 权限分类名称(如 "采购管理" / "销售管理") | | |
| 212 | +| `sCategoryCode` | varchar(50) | 否 | — | 权限分类编码(系统内唯一,代码层引用) | | |
| 213 | +| `sCategoryDesc` | varchar(255) | 是 | NULL | 分类说明 | | |
| 214 | +| `iSortOrder` | int | 否 | 0 | 列表展示顺序 | | |
| 215 | +| `iIsDeleted` | tinyint(1) | 否 | 0 | 软删除标记 | | |
| 188 | 216 | |
| 189 | 217 | ### 索引 |
| 190 | 218 | |
| 191 | -- `uk_user_perm` (UNIQUE): `iUserId`, `iPermissionId` | |
| 192 | -- `idx_user_perm_perm` (BTREE): `iPermissionId` | |
| 219 | +- `pk_sys_permission_category` (PRIMARY): `iIncrement` | |
| 220 | +- `uk_sys_permission_category_code` (UNIQUE): `sCategoryCode` | |
| 221 | +- `idx_sys_permission_category_sort` (BTREE): `iIsDeleted, iSortOrder` | |
| 193 | 222 | |
| 194 | 223 | ### 外键 |
| 195 | 224 | |
| 196 | -- `fk_userperm_user`: `iUserId` → `t_user.iIncrement` (ON DELETE CASCADE / ON UPDATE RESTRICT) | |
| 197 | -- `fk_userperm_perm`: `iPermissionId` → `t_permission.iIncrement` (ON DELETE RESTRICT / ON UPDATE RESTRICT) | |
| 225 | +(无) | |
| 198 | 226 | |
| 199 | 227 | ### 业务注记 |
| 200 | 228 | |
| 201 | -- 关联表豁免 `sBrandsId` / `sSubsidiaryId` 的业务语义但保留列以遵守项目标准;写入时复用 `t_user` 的对应租户标识。 | |
| 202 | -- 删除用户时级联清理本表行(`ON DELETE CASCADE`);删除字典项保护已分配数据(`ON DELETE RESTRICT`)。 | |
| 203 | -- 唯一约束 `(iUserId, iPermissionId)` 确保同一用户对同一权限分类不重复授权。 | |
| 229 | +REQ-USR-002 / 003 表 2 "权限组" 的"权限分类"来源。本表是字典,分类条目由系统初始化或运营维护,**用户管理模块只读使用**。 | |
| 230 | + | |
| 231 | +--- | |
| 204 | 232 | |
| 205 | -## `t_company` — 公司 / 版本字典 | |
| 233 | +## `sys_user_permission_category` — 用户 × 权限分类授权关系 | |
| 206 | 234 | |
| 207 | 235 | ### 字段 |
| 208 | 236 | |
| ... | ... | @@ -213,18 +241,25 @@ USR 用户管理模块共 6 张表,围绕「用户」为核心: |
| 213 | 241 | | `sBrandsId` | varchar(100) | 是 | `1111111111` | 品牌 ID(多租户隔离,标准列) | |
| 214 | 242 | | `sSubsidiaryId` | varchar(100) | 是 | `1111111111` | 子公司 ID(组织层级隔离,标准列) | |
| 215 | 243 | | `tCreateDate` | datetime | 否 | 当前时间 | 创建时间(标准列) | |
| 216 | -| `sCode` | varchar(50) | 否 | — | 公司 / 版本编码;系统内唯一 | | |
| 217 | -| `sName` | varchar(100) | 否 | — | 显示名称 | | |
| 244 | +| `iUserId` | int | 否 | — | 用户 ID(FK → sys_user.iIncrement) | | |
| 245 | +| `iPermissionCategoryId` | int | 否 | — | 权限分类 ID(FK → sys_permission_category.iIncrement) | | |
| 246 | +| `sGrantedBy` | varchar(50) | 是 | NULL | 授予人用户名 | | |
| 218 | 247 | |
| 219 | 248 | ### 索引 |
| 220 | 249 | |
| 221 | -- `uk_company_code` (UNIQUE): `sCode` | |
| 250 | +- `pk_sys_user_permission_category` (PRIMARY): `iIncrement` | |
| 251 | +- `uk_sys_user_permission_category` (UNIQUE): `iUserId, iPermissionCategoryId` | |
| 252 | +- `idx_sys_user_permission_category_category` (BTREE): `iPermissionCategoryId` | |
| 222 | 253 | |
| 223 | 254 | ### 外键 |
| 224 | 255 | |
| 225 | -- 无 | |
| 256 | +- `fk_sys_upc_user`: `iUserId` → `sys_user.iIncrement` (ON DELETE CASCADE, ON UPDATE CASCADE) | |
| 257 | +- `fk_sys_upc_permission_category`: `iPermissionCategoryId` → `sys_permission_category.iIncrement` (ON DELETE CASCADE, ON UPDATE CASCADE) | |
| 226 | 258 | |
| 227 | 259 | ### 业务注记 |
| 228 | 260 | |
| 229 | -- REQ-USR-001 登录入参「版本」下拉数据源;启动时由初始化脚本插入至少一行默认公司数据;具体「版本」字段语义待后续业务确认后再补列。 | |
| 230 | -- 当前 REQ 描述不足以确认与 `t_user` 是否需要强关联,故先做独立字典;后续如需「用户绑定公司 / 版本」再于 `t_user` 增列。 | |
| 261 | +记录每个用户被授予的权限分类清单。 | |
| 262 | +- REQ-USR-002 新增用户时,根据表 2 勾选生成本表对应行。 | |
| 263 | +- REQ-USR-003 修改用户时,重新计算差集做增量增删。 | |
| 264 | +- `ON DELETE CASCADE`:用户被物理删除时联动清理(注意:业务上用户作废不会物理删除,所以联动主要面向极端场景);权限分类被物理删除时联动清理用户授权关系,避免悬挂。 | |
| 265 | +- 复合唯一键 `(iUserId, iPermissionCategoryId)` 保证同一用户同一分类只授权一次。 | ... | ... |
docs/04-技术规范.md
| ... | ... | @@ -35,117 +35,138 @@ |
| 35 | 35 | |
| 36 | 36 | ### 1.1 分层结构 |
| 37 | 37 | |
| 38 | +按 Spring Boot 3 + MyBatis-Plus 惯例分四层,每层职责单一: | |
| 39 | + | |
| 38 | 40 | | 层 | 职责 | |
| 39 | 41 | |---|---| |
| 40 | -| `controller/` | 接收 HTTP 请求、参数校验(`@Valid`)、调用 Service、组装响应;不写业务逻辑 | | |
| 41 | -| `service/` | 业务编排、事务边界、跨 Mapper 调用;接口 + `impl/` 实现 | | |
| 42 | -| `mapper/` | MyBatis-Plus 数据访问(Java 接口 + XML),仅做单表 CRUD 与简单关联查询 | | |
| 43 | -| `entity/` | 与数据库表 1:1 的实体类,字段名/类型严格对齐 docs/03 | | |
| 44 | -| `dto/` | 入参对象(前端 → 后端),含 `@NotNull` / `@Pattern` 等校验注解 | | |
| 45 | -| `vo/` | 出参对象(后端 → 前端),由 MapStruct 从 Entity 转换 | | |
| 46 | -| `config/` | Spring 配置类(Security / Redis / MyBatis-Plus / Swagger / Activiti) | | |
| 47 | -| `common/` | 全局响应包装、统一异常处理器、拦截器、工具类 | | |
| 48 | -| `security/` | JWT 生成 / 验证、`UserDetailsService` 实现、权限注解 | | |
| 42 | +| `controller` | HTTP 入口;参数校验(`@Valid`);调用 service;返回统一响应;**不写业务逻辑** | | |
| 43 | +| `service` + `service/impl` | 业务编排;事务边界;调用一个或多个 mapper;DTO ↔ Entity 转换走 converter | | |
| 44 | +| `mapper` + `resources/mapper/*.xml` | 单表 CRUD 走 MyBatis-Plus;复杂 SQL 写 XML;**不写业务逻辑** | | |
| 45 | +| `entity` / `dto` / `vo` / `converter` | 数据传输层;converter 用 MapStruct 自动生成实现 | | |
| 49 | 46 | |
| 50 | 47 | ### 1.2 命名约定 |
| 51 | 48 | |
| 52 | -- **包名**:全小写,单数。根包 `com.example.erp`。业务模块包 `<ROOT>.module.<模块代码小写>`(示例:`com.example.erp.module.usr`)。 | |
| 53 | -- **类名**:大驼峰;按层级加后缀(`UserController` / `UserServiceImpl` / `UserMapper` / `UserCreateDTO` / `UserListVO`)。 | |
| 54 | -- **方法名**:小驼峰;动词开头(`createUser` / `listUsers` / `disableUser`),与 REST 动作语义对齐。 | |
| 55 | -- **常量**:全大写下划线(`MAX_LOGIN_FAILS = 5`),放对应业务模块的 `constant/` 子包或公共 `common/constant/`。 | |
| 56 | -- **示例 1**:`com.example.erp.module.usr.controller.UserController#listUsers(UserQueryDTO)` 返回 `PageVO<UserListVO>`。 | |
| 57 | -- **示例 2**:`com.example.erp.module.usr.service.impl.UserServiceImpl#disableUser(Long id)` 写入 `t_user_audit_log` 审计。 | |
| 49 | +- **Java 包**:全小写。根包:`com.xly.erp`。业务模块:`module.<module_code_lower>`(与 docs/01 模块代码对齐)。 | |
| 50 | +- **类名**:大驼峰;后缀清晰标识层(`UserController` / `UserServiceImpl` / `UserMapper` / `UserEntity` / `CreateUserReq` / `UserVo` / `UserConverter`)。 | |
| 51 | +- **方法 / 字段**:小驼峰(`createUser` / `userName`)。 | |
| 52 | +- **常量**:全大写下划线(`MAX_LOGIN_ATTEMPTS`)。 | |
| 53 | +- **数据库表名**:小写下划线(`sys_user`);字段名同样小写下划线(`created_at`)。 | |
| 54 | + | |
| 55 | +**示例**: | |
| 56 | +```java | |
| 57 | +package com.xly.erp.module.usr.controller; | |
| 58 | + | |
| 59 | +@RestController | |
| 60 | +@RequestMapping("/api/v1/users") | |
| 61 | +public class UserController { | |
| 62 | + public Result<UserVo> createUser(@RequestBody @Valid CreateUserReq req) { ... } | |
| 63 | +} | |
| 64 | +``` | |
| 58 | 65 | |
| 59 | 66 | ### 1.3 统一响应格式 |
| 60 | 67 | |
| 61 | -后端所有 HTTP 接口返回 `Result<T>` 包装: | |
| 68 | +所有 HTTP 接口返回 `Result<T>` / `PageResult<T>`: | |
| 62 | 69 | |
| 63 | 70 | ```json |
| 64 | 71 | // 成功 |
| 65 | 72 | { "code": 0, "message": "ok", "data": { ... } } |
| 73 | + | |
| 66 | 74 | // 失败 |
| 67 | 75 | { "code": 40001, "message": "用户名已存在", "data": null } |
| 68 | 76 | ``` |
| 69 | 77 | |
| 70 | 78 | 错误码段位: |
| 71 | - | |
| 72 | -| 段位 | 含义 | | |
| 73 | -|---|---| | |
| 74 | -| `0` | 成功 | | |
| 75 | -| `1xxxx` | 系统级(参数校验、未授权、未登录) | | |
| 76 | -| `2xxxx` | 通用业务错误(资源不存在、状态非法) | | |
| 77 | -| `4xxxx` | 模块业务错误(按模块再分子段:USR=`40xxx`,PUR=`41xxx`...) | | |
| 78 | -| `5xxxx` | 第三方 / 基础设施错误(DB / Redis / 外部服务) | | |
| 79 | +- `0`:成功 | |
| 80 | +- `10xxx`:参数 / 校验类错误 | |
| 81 | +- `20xxx`:业务规则错误 | |
| 82 | +- `30xxx`:权限类错误 | |
| 83 | +- `40xxx`:资源类错误(找不到、冲突) | |
| 84 | +- `50xxx`:服务器内部错误 | |
| 85 | +- `60xxx`:第三方依赖错误 | |
| 79 | 86 | |
| 80 | 87 | ### 1.4 异常处理 |
| 81 | 88 | |
| 82 | -- **全局异常处理器**:`@RestControllerAdvice` 统一捕获,转换为 `Result` 失败结构。 | |
| 83 | -- **必须 catch**:`MethodArgumentNotValidException`(参数校验)/ `BindException` / 业务自定义 `BizException` / `AccessDeniedException`。 | |
| 84 | -- **禁止 catch**:泛 `Exception` 在业务代码中吞掉;必须让全局处理器接管。 | |
| 85 | -- **接口响应禁止回显后端异常堆栈**:返回用户友好错误码 + 文案;堆栈仅写入 logback。 | |
| 89 | +- 全局异常处理器 `GlobalExceptionHandler`(`@RestControllerAdvice`)统一捕获并转 `Result.fail(...)`。 | |
| 90 | +- 业务异常用 `BizException(int code, String message)`,service 层抛出,全局处理器捕获后转响应。 | |
| 91 | +- **禁止**在 controller / service 里 `catch (Exception e) { e.printStackTrace(); }` 后吞掉。 | |
| 92 | +- **接口响应禁止回显后端异常堆栈**:仅返回 `code` + 用户友好 `message`;堆栈写日志(含 traceId)。 | |
| 86 | 93 | |
| 87 | 94 | ### 1.5 事务 |
| 88 | 95 | |
| 89 | -- **事务边界**:放在 Service 层方法上(`@Transactional`),Controller / Mapper 禁止开事务。 | |
| 90 | -- **传播策略**:默认 `REQUIRED`;只读查询用 `@Transactional(readOnly = true)`。 | |
| 91 | -- **跨服务调用禁止开新事务嵌套**:跨进程 / 跨服务的一致性走「最终一致 + 补偿」或 Activiti 工作流,不要用分布式事务。 | |
| 96 | +- 事务边界统一在 **service 实现层**,使用 `@Transactional(rollbackFor = Exception.class)`。 | |
| 97 | +- controller / mapper 层**禁止**写 `@Transactional`。 | |
| 98 | +- 跨服务(跨进程)调用**禁止**使用本地事务跨边界;改用最终一致性(消息队列 / 补偿任务),本项目当前无跨服务场景。 | |
| 92 | 99 | |
| 93 | 100 | ### 1.6 认证 |
| 94 | 101 | |
| 95 | -- **协议**:JWT(HS256),头 `Authorization: Bearer <token>`。 | |
| 96 | -- **生命周期**:access token 8 小时,refresh token 7 天;登录返回两枚 token;access 过期前端凭 refresh 刷新一次。 | |
| 97 | -- **刷新机制**:refresh token 只能用于换 access,不能直接调业务接口;服务端可吊销(Redis 存吊销名单)。 | |
| 98 | -- **密钥管理**:JWT 签名密钥放 `.env.local` 的 `JWT_SECRET`,至少 32 字节;生产环境必须更换默认值。 | |
| 99 | -- **登录失败锁定**:连续 5 次失败锁账户 30 分钟,Redis 计数器 key `login:fail:<username>`。 | |
| 102 | +- 使用 Spring Security + JWT。 | |
| 103 | +- **Access Token**:HS256,TTL 2 小时;签发后写 Redis(key=`auth:token:<userId>:<jti>`,value=用户摘要)。 | |
| 104 | +- **Refresh Token**:TTL 7 天,单独签发;刷新时旋转 token 并使旧 token 失效。 | |
| 105 | +- 密钥(HMAC secret)放 `.env.local` 的 `JWT_SECRET`,**禁止**写入 application.yml 或代码。 | |
| 106 | +- 登出 / 改密 / 停用:把对应 `jti` 加入 Redis 黑名单直至原 TTL 过期。 | |
| 100 | 107 | |
| 101 | 108 | ## 二、前端规范 |
| 102 | 109 | |
| 103 | 110 | ### 2.1 目录约定 |
| 104 | 111 | |
| 112 | +按 Vite + React 18 + Redux Toolkit 惯例分层(详见 docs/09 § 三): | |
| 113 | + | |
| 105 | 114 | | 目录 | 职责 | |
| 106 | 115 | |---|---| |
| 107 | -| `src/api/` | Axios 实例 + 每模块一个 API 文件;**所有 HTTP 调用唯一入口** | | |
| 108 | -| `src/components/` | 跨页面通用组件(`AuthButton` / `AppTable` / `PageHeader`) | | |
| 109 | -| `src/pages/` | 业务页面,按模块组织子目录 | | |
| 110 | -| `src/store/` | Redux Toolkit slice,仅放真正的全局状态 | | |
| 111 | -| `src/hooks/` | 自定义 hook(`useAuth` / `usePagination` / `useDebounce`) | | |
| 112 | -| `src/utils/` | 纯函数工具(格式化、校验、正则) | | |
| 113 | -| `src/styles/` | `tokens.css` + 全局样式 | | |
| 114 | -| `src/router/` | 路由表 + 路由守卫 | | |
| 115 | - | |
| 116 | -> **前端禁止直接写 SQL / 操作 DB**,所有数据访问走 `api/` 层统一封装。 | |
| 116 | +| `src/api/` | Axios 实例 + 各模块接口调用函数;**所有数据访问唯一入口** | | |
| 117 | +| `src/store/` | Redux Toolkit slices;全局态(认证 / 用户信息 / 字典) | | |
| 118 | +| `src/router/` | React Router 配置 + 路由守卫 | | |
| 119 | +| `src/pages/<module>/` | 业务页面(按模块分目录) | | |
| 120 | +| `src/components/` | 跨页面通用组件(含 `Permission` 等) | | |
| 121 | +| `src/layouts/` | 框架布局 | | |
| 122 | +| `src/hooks/` | 自定义 hook | | |
| 123 | +| `src/utils/` | 工具方法(日期 / 金额 / 校验等) | | |
| 124 | +| `src/styles/` | 全局样式 + Design Tokens | | |
| 125 | +| `src/types/` | 全局 TypeScript 类型 | | |
| 126 | + | |
| 127 | +**前端禁止直接写 SQL / 操作 DB**;所有数据访问走 `api/` 层封装的 HTTP 调用。 | |
| 117 | 128 | |
| 118 | 129 | ### 2.2 状态管理 |
| 119 | 130 | |
| 120 | -- **Redux 存什么**:全局共享 + 跨页面持久(当前用户信息、权限码列表、菜单树、字典)。 | |
| 121 | -- **Redux 不存什么**:单个页面的表单值、Modal 开关、表格分页参数 → 放组件 `useState` / `useReducer`。 | |
| 122 | -- **服务端数据**:业务数据(列表、明细)不要塞 Redux,每次进入页面走 `api/` 取最新;只有字典 / 全局枚举做内存缓存(`store/slices/dictSlice`)。 | |
| 131 | +- **全局态**(用户身份 / 权限 / 全局字典 / 主题):Redux Toolkit。 | |
| 132 | +- **页面级态**(表单值 / 列表参数):组件本地 `useState` / `useReducer`。 | |
| 133 | +- **服务端数据**(列表 / 详情):直接调 `api/` 函数,配合 `useEffect` + 本地 state;**不放**全局 store,避免缓存一致性问题。 | |
| 134 | +- 跨页面共享但**非全局**的态用 React Context(如 Modal Provider)。 | |
| 123 | 135 | |
| 124 | 136 | ### 2.3 请求封装 |
| 125 | 137 | |
| 126 | -- **Axios 实例**:`src/api/http.ts` 统一创建,`baseURL` 取 `import.meta.env.VITE_API_BASE`。 | |
| 127 | -- **请求拦截器**:自动注入 `Authorization: Bearer <accessToken>`;token 临期则先刷新再发起原请求。 | |
| 128 | -- **响应拦截器**:剥 `Result.data` 给业务;`code !== 0` 时 `message.error(message)` 并 reject。 | |
| 129 | -- **超时**:默认 15s;上传 / 导出接口单独 60s。 | |
| 130 | -- **错误重试**:仅对幂等 GET 重试 1 次,POST / PUT / DELETE 禁止重试。 | |
| 138 | +`src/api/client.ts` 统一 Axios 实例: | |
| 139 | + | |
| 140 | +- baseURL 从 `import.meta.env.VITE_API_BASE_URL` 读取。 | |
| 141 | +- **请求拦截**:自动注入 `Authorization: Bearer <accessToken>`;附带 `X-Request-Id` 用于链路追踪。 | |
| 142 | +- **响应拦截**: | |
| 143 | + - HTTP 200 + `code === 0` → 返回 `data`。 | |
| 144 | + - HTTP 200 + `code !== 0` → 抛业务错误(含 message 和 code)。 | |
| 145 | + - HTTP 401 → 触发 refresh token;refresh 失败 → 清登录态 + 跳登录页。 | |
| 146 | + - HTTP 5xx → 抛网络错误。 | |
| 147 | +- 超时:默认 10s;上传 / 导出接口 60s。 | |
| 148 | +- 不做自动重试(重试由业务调用处显式控制)。 | |
| 131 | 149 | |
| 132 | 150 | ### 2.4 错误处理 |
| 133 | 151 | |
| 134 | -- **网络错误**:Axios `error.code === 'ERR_NETWORK'` → 全局 `message.error('网络异常,请检查连接')`。 | |
| 135 | -- **业务错误**:`code !== 0` 在响应拦截器统一弹 message;页面只需处理 `try/catch` 中的 reject。 | |
| 136 | -- **页面级错误**:路由级 `ErrorBoundary` 包顶层,组件 throw 时显示统一错误页(docs/06 § 1.2)。 | |
| 152 | +| 错误类型 | 处理方式 | | |
| 153 | +|---|---| | |
| 154 | +| 网络错误(断网 / 5xx) | 顶层 `message.error('网络异常,请重试')`,业务调用处可自定义重试按钮 | | |
| 155 | +| 业务错误(code !== 0) | 默认 `message.error(err.message)`,业务调用处可拦截做特殊提示 | | |
| 156 | +| 表单校验失败 | 字段下方红字(Ant Design `Form` 自带) | | |
| 157 | +| 路由级未授权 | `RequireAuth` 守卫重定向到 `/login` | | |
| 158 | +| 页面级崩溃 | 顶层 `ErrorBoundary` 显示 `Result status="500"` + 重新加载按钮 | | |
| 137 | 159 | |
| 138 | 160 | ### 2.5 样式与主题 |
| 139 | 161 | |
| 140 | 162 | - **CSS 变量命名**:`--color-<scope>-<role>-<state>` |
| 141 | - - `scope` = `form` / `table-row` / `btn` / `link` / ... | |
| 142 | - - `role` = `bg` / `fg` / `border` | |
| 143 | - - `state` = `edit` / `readonly` / `hover` / `selected` | |
| 144 | -- **文件位置**:`src/styles/tokens.css`,由 skeleton-gen 生成空骨架,色值由 docs/06 § 二锁定后填入。 | |
| 145 | -- **组件样式**:只用 `var(--color-xxx)`,禁止硬编码 hex / rgba。 | |
| 146 | -- **Ant Design 主题对接**:`<ConfigProvider theme={{ token: { colorPrimary: 'var(--color-primary)', ... } }}>` 把 tokens 注入 antd 主题。 | |
| 147 | - | |
| 148 | -具体 token 表见 docs/06 § 二。 | |
| 163 | + - `scope`:`form` / `table-row` / `tag` / `btn` 等;通用全局色无 scope(如 `--color-primary`) | |
| 164 | + - `role`:`bg` / `fg` / `border` | |
| 165 | + - `state`:`edit` / `readonly` / `hover` / `selected` / `disabled`;常态省略 | |
| 166 | +- **文件位置**:`frontend/src/styles/tokens.css`(由 skeleton-gen 生成骨架,色值由 docs/06 § 二锁定)。 | |
| 167 | +- **组件样式中只用 `var(--color-xxx)`**,禁止硬编码 hex / rgba。 | |
| 168 | +- **与 Ant Design 主题对接**:`App.tsx` 顶层包 `<ConfigProvider theme={{ token: { colorPrimary: 'var(--color-primary)', ... } }}>`;Ant Design 5 接受 token 对象映射,确保组件库色值与 tokens.css 单一来源。 | |
| 169 | +- 具体 token 表见 `docs/06 § 二`。 | |
| 149 | 170 | |
| 150 | 171 | ## 三、共同约定 |
| 151 | 172 | |
| ... | ... | @@ -153,35 +174,40 @@ |
| 153 | 174 | |
| 154 | 175 | `<type>(<scope>): <subject> REQ-XXX-NNN` |
| 155 | 176 | |
| 156 | -- `type` ∈ `feat` / `fix` / `refactor` / `test` / `docs` / `chore` / `style` | |
| 157 | -- `scope` 为模块代码小写(`usr` / `pur` / `sal`)或 `infra` | |
| 158 | -- `subject` 50 字以内动词开头 | |
| 159 | -- 末尾必须挂 `REQ-XXX-NNN`(如 `REQ-USR-001`),CI 用此挂关联 | |
| 177 | +- type:见 CLAUDE.md § 🗂️ Git 提交规范。 | |
| 178 | +- scope:模块代码小写(`usr` / `pur` / `sal`)或 `infra` / `docs`。 | |
| 179 | +- subject:祈使句,中文,<= 50 字。 | |
| 180 | +- REQ tag:业务类提交(feat / fix / test)必带。 | |
| 181 | + | |
| 182 | +**示例**:`feat(usr): 实现用户登录接口 REQ-USR-001` | |
| 160 | 183 | |
| 161 | 184 | ### 3.2 分页查询 |
| 162 | 185 | |
| 163 | -- **后端入参**:`PageQuery { pageNum: int = 1, pageSize: int = 20 }`(`pageSize` 上限 100)+ 业务过滤字段;继承基类避免重复声明。 | |
| 164 | -- **后端出参**:`PageVO<T> { records: List<T>, total: long, pageNum: int, pageSize: int }`。 | |
| 165 | -- **前端组件**:统一封装 `<AppTable />` 内置 antd `Table` + `Pagination`,受控 `current` / `pageSize` / `total`。 | |
| 166 | -- **默认排序**:按创建时间倒序;可按列点击切换。 | |
| 186 | +- **后端**:请求入参 `PageReq { page=1, size=20 }`(兜底默认值;size 上限 100);返回 `PageResult<T> { records: List<T>, total: long, page: int, size: int }`。 | |
| 187 | +- **前端**:使用 Ant Design `Table` + `Pagination`;统一组件包装在 `components/PagedTable`,对接 `PageResult`。 | |
| 188 | +- 排序参数走 `sortField` / `sortOrder`,白名单字段,禁止任意字段排序。 | |
| 167 | 189 | |
| 168 | 190 | ### 3.3 日期与金额 |
| 169 | 191 | |
| 170 | -- **后端类型**:日期 `LocalDate`,时间 `LocalDateTime`,金额 `BigDecimal`(scale=2,HALF_UP)。 | |
| 171 | -- **序列化**:Jackson 全局配置 `LocalDateTime` ↔ `yyyy-MM-dd HH:mm:ss`;`BigDecimal` 序列化为字符串避免精度丢失。 | |
| 172 | -- **前端展示**:`utils/format.ts` 提供 `formatDate(d)` / `formatMoney(n)`;金额展示固定 2 位小数 + 千分位(`¥1,234.56`)。 | |
| 192 | +- **后端类型**:日期用 `LocalDateTime` / `LocalDate`;金额用 `BigDecimal`(精度 2 位)。 | |
| 193 | +- **后端 → 前端**:日期统一序列化为 `YYYY-MM-DD HH:mm:ss` 字符串;金额序列化为 `string`(避免精度丢失)。 | |
| 194 | +- **前端展示**:日期使用 `dayjs` 格式化(`YYYY-MM-DD HH:mm`);金额使用 `utils/money.ts` 格式化为千分位。 | |
| 195 | +- **金额精度**:内部全部 `BigDecimal`,4 舍 6 入 5 成双(`HALF_EVEN`);展示截断到 2 位。 | |
| 173 | 196 | |
| 174 | 197 | ### 3.4 数据访问规约 |
| 175 | 198 | |
| 176 | -- **SELECT 字段显式列举**,禁止 `SELECT *`;Mapper XML 用 `<sql id="Base_Column_List">` 集中维护。 | |
| 177 | -- **禁止 N+1**:循环中不得执行 DB 查询;改用批量查(`IN (?,?,?)`)/ JOIN / `<foreach>`。 | |
| 178 | -- **表名 / 字段名**:Mapper XML 中通过 `<sql>` 片段或常量引用,禁止字符串拼接(防 SQL 注入 + 利于改名)。 | |
| 179 | -- **分页**:统一用 MyBatis-Plus `Page<T>` + `selectPage(...)`,禁止 LIMIT 字符串拼接。 | |
| 199 | +- **SELECT 字段显式列举**:禁止 `SELECT *`;MyBatis-Plus 用 `select(User::getId, User::getUsername)` 或 XML 显式列名。 | |
| 200 | +- **禁止 N+1 反模式**:循环中不得执行 DB 查询;改用 `selectBatchIds` / `IN` 子句 / `JOIN`。 | |
| 201 | +- **Mapper XML**:表名 / 字段名用常量或引用,禁止字符串拼接 SQL(防注入)。 | |
| 202 | +- **复杂查询走 XML**,简单 CRUD 走 LambdaQueryWrapper。 | |
| 203 | +- **写操作**:单表批量写用 `saveBatch` / `updateBatchById`;超大批量(>1000)分批 commit。 | |
| 180 | 204 | |
| 181 | 205 | ### 3.5 配置与安全 |
| 182 | 206 | |
| 183 | -- **配置**:DB 连接 / 端口 / 密钥 / 第三方 URL 等一律放 `application.yml` + `.env.local`,代码里**禁止硬编码**;`application.yml` 用 `${VAR_NAME:default}` 引用环境变量。 | |
| 207 | +- **后端配置**:DB 连接 / 端口 / 密钥 / 第三方 URL 等一律放 `application.yml` + `.env.local`。代码中**禁止硬编码**(用 `@Value` / `@ConfigurationProperties` 注入)。 | |
| 208 | +- **多环境**:`application-dev.yml` / `application-test.yml` / `application-prod.yml`,通过 `SPRING_PROFILES_ACTIVE` 切换。 | |
| 184 | 209 | - **前端安全**: |
| 185 | - - `localStorage` **不存敏感信息**(token / 身份 / 个人数据);推荐 HttpOnly Cookie 或 「内存 access token + HttpOnly refresh cookie」组合。 | |
| 186 | - - 接口响应禁止回显后端异常堆栈(与 § 1.4 一致)。 | |
| 187 | - - XSS:所有用户输入展示走 React JSX 自动转义;`dangerouslySetInnerHTML` 禁用,除非内容来源已白名单化。 | |
| 210 | + - `localStorage` **禁止**存敏感信息(token / 身份 / 个人数据)。 | |
| 211 | + - Token 推荐**内存 + HttpOnly Cookie**(refresh)或纯内存(access),Redux store 持有 access;刷新页面通过 refresh cookie 重新换取。 | |
| 212 | + - **接口响应禁止回显后端异常堆栈**(与 § 1.4 一致)。 | |
| 213 | +- **密钥来源**:所有密钥(JWT / DB 密码 / 第三方 API key)从 `.env.local` 加载;`.env.local` 在 `.gitignore`,永不入库。 | ... | ... |
docs/05-API接口契约.md
| 1 | 1 | # 05-API接口契约 |
| 2 | 2 | |
| 3 | -BasePath: `/api` | |
| 4 | -端口: `8080` | |
| 3 | +BasePath: `/api/v1` | |
| 4 | +端口: `9090` | |
| 5 | 5 | |
| 6 | 6 | ## 全局约定 |
| 7 | 7 | |
| ... | ... | @@ -22,186 +22,109 @@ BasePath: `/api` |
| 22 | 22 | |
| 23 | 23 | ### 鉴权 |
| 24 | 24 | |
| 25 | -所有业务接口走 JWT Bearer。客户端在 `Authorization: Bearer <accessToken>` 头中携带访问令牌。 | |
| 26 | - | |
| 27 | -- 登录 `POST /api/usr/auth/login` 返回 `accessToken`(8 小时)+ `refreshToken`(7 天) | |
| 28 | -- 临期前端用 refresh token 换新 access token:`POST /api/usr/auth/refresh` | |
| 29 | -- 注销 `POST /api/usr/auth/logout` 服务端把 token 加入 Redis 吊销名单 | |
| 30 | -- 未携带 / 过期 / 已吊销 → 401,对应错误码 `40101 未登录` / `40102 Token 已过期` / `40103 Token 已吊销` | |
| 25 | +除 `POST /api/v1/auth/login` 和 `GET /api/v1/companies`(登录页"版本"下拉)之外的全部接口需在请求头携带 `Authorization: Bearer <accessToken>`。Token 由 `/auth/login` 签发,TTL 2 小时,HS256 签名(密钥 `JWT_SECRET` 来自 `.env.local`)。鉴权失败统一返回 `40101 token 无效或已过期` / `40102 用户已被作废或锁定`。详见 docs/04 § 1.6。 | |
| 31 | 26 | |
| 32 | 27 | ### 分页参数 |
| 33 | 28 | |
| 34 | -统一查询入参: | |
| 29 | +请求入参: | |
| 35 | 30 | |
| 36 | -| 字段 | 类型 | 必填 | 默认 | 说明 | | |
| 31 | +| 参数 | 类型 | 必填 | 默认 | 说明 | | |
| 37 | 32 | |---|---|---|---|---| |
| 38 | -| `pageNum` | int | 否 | 1 | 当前页码,从 1 开始 | | |
| 39 | -| `pageSize` | int | 否 | 20 | 每页条数;上限 100 | | |
| 40 | -| `orderBy` | string | 否 | `tCreateDate DESC` | 排序表达式,列名 + 方向 | | |
| 33 | +| `page` | int | 否 | 1 | 页码,从 1 开始 | | |
| 34 | +| `size` | int | 否 | 20 | 每页条数,上限 100 | | |
| 35 | +| `sortField` | string | 否 | — | 排序字段,白名单内 | | |
| 36 | +| `sortOrder` | string | 否 | `desc` | `asc` / `desc` | | |
| 41 | 37 | |
| 42 | -响应包装: | |
| 38 | +响应 `data` 结构 `PageResult<T>`: | |
| 43 | 39 | |
| 44 | 40 | ```json |
| 45 | 41 | { |
| 46 | - "code": 200, "message": "ok", | |
| 47 | - "data": { | |
| 48 | - "records": [ ... ], | |
| 49 | - "total": 1234, | |
| 50 | - "pageNum": 1, | |
| 51 | - "pageSize": 20 | |
| 52 | - }, | |
| 53 | - "timestamp": 1700000000000 | |
| 42 | + "records": [], | |
| 43 | + "total": 0, | |
| 44 | + "page": 1, | |
| 45 | + "size": 20 | |
| 54 | 46 | } |
| 55 | 47 | ``` |
| 56 | 48 | |
| 57 | 49 | ## 接口清单 |
| 58 | 50 | (各模块接口段落见下方,由 `downstream-gen` 按 REQ 填入) |
| 59 | 51 | |
| 60 | -## module_usr — USR 用户管理 | |
| 52 | +## module_usr 用户管理 | |
| 61 | 53 | |
| 62 | 54 | ### REQ-USR-001 用户登录 |
| 63 | 55 | |
| 64 | 56 | - **Method**: POST |
| 65 | -- **Path**: `/api/usr/auth/login` | |
| 66 | -- **Auth**: 公开(无需 JWT) | |
| 67 | -- **请求**: | |
| 68 | - ```json | |
| 69 | - { | |
| 70 | - "userName": "admin", | |
| 71 | - "password": "P@ssw0rd", | |
| 72 | - "companyVersion": "STANDARD" | |
| 73 | - } | |
| 74 | - ``` | |
| 75 | - - `userName` (string, 必填, 3-50 位) | |
| 76 | - - `password` (string, 必填, 明文传输,由 TLS 保证机密性;后端用 BCrypt/Argon2 比对哈希) | |
| 77 | - - `companyVersion` (string, 必填, 枚举 `STANDARD` / `PRO` / `FLAGSHIP`) | |
| 78 | -- **响应**: | |
| 79 | - ```json | |
| 80 | - { | |
| 81 | - "code": 200, "message": "ok", | |
| 82 | - "data": { | |
| 83 | - "accessToken": "eyJ...", | |
| 84 | - "refreshToken": "eyJ...", | |
| 85 | - "expiresIn": 28800, | |
| 86 | - "user": { | |
| 87 | - "iIncrement": 1, | |
| 88 | - "sUserName": "admin", | |
| 89 | - "sUserType": "SUPER_ADMIN", | |
| 90 | - "sLanguage": "zh-CN", | |
| 91 | - "permissionCodes": ["USR:ADD", "USR:EDIT", "USR:DELETE", "USR:VIEW"] | |
| 92 | - } | |
| 93 | - }, | |
| 94 | - "timestamp": 1700000000000 | |
| 95 | - } | |
| 96 | - ``` | |
| 57 | +- **Path**: `/api/v1/auth/login` | |
| 58 | +- **Auth**: 无需(公开接口) | |
| 59 | +- **请求**: JSON body `LoginReq { username: string (3-20), password: string (8-20, 明文,HTTPS 传输), companyCode: string (sys_company.sCompanyCode) }` | |
| 60 | +- **响应**: JSON `LoginVo { accessToken: string (JWT), tokenType: "Bearer", expiresInSec: 7200, userInfo: { userId: int, username: string, userType: "NORMAL"|"SUPER_ADMIN", language: string, employeeName?: string, companyCode: string } }` | |
| 97 | 61 | |
| 98 | 62 | #### 错误码 |
| 99 | -- `40001` — 用户名或密码错误(不区分两者,防爆破) | |
| 100 | -- `40301` — 账号已锁定,请稍后再试 | |
| 101 | -- `40302` — 账号已作废 | |
| 102 | -- `40010` — 参数校验失败(用户名 / 密码 / 版本字段为空或格式不合法) | |
| 103 | - | |
| 104 | ---- | |
| 63 | +- `40001` — 用户名或密码格式错误 | |
| 64 | +- `40101` — 用户名或密码错误(统一文案,不区分两者) | |
| 65 | +- `40103` — 账号已被作废,禁止登录 | |
| 66 | +- `42301` — 账号已锁定,请稍后再试(HTTP 423;锁定剩余时间见 `data.lockUntil`) | |
| 67 | +- `40004` — 公司不存在或已删除 | |
| 105 | 68 | |
| 106 | 69 | ### REQ-USR-002 新增用户 |
| 107 | 70 | |
| 108 | 71 | - **Method**: POST |
| 109 | -- **Path**: `/api/usr/users` | |
| 110 | -- **Auth**: JWT;要求权限码 `USR:ADD` | |
| 111 | -- **请求**: | |
| 112 | - ```json | |
| 113 | - { | |
| 114 | - "userNo": "U10001", | |
| 115 | - "userName": "zhangsan", | |
| 116 | - "employeeId": 12, | |
| 117 | - "userType": "NORMAL", | |
| 118 | - "language": "zh-CN", | |
| 119 | - "modifyDoc": false, | |
| 120 | - "permissionIds": [3, 5, 8] | |
| 121 | - } | |
| 122 | - ``` | |
| 123 | - - `userNo` / `userName` 必填且系统内唯一 | |
| 124 | - - `employeeId` 可空(非员工账号) | |
| 125 | - - `userType` 枚举 `NORMAL` / `SUPER_ADMIN`(仅超级管理员可创建超级管理员) | |
| 126 | - - `language` 枚举 `zh-CN` / `en-US` / `zh-TW` | |
| 127 | - - `permissionIds` 权限分类 ID 数组,可为空 | |
| 128 | - - 初始密码后端固定生成(`666666`),不在请求体中 | |
| 129 | -- **响应**: | |
| 130 | - ```json | |
| 131 | - { "code": 200, "message": "ok", "data": { "userNo": "U10001", "iIncrement": 42 }, "timestamp": 1700000000000 } | |
| 132 | - ``` | |
| 72 | +- **Path**: `/api/v1/users` | |
| 73 | +- **Auth**: Bearer Token;仅 `userType=SUPER_ADMIN` 可调用 | |
| 74 | +- **请求**: JSON body `CreateUserReq { username: string (3-20,正则 ^[A-Za-z0-9_]{3,20}$), userCode: string (max 50), userType: "NORMAL"|"SUPER_ADMIN", language: "zh-CN"|"en-US"|"zh-TW", canEditDocument: boolean, employeeId?: int, permissionCategoryIds?: int[] }`。**初始密码由系统统一设为 `"666666"`(BCrypt 哈希后入库),请求体不接受 `password` 字段(出现即返 40001)。** | |
| 75 | +- **响应**: JSON `CreateUserVo { userId: int, username: string, userCode: string }`(HTTP 201) | |
| 133 | 76 | |
| 134 | 77 | #### 错误码 |
| 135 | -- `40001` — 用户名 / 用户号已存在 | |
| 136 | -- `40010` — 参数校验失败 | |
| 137 | -- `40310` — 普通用户无权创建超级管理员 | |
| 138 | -- `40411` — 关联职员不存在 | |
| 139 | -- `40412` — 关联权限分类不存在 | |
| 78 | +- `40001` — 必填字段缺失或格式错误(含携带未知字段如 `password`) | |
| 79 | +- `40004` — 指定的员工 / 权限分类不存在 | |
| 80 | +- `40101` — 未携带或无效 Token | |
| 81 | +- `40301` — 当前用户非超级管理员,无权调用 | |
| 82 | +- `40901` — 用户名已存在 | |
| 83 | +- `40902` — 用户号已存在 | |
| 140 | 84 | |
| 141 | ---- | |
| 85 | +### REQ-USR-003 GET 用户详情 | |
| 86 | + | |
| 87 | +- **Method**: GET | |
| 88 | +- **Path**: `/api/v1/users/{userId}` | |
| 89 | +- **Auth**: Bearer Token;仅 `userType=SUPER_ADMIN` 可调用 | |
| 90 | +- **请求**: Path `userId: int` | |
| 91 | +- **响应**: JSON `UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds: int[], updatedBy, updatedDate }` | |
| 92 | + | |
| 93 | +#### 错误码 | |
| 94 | +- `40101` — 未携带或无效 Token | |
| 95 | +- `40301` — 当前用户非超级管理员,无权调用 | |
| 96 | +- `40401` — 用户不存在 | |
| 142 | 97 | |
| 143 | 98 | ### REQ-USR-003 修改用户 |
| 144 | 99 | |
| 145 | 100 | - **Method**: PUT |
| 146 | -- **Path**: `/api/usr/users/{iIncrement}` | |
| 147 | -- **Auth**: JWT;要求权限码 `USR:EDIT` | |
| 148 | -- **请求**: | |
| 149 | - ```json | |
| 150 | - { | |
| 151 | - "employeeId": 12, | |
| 152 | - "userType": "NORMAL", | |
| 153 | - "language": "en-US", | |
| 154 | - "modifyDoc": true, | |
| 155 | - "void": false, | |
| 156 | - "permissionIds": [3, 5] | |
| 157 | - } | |
| 158 | - ``` | |
| 159 | - - `userName` 不可修改,故请求体不包含 | |
| 160 | - - `void = true` 等价于禁用(软删除) | |
| 161 | - - `permissionIds` 全量覆盖(先 DELETE 后 INSERT,事务内) | |
| 162 | -- **响应**: | |
| 163 | - ```json | |
| 164 | - { "code": 200, "message": "ok", "data": { "iIncrement": 42 }, "timestamp": 1700000000000 } | |
| 165 | - ``` | |
| 101 | +- **Path**: `/api/v1/users/{userId}` | |
| 102 | +- **Auth**: Bearer Token;仅 `userType=SUPER_ADMIN` 可调用 | |
| 103 | +- **请求**: Path `userId: int`;JSON body `UpdateUserReq { userCode?: string, userType?: "NORMAL"|"SUPER_ADMIN", language?: "zh-CN"|"en-US"|"zh-TW", canEditDocument?: boolean, employeeId?: int|null, isDeleted?: boolean, permissionCategoryIds?: int[] }`(username 与 password 字段不接受) | |
| 104 | +- **响应**: JSON `UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds, updatedBy, updatedDate }` | |
| 166 | 105 | |
| 167 | 106 | #### 错误码 |
| 168 | -- `40404` — 目标用户不存在 | |
| 169 | -- `40310` — 不可修改超级管理员(非超级管理员调用) | |
| 170 | -- `40311` — 不可禁用 / 修改自己 | |
| 171 | -- `40411` — 关联职员不存在 | |
| 172 | -- `40412` — 关联权限分类不存在 | |
| 173 | -- `40010` — 参数校验失败 | |
| 174 | - | |
| 175 | ---- | |
| 107 | +- `40001` — 字段格式错误或试图修改 username / password | |
| 108 | +- `40004` — 指定的员工 / 权限分类不存在 | |
| 109 | +- `40101` — 未携带或无效 Token | |
| 110 | +- `40301` — 当前用户非超级管理员,无权调用 | |
| 111 | +- `40302` — 试图停用当前登录用户自己 | |
| 112 | +- `40401` — 用户不存在 | |
| 113 | +- `40902` — 用户号已被占用 | |
| 176 | 114 | |
| 177 | 115 | ### REQ-USR-004 查询用户 |
| 178 | 116 | |
| 179 | 117 | - **Method**: GET |
| 180 | -- **Path**: `/api/usr/users` | |
| 181 | -- **Auth**: JWT;要求权限码 `USR:VIEW` | |
| 182 | -- **请求**: query string | |
| 183 | - - `searchField` 枚举 `userName` / `employeeName` / `userNo` / `departmentName` / `userType` / `void` / `lastLoginDate` / `creator` | |
| 184 | - - `matchMode` 枚举 `CONTAINS` / `NOT_CONTAINS` / `EQUALS` | |
| 185 | - - `searchValue` 字符串,空则不过滤 | |
| 186 | - - `includeVoid` 布尔,默认 false(不返回已作废用户) | |
| 187 | - - `pageNum` / `pageSize` / `orderBy` 通用分页参数 | |
| 188 | -- **响应**: 分页结构,`records[]` 元素: | |
| 189 | - ```json | |
| 190 | - { | |
| 191 | - "iIncrement": 42, | |
| 192 | - "sUserName": "zhangsan", | |
| 193 | - "employeeName": "张三", | |
| 194 | - "sUserNo": "U10001", | |
| 195 | - "departmentName": "技术部", | |
| 196 | - "sUserType": "NORMAL", | |
| 197 | - "sLanguage": "zh-CN", | |
| 198 | - "bVoid": false, | |
| 199 | - "tLastLoginDate": "2026-05-13 09:12:33", | |
| 200 | - "sCreator": "admin", | |
| 201 | - "tCreateDate": "2025-12-01 10:00:00" | |
| 202 | - } | |
| 203 | - ``` | |
| 204 | - - 不返回 `sPasswordHash` / `iLoginFailCount` / `tLockUntil` | |
| 118 | +- **Path**: `/api/v1/users` | |
| 119 | +- **Auth**: Bearer Token;仅 `userType=SUPER_ADMIN` 可调用 | |
| 120 | +- **请求**: Query 参数 `page=1&size=20&sortField=tCreateDate&sortOrder=desc&queryField=username&matchMode=contains&queryValue=张&userType=NORMAL&isDeleted=false` | |
| 121 | + - `queryField`: 枚举 `username|employeeName|userCode|departmentName|userType|isDeleted|lastLoginDate|createdBy` | |
| 122 | + - `matchMode`: 枚举 `contains|notContains|equals`,缺省 `contains` | |
| 123 | + - `queryValue`: 字符串,空字符串表示不限 | |
| 124 | +- **响应**: `PageResult<UserListItemVo>`,`UserListItemVo { userId, username, employeeName, userCode, departmentName, userType, language, isDeleted, lastLoginDate, createdBy, createdDate }`(密码字段不返回) | |
| 205 | 125 | |
| 206 | 126 | #### 错误码 |
| 207 | -- `40010` — 参数校验失败(`pageSize > 100` / `matchMode` 不合法 / `searchField` 不合法) | |
| 127 | +- `40001` — 分页参数越界或类型错误(page<1 / size 越界 / sortOrder 非 asc-desc / userType 非枚举 / lastLoginDate 入参非法日期 / isDeleted 入参非布尔) | |
| 128 | +- `40003` — `sortField` / `queryField` / `matchMode` 不在白名单 | |
| 129 | +- `40101` — 未携带或无效 Token | |
| 130 | +- `40301` — 当前用户非超级管理员,无权调用 | ... | ... |
docs/06-UI交互规范.md
| ... | ... | @@ -6,88 +6,95 @@ |
| 6 | 6 | |
| 7 | 7 | ### 1.1 操作反馈 |
| 8 | 8 | |
| 9 | -- **成功提示**:使用 Ant Design `message.success()`,默认 3s 自动消失;新增 / 修改 / 删除等写操作必须给反馈。 | |
| 10 | -- **失败提示**:使用 `message.error()` 显示后端返回的 `message` 字段;表单字段级错误用 Form 的 `validateStatus="error"` + `help` 同步显示。 | |
| 11 | -- **危险操作二次确认**:删除 / 禁用 / 重置密码等不可逆操作必须用 `Modal.confirm({ okType: 'danger' })` 二次确认,按钮文案使用业务动词(如「确认禁用」),不用「确定」。 | |
| 12 | -- **长耗时按钮 loading**:提交类按钮在请求未返回前必须置为 `loading=true` 并禁用,避免重复提交;查询类操作在表格组件上启用 `loading` 属性即可。 | |
| 9 | +- **成功提示**:使用 Ant Design `message.success`,3 秒自动消失;不阻塞操作。 | |
| 10 | +- **失败提示**:使用 `message.error` 显示后端 `Result.message`;4.5 秒消失;保留可复制。 | |
| 11 | +- **危险操作**(删除 / 停用 / 重置密码):必须 `Modal.confirm` 二次确认,标题写明动作,正文写明影响对象 + 不可逆性。 | |
| 12 | +- **长耗时按钮**(>300ms):按钮 `loading` 态 + 禁用,避免重复提交;提交完成后恢复。 | |
| 13 | +- **表单校验失败**:字段下方红字提示;首个错误字段自动聚焦并滚入视口。 | |
| 13 | 14 | |
| 14 | 15 | ### 1.2 数据展示 |
| 15 | 16 | |
| 16 | -- **空状态**:列表 / 表格无数据时使用 `<Empty description="暂无数据" />`,明细页无数据时附加「返回列表」按钮。 | |
| 17 | -- **加载状态**:页面级加载用 `<Spin spinning>` 包裹主区域;表格 / 卡片局部加载用组件自带 `loading` 属性。 | |
| 18 | -- **异常状态**:接口 5xx 或网络异常时显示 `<Result status="error" title="加载失败" extra={<Button>重试</Button>} />`;403 时显示「无权限」并提供「返回首页」按钮。 | |
| 17 | +- **空状态**:使用 `Empty` 组件,图标 + 一句话说明(如 `暂无用户`)+ 主操作按钮(如 `新增用户`)。 | |
| 18 | +- **加载中**:列表用 `Skeleton`;单组件用 `Spin`;全屏首次加载允许使用 `Spin` 覆盖。 | |
| 19 | +- **异常态**:网络 / 5xx 错误使用 `Result status="error"` 显示,附 `重试` 按钮。 | |
| 20 | +- **分页**:默认 20 条 / 页,可选 10 / 20 / 50 / 100;显示总数 + 当前页 / 总页数。 | |
| 21 | +- **表格列**:必显列固定在左侧;操作列固定在右侧;时间列统一格式 `YYYY-MM-DD HH:mm`。 | |
| 19 | 22 | |
| 20 | 23 | ### 1.3 权限控制(前端) |
| 21 | 24 | |
| 22 | -- **菜单级**:登录后由后端返回菜单权限码列表,前端 Layout 按权限码过滤后渲染左侧菜单。 | |
| 23 | -- **按钮级**:包装 `<AuthButton code="USR:ADD">新增</AuthButton>`,权限码不在用户列表时直接不渲染(避免显示再禁用)。 | |
| 24 | -- **路由级**:在 React Router 外层包 `<AuthRoute />`,路由元数据声明 `meta.code`,无权限重定向 403 页面。 | |
| 25 | -- **关联后端 RBAC**:权限码格式 `<模块代码>:<动作>`(如 `USR:ADD` / `USR:EDIT` / `USR:DELETE`),与后端 Spring Security `@PreAuthorize("hasAuthority('USR:ADD')")` 一一对应。 | |
| 25 | +- **菜单级**:Redux `auth.user.permissions` 驱动;菜单项无权限直接不渲染。 | |
| 26 | +- **路由级**:`router/index.tsx` 用 `RequireAuth` + `RequireRole` 高阶守卫;未授权重定向到登录或 403 页。 | |
| 27 | +- **按钮级**:`<Permission code="...">` 包裹按钮,无权限时不渲染(不显示 disabled 灰按钮)。 | |
| 28 | +- **后端 RBAC 同源**:前端权限码与后端 `Permission` 表严格对齐(codename 一致);前端只做展示控制,最终鉴权由后端兜底。 | |
| 26 | 29 | |
| 27 | 30 | ## 二、Design Tokens |
| 28 | 31 | |
| 29 | -> 所有色值统一以 CSS 变量定义于 `src/styles/tokens.css`;命名规范见 docs/04 § 2.5。 | |
| 32 | +> 所有色值统一以 CSS 变量定义于 `frontend/src/styles/tokens.css`;命名规范见 docs/04 § 2.5。组件样式只引用 `var(--color-xxx)`,禁止硬编码 hex / rgba。 | |
| 30 | 33 | |
| 31 | 34 | ### 2.1 全局调色板 |
| 32 | 35 | |
| 33 | -与 Ant Design 5 主题对齐,统一通过 `<ConfigProvider theme={{ token: {...} }}>` 注入。 | |
| 36 | +与 Ant Design 5 默认主题对齐,按语义分组。 | |
| 34 | 37 | |
| 35 | 38 | | 语义 | 变量名 | 默认值 | 用途 | |
| 36 | 39 | |---|---|---|---| |
| 37 | -| 主色 | `--color-primary` | `#1677ff` | 主操作按钮 / 链接 / 选中态 | | |
| 38 | -| 成功 | `--color-success` | `#52c41a` | 成功消息 / 启用状态标签 | | |
| 39 | -| 警告 | `--color-warning` | `#faad14` | 警告消息 / 待处理状态 | | |
| 40 | -| 错误 | `--color-error` | `#ff4d4f` | 错误消息 / 危险按钮 / 禁用状态 | | |
| 41 | -| 主文字 | `--color-text-primary` | `rgba(0, 0, 0, 0.88)` | 正文 / 表格内容 | | |
| 42 | -| 次文字 | `--color-text-secondary` | `rgba(0, 0, 0, 0.65)` | 辅助说明 / 占位 | | |
| 43 | -| 边框 | `--color-border` | `#d9d9d9` | 表单输入 / 卡片边界 | | |
| 44 | -| 背景 | `--color-bg-base` | `#ffffff` | 页面主背景 | | |
| 45 | -| 弱背景 | `--color-bg-layout` | `#f5f5f5` | 页面 layout 间隙 / 灰底 | | |
| 40 | +| 主色 | `--color-primary` | `#1677ff` | 主按钮、链接、激活态边框 | | |
| 41 | +| 主色-悬浮 | `--color-primary-hover` | `#4096ff` | 主色控件 hover | | |
| 42 | +| 主色-激活 | `--color-primary-active` | `#0958d9` | 主色控件 active / pressed | | |
| 43 | +| 成功 | `--color-success` | `#52c41a` | 成功提示、积极状态标签 | | |
| 44 | +| 警告 | `--color-warning` | `#faad14` | 警告提示、需注意状态 | | |
| 45 | +| 错误 | `--color-error` | `#ff4d4f` | 错误提示、危险按钮 | | |
| 46 | +| 信息 | `--color-info` | `#1677ff` | 普通信息提示 | | |
| 47 | +| 主文字 | `--color-text` | `rgba(0,0,0,0.88)` | 标题、正文 | | |
| 48 | +| 次文字 | `--color-text-secondary` | `rgba(0,0,0,0.65)` | 辅助说明 | | |
| 49 | +| 禁用文字 | `--color-text-disabled` | `rgba(0,0,0,0.25)` | 禁用态 | | |
| 50 | +| 边框 | `--color-border` | `#d9d9d9` | 输入框 / 卡片边框 | | |
| 51 | +| 分割线 | `--color-split` | `#f0f0f0` | 列表分割线 | | |
| 52 | +| 背景-页面 | `--color-bg-page` | `#f5f5f5` | 页面底色 | | |
| 53 | +| 背景-容器 | `--color-bg-container` | `#ffffff` | 卡片 / 表单 / Modal | | |
| 54 | +| 背景-禁用 | `--color-bg-disabled` | `#f5f5f5` | 禁用控件 | | |
| 46 | 55 | |
| 47 | 56 | ### 2.2 组件级状态色 |
| 48 | 57 | |
| 49 | -| 序号 | 组件 | 编辑bg | 只读bg | 悬浮bg | 编辑fg | 只读fg | 悬浮fg | 备注 | | |
| 50 | -|---|---|---|---|---|---|---|---|---| | |
| 51 | -| 1 | 表单输入框 | `var(--color-bg-base)` | `var(--color-bg-layout)` | `var(--color-bg-base)` | `var(--color-text-primary)` | `var(--color-text-secondary)` | `var(--color-text-primary)` | 只读态边框使用 `transparent` | | |
| 52 | -| 2 | 下拉单选 | `var(--color-bg-base)` | `var(--color-bg-layout)` | `var(--color-bg-base)` | `var(--color-text-primary)` | `var(--color-text-secondary)` | `var(--color-text-primary)` | — | | |
| 53 | -| 3 | 表格行 | — | `var(--color-bg-base)` | `var(--color-bg-row-hover)` | — | `var(--color-text-primary)` | `var(--color-text-primary)` | 编辑态走 Modal,行内不编辑 | | |
| 54 | -| 4 | 主按钮 | `var(--color-primary)` | `var(--color-bg-layout)` | `var(--color-primary-hover)` | `#ffffff` | `var(--color-text-secondary)` | `#ffffff` | 危险按钮替换 `--color-error` | | |
| 55 | -| 5 | 链接 | — | — | `var(--color-primary-hover)` | `var(--color-primary)` | — | `var(--color-primary-hover)` | — | | |
| 58 | +按场景 × 状态映射,单元格写 token 引用形式(`var(--color-xxx)`)。`—` 表示该状态不适用。 | |
| 56 | 59 | |
| 57 | -**Token 默认值**: | |
| 60 | +| # | 组件 | 编辑bg | 只读bg | 悬浮bg | 编辑fg | 只读fg | 悬浮fg | 备注 | | |
| 61 | +|---|---|---|---|---|---|---|---|---| | |
| 62 | +| 1 | Input | `var(--color-bg-container)` | `var(--color-bg-disabled)` | `var(--color-bg-container)` | `var(--color-text)` | `var(--color-text-secondary)` | `var(--color-text)` | 只读用 `readOnly`,禁用用 `disabled` | | |
| 63 | +| 2 | Select | `var(--color-bg-container)` | `var(--color-bg-disabled)` | `var(--color-bg-container)` | `var(--color-text)` | `var(--color-text-secondary)` | `var(--color-text)` | 同 Input | | |
| 64 | +| 3 | Button-Primary | `var(--color-primary)` | — | `var(--color-primary-hover)` | `#fff` | — | `#fff` | 主操作 | | |
| 65 | +| 4 | Button-Default | `var(--color-bg-container)` | — | `var(--color-bg-container)` | `var(--color-text)` | — | `var(--color-primary)` | 次操作 | | |
| 66 | +| 5 | Button-Danger | `var(--color-error)` | — | `#ff7875` | `#fff` | — | `#fff` | 删除 / 停用 | | |
| 67 | +| 6 | Table-Row | `var(--color-bg-container)` | — | `#fafafa` | `var(--color-text)` | — | `var(--color-text)` | 斑马纹关闭 | | |
| 68 | +| 7 | Tag-Success | `#f6ffed` | — | — | `var(--color-success)` | — | — | 正常 / 启用状态 | | |
| 69 | +| 8 | Tag-Error | `#fff2f0` | — | — | `var(--color-error)` | — | — | 停用 / 失败状态 | | |
| 58 | 70 | |
| 59 | -| Token | 默认值 | | |
| 60 | -|---|---| | |
| 61 | -| `--color-primary-hover` | `#4096ff` | | |
| 62 | -| `--color-bg-row-hover` | `#fafafa` | | |
| 71 | +**Token 默认值**(与 § 2.1 完整一致;新增 token 时此表与 tokens.css 同步更新)。 | |
| 63 | 72 | |
| 64 | 73 | ### 2.3 引用约定 |
| 65 | 74 | |
| 66 | -- 组件样式只用 `var(--color-xxx)`,禁止硬编码 hex / rgba。 | |
| 67 | -- 新增 token 须先登记到 § 2.1 / 2.2 再补 `tokens.css`。 | |
| 68 | -- 修改色值只改 `tokens.css` 一处,不允许组件覆盖。 | |
| 75 | +- 组件样式只用 `var(--color-xxx)`,**禁止**直接写 hex / rgb / rgba。 | |
| 76 | +- 新增 token 必须先登记到 § 2.1 / 2.2,再写入 `tokens.css`。 | |
| 77 | +- 修改色值只改 `tokens.css` 一处;组件层不允许覆盖(如 `.ant-btn-primary { background: ... }` 之类禁止)。 | |
| 69 | 78 | |
| 70 | 79 | ## 三、页面清单 |
| 80 | +(由 `downstream-gen` 按模块追加段落) | |
| 71 | 81 | |
| 72 | -### module_usr USR-用户管理 | |
| 82 | +### module_usr 用户管理 | |
| 73 | 83 | |
| 74 | 84 | - **登录页** (`/login`) |
| 75 | 85 | - 类型: 表单页 |
| 76 | 86 | - 对应 REQ: REQ-USR-001 |
| 77 | - - 入口菜单: 无(未登录态唯一可达页面) | |
| 78 | - - 主要交互: 表单输入 用户名 / 密码 / 版本 → 提交 → 成功跳首页 / 失败留页并显示通用错误消息;账号锁定时显示锁定提示 | |
| 79 | -- **用户列表页** (`/usr/users`) | |
| 87 | + - 入口菜单: 未登录访问任何路径均重定向至此 | |
| 88 | + - 主要交互: 公司/版本下拉(页面加载时拉取 `GET /api/v1/companies`)→ 输入用户名 / 密码(密码星号显示)→ 提交按钮带 loading 态防重复提交;连续 5 次失败显示锁定剩余时间;成功后写 access token 到内存并跳转默认主页 | |
| 89 | + | |
| 90 | +- **用户列表** (`/users`) | |
| 80 | 91 | - 类型: 列表页 |
| 81 | - - 对应 REQ: REQ-USR-004 | |
| 82 | - - 入口菜单: 系统管理 → 用户管理 → 用户列表 | |
| 83 | - - 主要交互: 查询字段 + 匹配方式 + 查询值 三段筛选 → 表格分页展示 → 行内「编辑」「禁用 / 启用」按钮(权限码 `USR:EDIT`);顶部「新增用户」按钮(权限码 `USR:ADD`)跳新增表单 | |
| 84 | -- **用户新增页** (`/usr/users/new`) | |
| 85 | - - 类型: 表单页 | |
| 86 | - - 对应 REQ: REQ-USR-002 | |
| 87 | - - 入口菜单: 系统管理 → 用户管理 → 用户列表 → 新增按钮 | |
| 88 | - - 主要交互: 员工名(下拉单选)选择后自动回填用户号 / 用户名;类型 / 语言下拉;权限组表格(多选);提交校验失败显示字段级 `validateStatus="error"`;成功 message.success 后回列表页 | |
| 89 | -- **用户编辑页** (`/usr/users/:id/edit`) | |
| 90 | - - 类型: 表单页 | |
| 91 | - - 对应 REQ: REQ-USR-003 | |
| 92 | - - 入口菜单: 用户列表 → 行内「编辑」按钮 | |
| 93 | - - 主要交互: 复用新增表单组件;用户名只读;提供「重置密码」按钮(二次确认);提供「禁用 / 启用」开关;禁用自己时按钮置灰 | |
| 92 | + - 对应 REQ: REQ-USR-002("新增"入口)、REQ-USR-003("编辑"行内按钮)、REQ-USR-004(列表分页 + 筛选) | |
| 93 | + - 入口菜单: 系统管理 → 用户管理 | |
| 94 | + - 主要交互: 顶部筛选区(查询字段下拉 / 匹配方式下拉 / 查询值输入框 / 查询按钮)+ 右上"新增用户"按钮 + Ant Design Table(含序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废标签 / 登录日期 / 制单人 / 制单日期 / 操作列)+ 分页器(10/20/50/100);编辑按钮打开 Modal 复用新增表单组件(按 REQ-USR-003 字段差异置只读 / 禁用);不允许停用当前登录账号自己(按钮 disabled + tooltip 解释) | |
| 95 | + | |
| 96 | +- **用户表单**(嵌入式 Modal,无独立路由) | |
| 97 | + - 类型: 表单页(Modal) | |
| 98 | + - 对应 REQ: REQ-USR-002 / REQ-USR-003 | |
| 99 | + - 入口菜单: 由用户列表的"新增" / "编辑"按钮触发 | |
| 100 | + - 主要交互: 左侧基础信息区(员工名下拉 / 用户号 / 用户名 / 类型下拉 / 语言下拉 / 单据修改权限复选框 / 作废复选框(仅编辑))+ 右侧权限组区(按 `sys_permission_category` 渲染勾选列表)+ 底部"保存"/"取消";保存调 `POST /api/v1/users`(新增)或 `PUT /api/v1/users/{userId}`(编辑),成功后关闭 Modal 并刷新列表 | ... | ... |
docs/07-环境配置.md
| ... | ... | @@ -4,43 +4,46 @@ |
| 4 | 4 | |
| 5 | 5 | | 层 | 依赖 | 版本 | 说明 | |
| 6 | 6 | |---|---|---|---| |
| 7 | -| 运行时 | Java (JDK) | 17 / 21 | Spring Boot 3 推荐版本 | | |
| 8 | -| 运行时 | MySQL | 8.x | 关系数据库 | | |
| 9 | -| 运行时 | Redis | 最新稳定版 | 缓存 / 会话 | | |
| 10 | -| 运行时 | Node.js | 20.x LTS | 前端构建 + 本地 dev server | | |
| 11 | -| 构建(后端) | Maven | 3.9.x | Java 依赖与构建工具 | | |
| 12 | -| 构建(前端) | pnpm | 8.x(或 npm 10.x) | 前端依赖管理 | | |
| 13 | -| 构建(前端) | Vite | 最新稳定版 | 前端开发与打包 | | |
| 14 | -| 容器 | Docker | 最新稳定版 | 容器化部署 | | |
| 15 | -| 容器 | Docker Compose | 最新稳定版 | 本地一键启依赖(MySQL + Redis) | | |
| 16 | -| 反向代理 | Nginx | 最新稳定版 | 前端静态托管 / 反向代理 | | |
| 17 | -| CLI 工具 | git | 2.30+ | 代码版本控制 | | |
| 18 | -| CLI 工具 | mysql client | 8.x | 本地执行 SQL / 验证 migration | | |
| 19 | -| CLI 工具 | glab(可选) | 最新稳定版 | GitLab MR 创建(亦可直接走 Web) | | |
| 7 | +| 运行时 | Java JDK | 17 或 21 | Spring Boot 3.x 运行环境 | | |
| 8 | +| 运行时 | Node.js | 20.x LTS | Vite 5 / React 18 构建运行 | | |
| 9 | +| 运行时 | MySQL | 8.x | 关系型数据库 | | |
| 10 | +| 运行时 | Redis | 最新稳定 | 缓存 / 会话 | | |
| 11 | +| 运行时 | Nginx | 最新稳定 | 反向代理、前端静态托管(生产) | | |
| 12 | +| 构建 | Maven | 3.9.x | 后端依赖管理与构建 | | |
| 13 | +| 构建 | npm(或 pnpm) | 与 Node 配套 | 前端包管理器 | | |
| 14 | +| 构建 | Flyway core + mysql | 10.x | Spring Boot 启动时自动 apply migration | | |
| 15 | +| 构建 | Vite | 最新稳定 | 前端打包 | | |
| 16 | +| 容器 | Docker | 最新稳定 | 镜像构建与本地编排 | | |
| 17 | +| 容器 | docker-compose | 最新稳定 | 本地 mysql / redis 编排 | | |
| 18 | +| CLI 工具 | git | ≥ 2.40 | 版本控制 | | |
| 19 | +| CLI 工具 | mysql client | 8.x | `scripts/setup-test-db.sh` 调用 | | |
| 20 | +| CLI 工具 | curl / jq | 最新稳定 | API 调试、脚本辅助 | | |
| 20 | 21 | |
| 21 | 22 | ## 二、端口约定 |
| 22 | 23 | |
| 23 | 24 | | 服务 | 端口 | 说明 | |
| 24 | 25 | |---|---|---| |
| 25 | -| 后端 HTTP | 8080 | Spring Boot 默认端口(`server.port`) | | |
| 26 | -| 前端 dev server | 5173 | Vite 默认端口(`vite --port`) | | |
| 27 | -| MySQL | 3306 | 默认端口,本地开发可用 Docker Compose 暴露 | | |
| 28 | -| Redis | 6379 | 默认端口 | | |
| 29 | -| Nginx | 80 / 443 | 生产部署反向代理入口 | | |
| 26 | +| 后端 HTTP | 9090 | 项目锁定(避开常见 8080 冲突) | | |
| 27 | +| 前端 dev server | 5173 | Vite 默认 | | |
| 28 | +| MySQL | 3306 | 主库 | | |
| 29 | +| MySQL(测试) | 3307 | 本地独立测试库(避免与开发库冲突,可选) | | |
| 30 | +| Redis | 6379 | 默认 | | |
| 31 | +| Nginx | 80 / 443 | 生产反代 | | |
| 30 | 32 | |
| 31 | 33 | ## 三、环境变量 |
| 32 | 34 | |
| 33 | -运行时凭据(数据库连接、JWT 密钥等)全部放在仓库根的 `.env.local`,不入 git。 | |
| 34 | -字段清单与占位符见该文件,真实值由开发者本地填写。 | |
| 35 | +运行时凭据(数据库连接、JWT 密钥、Redis 密码等)全部放在仓库根的 `.env.local`,**不入 git**(由 `.gitignore` 强制忽略)。 | |
| 36 | + | |
| 37 | +字段清单与占位符见 `.env.local`,真实值由开发者本地填写。CC 不读取 `.env.local` 的真实值;任何引用都用 `${VAR_NAME}` 形式。 | |
| 35 | 38 | |
| 36 | 39 | ## 四、常用命令 |
| 37 | 40 | |
| 38 | 41 | | 命令 | 说明 | |
| 39 | 42 | |---|---| |
| 40 | -| `./mvnw spring-boot:run` | 本地启动后端(含 Flyway 自动 apply migration) | | |
| 41 | -| `pnpm dev` | 本地启动前端 dev server(默认 5173) | | |
| 42 | -| `./mvnw clean package -DskipTests` | 后端打包生成 jar | | |
| 43 | -| `pnpm build` | 前端打包到 `dist/` | | |
| 44 | -| `bash scripts/test.sh` | 执行后端 + 前端测试组合(lint / unit / e2e) | | |
| 45 | -| `bash scripts/setup-test-db.sh` | 重置本地测试数据库(DROP + CREATE + apply V1) | | |
| 46 | -| `glab mr create` | 推送当前分支并创建 GitLab MR(亦可走 Web 端) | | |
| 43 | +| `cd backend && ./mvnw spring-boot:run` | 启动后端服务(监听 8080) | | |
| 44 | +| `cd frontend && npm run dev` | 启动前端 dev server(监听 5173) | | |
| 45 | +| `cd backend && ./mvnw clean package` | 后端打包(生成 jar) | | |
| 46 | +| `cd frontend && npm run build` | 前端打包(生成 dist/) | | |
| 47 | +| `bash scripts/test.sh` | 跑后端 + 前端全部测试(test-gate 入口) | | |
| 48 | +| `bash scripts/setup-test-db.sh` | DROP+CREATE 测试库(Flyway V1 会在测试启动时 apply) | | |
| 49 | +| `glab mr create` 或 `gh pr create` | 创建 MR / PR(mr-create skill 调用) | | ... | ... |
docs/08-模块任务管理.md
| ... | ... | @@ -55,15 +55,15 @@ |
| 55 | 55 | - [ ] REQ-SYS-002 用户注册 |
| 56 | 56 | --> |
| 57 | 57 | |
| 58 | -- module_usr USR-用户管理 | |
| 58 | +- module_usr 用户管理 | |
| 59 | 59 | - 依赖: — |
| 60 | - - 路径: backend/src/main/java/com/example/erp/module/usr/ | |
| 61 | - - MR: — | |
| 60 | + - 路径: backend/module/usr/ | |
| 61 | + - MR: !1 | |
| 62 | 62 | - 功能: |
| 63 | - - [ ] REQ-USR-001 用户登录 | |
| 64 | - - [ ] REQ-USR-002 新增用户 | |
| 65 | - - [ ] REQ-USR-003 修改用户 | |
| 66 | - - [ ] REQ-USR-004 查询用户 | |
| 63 | + - [x] REQ-USR-001 用户登录 | |
| 64 | + - [x] REQ-USR-002 新增用户 | |
| 65 | + - [x] REQ-USR-003 修改用户 | |
| 66 | + - [x] REQ-USR-004 查询用户 | |
| 67 | 67 | |
| 68 | 68 | ## 三、Coding 阶段(前端整体) |
| 69 | 69 | ... | ... |
docs/09-项目目录结构.md
| ... | ... | @@ -4,93 +4,108 @@ |
| 4 | 4 | |
| 5 | 5 | ``` |
| 6 | 6 | . |
| 7 | -├── CLAUDE.md # 项目级规范与流程指令 | |
| 8 | -├── README.md # 项目说明(可选) | |
| 9 | -├── .env.local # 本地凭据(不入 git) | |
| 7 | +├── CLAUDE.md # Claude Code 主指令 | |
| 8 | +├── README.md # 项目说明 | |
| 9 | +├── .env.local # 本地凭据(不入 git) | |
| 10 | 10 | ├── .gitignore |
| 11 | -├── .githooks/ | |
| 12 | -│ └── pre-push # 推送前自动跑 scripts/test.sh | |
| 13 | -├── scripts/ | |
| 14 | -│ ├── test.sh # 后端 + 前端测试组合入口 | |
| 15 | -│ └── setup-test-db.sh # 重置本地测试数据库 | |
| 11 | +├── .githooks/ # 仓库级 git hooks(core.hooksPath 指向) | |
| 12 | +│ └── pre-push | |
| 13 | +├── scripts/ # 项目脚本(test.sh / setup-test-db.sh 等) | |
| 14 | +├── docs/ # 全部规划与设计文档 | |
| 15 | +├── prototype/ # 前端 HTML mockup(前端阶段的布局权威) | |
| 16 | 16 | ├── sql/ |
| 17 | -│ └── migrations/ # Flyway V*__*.sql 文件 | |
| 18 | -├── docs/ # 全量项目文档(见 § 四) | |
| 19 | -├── prototype/ # 静态 HTML mockup(前端实现权威) | |
| 20 | -├── backend/ # 后端工程(Spring Boot + Maven) | |
| 21 | -└── frontend/ # 前端工程(Vite + React) | |
| 17 | +│ └── migrations/ # Flyway V_n__*.sql | |
| 18 | +├── backend/ # Spring Boot 服务 | |
| 19 | +│ ├── pom.xml | |
| 20 | +│ └── src/ | |
| 21 | +└── frontend/ # Vite + React 应用 | |
| 22 | + ├── package.json | |
| 23 | + ├── vite.config.ts | |
| 24 | + └── src/ | |
| 22 | 25 | ``` |
| 23 | 26 | |
| 24 | 27 | ## 二、后端目录 |
| 25 | 28 | |
| 29 | +后端按 Spring Boot 3 + MyBatis-Plus 的惯例组织;业务代码按 docs/01 模块索引落到 `module/<module_code_lower>/`。 | |
| 30 | + | |
| 26 | 31 | ``` |
| 27 | 32 | backend/ |
| 28 | 33 | ├── pom.xml |
| 29 | -└── src/ | |
| 30 | - ├── main/ | |
| 31 | - │ ├── java/ | |
| 32 | - │ │ └── com.example.erp/ | |
| 33 | - │ │ ├── ErpApplication.java | |
| 34 | - │ │ ├── common/ # 全局响应 / 异常 / 拦截器 / 工具 | |
| 35 | - │ │ │ ├── result/ | |
| 36 | - │ │ │ ├── exception/ | |
| 37 | - │ │ │ ├── interceptor/ | |
| 38 | - │ │ │ └── util/ | |
| 39 | - │ │ ├── config/ # Spring 配置类(Security / Redis / MyBatis-Plus / Swagger / Activiti) | |
| 40 | - │ │ ├── module/ # 业务模块(按 docs/01 索引拆分) | |
| 41 | - │ │ │ └── usr/ # USR 用户管理 | |
| 42 | - │ │ │ ├── controller/ | |
| 43 | - │ │ │ ├── service/ | |
| 44 | - │ │ │ │ └── impl/ | |
| 45 | - │ │ │ ├── mapper/ | |
| 46 | - │ │ │ ├── entity/ | |
| 47 | - │ │ │ ├── dto/ | |
| 48 | - │ │ │ └── vo/ | |
| 49 | - │ │ └── security/ # 认证 / 鉴权 / JWT | |
| 50 | - │ └── resources/ | |
| 51 | - │ ├── application.yml | |
| 52 | - │ ├── application-dev.yml | |
| 53 | - │ ├── application-prod.yml | |
| 54 | - │ ├── mapper/ # MyBatis XML(按模块再分子目录) | |
| 55 | - │ └── logback-spring.xml | |
| 56 | - └── test/ | |
| 57 | - └── java/ | |
| 58 | - └── com.example.erp/ | |
| 59 | - └── module/ | |
| 60 | - └── usr/ # 与 main/ 镜像 | |
| 34 | +├── src/ | |
| 35 | +│ ├── main/ | |
| 36 | +│ │ ├── java/ | |
| 37 | +│ │ │ └── com/xly/erp/ | |
| 38 | +│ │ │ ├── Application.java # Spring Boot 启动类 | |
| 39 | +│ │ │ ├── common/ # 跨模块基础组件 | |
| 40 | +│ │ │ │ ├── response/ # 统一响应包装(Result / PageResult) | |
| 41 | +│ │ │ │ ├── exception/ # 全局异常处理 + 业务异常 | |
| 42 | +│ │ │ │ ├── security/ # Spring Security / JWT 配置 | |
| 43 | +│ │ │ │ ├── config/ # 通用配置(CORS / Swagger / MyBatis-Plus 等) | |
| 44 | +│ │ │ │ └── util/ # 工具类 | |
| 45 | +│ │ │ └── module/ | |
| 46 | +│ │ │ └── usr/ # 用户管理(REQ-USR-*) | |
| 47 | +│ │ │ ├── controller/ | |
| 48 | +│ │ │ ├── service/ | |
| 49 | +│ │ │ ├── service/impl/ | |
| 50 | +│ │ │ ├── mapper/ | |
| 51 | +│ │ │ ├── entity/ | |
| 52 | +│ │ │ ├── dto/ # Request DTO | |
| 53 | +│ │ │ ├── vo/ # Response VO | |
| 54 | +│ │ │ └── converter/ # MapStruct DTO/VO/Entity 转换 | |
| 55 | +│ │ └── resources/ | |
| 56 | +│ │ ├── application.yml # 主配置 | |
| 57 | +│ │ ├── application-dev.yml # 开发环境 | |
| 58 | +│ │ ├── application-test.yml # 测试环境 | |
| 59 | +│ │ ├── mapper/ # MyBatis XML(与 module 子目录对应) | |
| 60 | +│ │ │ └── usr/ | |
| 61 | +│ │ └── logback-spring.xml | |
| 62 | +│ └── test/ | |
| 63 | +│ └── java/ | |
| 64 | +│ └── com/xly/erp/ | |
| 65 | +│ └── module/ | |
| 66 | +│ └── usr/ # 单元测试 + 集成测试 | |
| 67 | +└── target/ # Maven 构建产物(gitignore) | |
| 61 | 68 | ``` |
| 62 | 69 | |
| 70 | +> Flyway migration 不在 backend/ 下,统一在仓库根 `sql/migrations/`,由 Spring Boot 启动时自动 apply(见 docs/04 § Schema 演化规约)。 | |
| 71 | + | |
| 63 | 72 | ## 三、前端目录 |
| 64 | 73 | |
| 74 | +前端按 Vite + React 18 + Ant Design 5 + Redux Toolkit + React Router v6 的惯例组织;业务页面按模块分组放在 `pages/<module>/`。 | |
| 75 | + | |
| 65 | 76 | ``` |
| 66 | 77 | frontend/ |
| 67 | 78 | ├── package.json |
| 68 | 79 | ├── vite.config.ts |
| 69 | 80 | ├── tsconfig.json |
| 70 | 81 | ├── index.html |
| 71 | -└── src/ | |
| 72 | - ├── main.tsx # 入口(挂载 React + Router + Store + ConfigProvider) | |
| 73 | - ├── App.tsx # 顶层路由 + 全局 Layout | |
| 74 | - ├── api/ # Axios 实例 + 各模块 API(数据访问统一入口) | |
| 75 | - │ └── usr.ts # USR 用户管理 API | |
| 76 | - ├── components/ # 跨页面通用组件(AuthButton / AppTable / PageHeader 等) | |
| 77 | - ├── pages/ # 按业务模块组织页面 | |
| 78 | - │ └── usr/ # USR 用户管理 | |
| 79 | - │ ├── UserList.tsx | |
| 80 | - │ ├── UserEdit.tsx | |
| 81 | - │ └── Login.tsx | |
| 82 | - ├── store/ # Redux Toolkit slices(全局状态) | |
| 83 | - │ └── slices/ | |
| 84 | - ├── hooks/ # 自定义 hook(useAuth / usePagination 等) | |
| 85 | - ├── utils/ # 工具函数(formatDate / formatMoney / regex 等) | |
| 86 | - ├── styles/ | |
| 87 | - │ ├── tokens.css # Design Tokens(docs/06 § 二) | |
| 88 | - │ └── global.css # 全局样式 reset / typography | |
| 89 | - ├── router/ # 路由表(含 meta.code 权限码) | |
| 90 | - └── assets/ # 静态资源(图片 / 字体) | |
| 82 | +├── src/ | |
| 83 | +│ ├── main.tsx # 入口 | |
| 84 | +│ ├── App.tsx # 根组件 + 路由 | |
| 85 | +│ ├── api/ # Axios 实例 + 接口调用层 | |
| 86 | +│ │ ├── client.ts # axios 实例 + 拦截器(401 / 错误统一处理) | |
| 87 | +│ │ └── usr.ts # 用户管理接口(按模块拆分) | |
| 88 | +│ ├── store/ # Redux Toolkit | |
| 89 | +│ │ ├── index.ts # configureStore | |
| 90 | +│ │ └── slices/ | |
| 91 | +│ │ └── auth.ts # 登录态 slice | |
| 92 | +│ ├── router/ # React Router 配置 | |
| 93 | +│ │ └── index.tsx | |
| 94 | +│ ├── pages/ # 页面(按模块分组) | |
| 95 | +│ │ ├── login/ # FE-NN 登录页 | |
| 96 | +│ │ └── usr/ # 用户管理页面 | |
| 97 | +│ ├── components/ # 跨页面通用组件 | |
| 98 | +│ ├── layouts/ # 框架布局(侧栏 / 顶栏 / 内容区) | |
| 99 | +│ ├── hooks/ # 自定义 hook | |
| 100 | +│ ├── utils/ # 工具方法 | |
| 101 | +│ ├── styles/ | |
| 102 | +│ │ └── tokens.css # Design Tokens(docs/06 § 二) | |
| 103 | +│ └── types/ # 全局 TypeScript 类型 | |
| 104 | +├── tests/ # Playwright E2E | |
| 105 | +└── dist/ # Vite 构建产物(gitignore) | |
| 91 | 106 | ``` |
| 92 | 107 | |
| 93 | -> 注:仓库根 `src/styles/tokens.css` 由 skeleton-gen 创建,作为 Design Tokens 的「上游」源;前端工程化前可先保留在根 `src/styles/` 下,前端工程初始化时迁入 `frontend/src/styles/`(同名同内容)。 | |
| 108 | +> Vitest 组件测试与对应源码同级(`*.test.tsx`)。 | |
| 94 | 109 | |
| 95 | 110 | ## 四、docs/ 结构 |
| 96 | 111 | |
| ... | ... | @@ -111,14 +126,37 @@ docs/ |
| 111 | 126 | |
| 112 | 127 | ## 五、命名与放置约定 |
| 113 | 128 | |
| 114 | -- **后端根包**:`com.example.erp`,下文统称 `<ROOT>`;新增业务模块时落到 `<ROOT>.module.<模块代码小写>`(如 `<ROOT>.module.usr`)。 | |
| 115 | -- **Controller**:`<ROOT>.module.<m>.controller.<Module>Controller`,文件名首字母大写驼峰,URI 前缀 `/api/<模块代码小写>`。 | |
| 116 | -- **Service**:接口 `<Module>Service` + 实现 `<Module>ServiceImpl`,实现类放 `service/impl/`。 | |
| 117 | -- **Mapper**:`<Module>Mapper`(Java 接口)+ `resources/mapper/<m>/<Module>Mapper.xml`(XML 同名)。 | |
| 118 | -- **Entity / DTO / VO**: | |
| 119 | - - `entity/` 数据库实体(与表 1:1,字段类型同 docs/03) | |
| 120 | - - `dto/` 入参(前端 → 后端的请求体) | |
| 121 | - - `vo/` 出参(后端 → 前端的响应体) | |
| 122 | -- **前端组件**:通用组件放 `frontend/src/components/`,文件名首字母大写驼峰(`AuthButton.tsx`);样式同名 `.module.css`(启用 CSS Modules)。 | |
| 123 | -- **前端页面**:放 `frontend/src/pages/<模块代码小写>/`,文件名按业务功能命名(`UserList.tsx` / `UserEdit.tsx`),路由 path 前缀 `/<模块代码小写>`。 | |
| 124 | -- **API 客户端**:每个模块一个文件 `frontend/src/api/<模块代码小写>.ts`,导出该模块所有接口函数;禁止在组件里直接 `axios.xxx`。 | |
| 129 | +### 5.1 根包 / 命名空间 | |
| 130 | + | |
| 131 | +- **Java 根包**: `com.xly.erp` | |
| 132 | +- **前端 npm 包名**: `xly-erp-frontend` | |
| 133 | + | |
| 134 | +### 5.2 后端文件放置 | |
| 135 | + | |
| 136 | +| 类型 | 路径 | 命名 | | |
| 137 | +|---|---|---| | |
| 138 | +| Controller | `module/<m>/controller/<Entity>Controller.java` | 大驼峰 + `Controller` 后缀 | | |
| 139 | +| Service 接口 | `module/<m>/service/<Entity>Service.java` | 大驼峰 + `Service` 后缀 | | |
| 140 | +| Service 实现 | `module/<m>/service/impl/<Entity>ServiceImpl.java` | 大驼峰 + `ServiceImpl` 后缀 | | |
| 141 | +| Mapper 接口 | `module/<m>/mapper/<Entity>Mapper.java` | 大驼峰 + `Mapper` 后缀 | | |
| 142 | +| Mapper XML | `resources/mapper/<m>/<Entity>Mapper.xml` | 与接口同名 | | |
| 143 | +| Entity | `module/<m>/entity/<Entity>.java` | 大驼峰,业务实体名 | | |
| 144 | +| DTO(请求) | `module/<m>/dto/<Action><Entity>Req.java` | 动词 + 实体 + `Req`,如 `CreateUserReq` | | |
| 145 | +| VO(响应) | `module/<m>/vo/<Entity>Vo.java` 或 `<Entity>DetailVo.java` | 大驼峰 + `Vo` 后缀 | | |
| 146 | +| Converter | `module/<m>/converter/<Entity>Converter.java` | MapStruct 接口 | | |
| 147 | + | |
| 148 | +### 5.3 前端文件放置 | |
| 149 | + | |
| 150 | +| 类型 | 路径 | 命名 | | |
| 151 | +|---|---|---| | |
| 152 | +| 页面 | `pages/<module>/<PageName>.tsx` | 大驼峰,与路由对齐 | | |
| 153 | +| 通用组件 | `components/<ComponentName>/index.tsx` | 大驼峰 | | |
| 154 | +| 接口调用 | `api/<module>.ts` | 小写模块名 | | |
| 155 | +| Redux slice | `store/slices/<feature>.ts` | 小驼峰 | | |
| 156 | +| 测试 | 与源文件同级 `<X>.test.tsx`(组件) / `tests/<X>.spec.ts`(E2E) | 与被测对象对齐 | | |
| 157 | + | |
| 158 | +### 5.4 通用规则 | |
| 159 | + | |
| 160 | +- 所有 Java 包名小写;类名大驼峰;方法 / 字段小驼峰;常量全大写下划线。 | |
| 161 | +- TypeScript / TSX 文件用大驼峰命名组件,其他用小驼峰。 | |
| 162 | +- 业务模块代码与 docs/01 模块代码(USR / PUR / ...)保持一致,小写下划线作为目录名(`module/usr/`)。 | ... | ... |
docs/superpowers/module-reports/2026-05-15-module_usr.md
0 → 100644
| 1 | +--- | |
| 2 | +module_id: module_usr | |
| 3 | +date: 2026-05-15 | |
| 4 | +git_range: 76218f3 (bootstrap spring boot 后端骨架) ↔ 4d58768 (test-gate evidence) | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# 模块完成报告 — module_usr 用户管理 | |
| 8 | + | |
| 9 | +## ① 模块信息 | |
| 10 | +- 模块 ID: module_usr | |
| 11 | +- 模块名: 用户管理 | |
| 12 | +- 开发区间: 2026-05-15 单日(REQ-USR-001 → 002 → 003 → 004) | |
| 13 | +- 分支: module-module_usr | |
| 14 | + | |
| 15 | +## ② REQ 完成清单 | |
| 16 | + | |
| 17 | +- [x] REQ-USR-001 — 用户登录 | |
| 18 | + - spec: docs/superpowers/specs/2026-05-15-REQ-USR-001.md | |
| 19 | + - plan: docs/superpowers/plans/2026-05-15-REQ-USR-001.md | |
| 20 | + - review: docs/superpowers/reviews/2026-05-15-REQ-USR-001.md | |
| 21 | +- [x] REQ-USR-002 — 新增用户 | |
| 22 | + - spec: docs/superpowers/specs/2026-05-15-REQ-USR-002.md | |
| 23 | + - plan: docs/superpowers/plans/2026-05-15-REQ-USR-002.md | |
| 24 | + - review: docs/superpowers/reviews/2026-05-15-REQ-USR-002.md | |
| 25 | +- [x] REQ-USR-003 — 修改用户(含 GET 详情) | |
| 26 | + - spec: docs/superpowers/specs/2026-05-15-REQ-USR-003.md | |
| 27 | + - plan: docs/superpowers/plans/2026-05-15-REQ-USR-003.md | |
| 28 | + - review: docs/superpowers/reviews/2026-05-15-REQ-USR-003.md | |
| 29 | +- [x] REQ-USR-004 — 查询用户 | |
| 30 | + - spec: docs/superpowers/specs/2026-05-15-REQ-USR-004.md | |
| 31 | + - plan: docs/superpowers/plans/2026-05-15-REQ-USR-004.md | |
| 32 | + - review: docs/superpowers/reviews/2026-05-15-REQ-USR-004.md | |
| 33 | + | |
| 34 | +## ③ 文件变更表 | |
| 35 | + | |
| 36 | +| 文件 | 操作 | 说明 | | |
| 37 | +|---|---|---| | |
| 38 | +| `backend/pom.xml` | Create | Spring Boot 3.3 + MyBatis-Plus + Flyway + JJWT + BCrypt 依赖 | | |
| 39 | +| `backend/src/main/java/com/xly/erp/Application.java` | Create | 启动类 + @MapperScan | | |
| 40 | +| `backend/src/main/resources/application.yml` / `application-test.yml` | Create | DB / JWT 配置 + Jackson 严格反序列化 + MyBatis mapper-locations | | |
| 41 | +| `backend/src/main/resources/logback-spring.xml` | Create | Logback 基础配置 | | |
| 42 | +| `backend/src/main/resources/mapper/usr/SysUserMapper.xml` | Create | REQ-004 动态 SQL JOIN + WHERE | | |
| 43 | +| `backend/src/main/java/com/xly/erp/common/response/{Result,ErrorCode,PageResult}.java` | Create | 统一响应包装 + 错误码(含 40001/40003/40004/40101/40103/40301/40302/40401/40901/40902/42301)+ 通用分页 VO | | |
| 44 | +| `backend/src/main/java/com/xly/erp/common/exception/{BizException,GlobalExceptionHandler}.java` | Create | 业务异常 + 全局 @RestControllerAdvice | | |
| 45 | +| `backend/src/main/java/com/xly/erp/common/security/{JwtUtil,LoginContext,RequireSuperAdmin,JwtHandlerInterceptor}.java` | Create | JWT 工具 + ThreadLocal + 角色守卫注解 + 鉴权拦截器 | | |
| 46 | +| `backend/src/main/java/com/xly/erp/common/config/{PasswordEncoderConfig,WebMvcConfig}.java` | Create | BCrypt Bean + interceptor 注册 | | |
| 47 | +| `backend/src/main/java/com/xly/erp/module/usr/entity/Sys*.java` | Create | SysUser / SysCompany / SysEmployee / SysPermissionCategory / SysUserPermissionCategory 5 张表实体 | | |
| 48 | +| `backend/src/main/java/com/xly/erp/module/usr/mapper/Sys*Mapper.java` + `UserQueryParams.java` | Create | 5 个 mapper + REQ-004 查询参数 DTO | | |
| 49 | +| `backend/src/main/java/com/xly/erp/module/usr/dto/{LoginReq,CreateUserReq,UpdateUserReq,UserQueryReq}.java` | Create | 4 个请求 DTO | | |
| 50 | +| `backend/src/main/java/com/xly/erp/module/usr/vo/{LoginVo,UserInfoVo,CreateUserVo,UserDetailVo,UserListItemVo}.java` | Create | 5 个响应 VO | | |
| 51 | +| `backend/src/main/java/com/xly/erp/module/usr/service/*Service.java` + `impl/*ServiceImpl.java` | Create | LoginService / UserCreateService / UserDetailService / UserUpdateService / UserListService | | |
| 52 | +| `backend/src/main/java/com/xly/erp/module/usr/controller/{AuthController,UserController}.java` | Create | 5 个 HTTP 端点(POST /auth/login,POST/GET/PUT /users,GET /users/{id}) | | |
| 53 | +| `backend/src/test/...` | Create | 14 个测试类,201 个测试方法 | | |
| 54 | +| `scripts/test.sh` | Modify | 把 `./mvnw` 改为系统 `mvn`(与 docs/07 § 一 Maven 3.9.x 依赖对齐;backend 未带 wrapper) | | |
| 55 | +| `docs/05-API接口契约.md` | Modify | REQ-002 去 password 字段 / 40002;REQ-003 补 GET 详情段;REQ-004 错误码列表补 sortField + 40101 | | |
| 56 | + | |
| 57 | +> 文件总数:59 个新建(44 个 main + 14 个 test),1 个修改(scripts/test.sh + docs/05);约 5118 行 backend/ 净增。 | |
| 58 | + | |
| 59 | +## ④ 数据库使用表 | |
| 60 | + | |
| 61 | +- 读: `sys_user`, `sys_company`, `sys_employee`, `sys_department`, `sys_permission_category`, `sys_user_permission_category` | |
| 62 | +- 写: `sys_user`(新增 / 部分字段更新 / 登录追踪字段 / 失败计数原子 UPDATE),`sys_user_permission_category`(增删差集) | |
| 63 | + | |
| 64 | +> `sys_company` / `sys_employee` / `sys_department` 本模块全程只读使用;新增 / 编辑这三张表的接口推迟到后续运营 / HR 模块。 | |
| 65 | + | |
| 66 | +## ⑤ 测试结果 | |
| 67 | + | |
| 68 | +- `scripts/test.sh` 最终:GREEN(详见 `docs/superpowers/module-reports/module_usr-test-gate.md`) | |
| 69 | +- 通过: 201 / 失败: 0 / 跳过: 0 | |
| 70 | +- 覆盖率: 未配置覆盖率插件;REQ 级 spec 验收覆盖: | |
| 71 | + - REQ-USR-001:12/12(含 BizException 单测、JWT 单测、并发原子累加回归) | |
| 72 | + - REQ-USR-002:15/15(spec § 15 唯一索引兜底未单测,依赖文本匹配;记入 ⑩) | |
| 73 | + - REQ-USR-003:22/22(spec § 23 作废用户登录路径属 REQ-USR-001 既有,不重复) | |
| 74 | + - REQ-USR-004:26/26(含 spec § 业务规则 3 isDeleted/lastLoginDate 强制 equals 回归) | |
| 75 | + | |
| 76 | +## ⑥ 本模块新增 Migration | |
| 77 | + | |
| 78 | +—(本模块无 schema 改动;V1__initial_schema.sql 已在 A 阶段建好 6 张表,本模块全程复用) | |
| 79 | + | |
| 80 | +## ⑦ 跨模块改动清单(软规则 S2) | |
| 81 | + | |
| 82 | +—(本模块未触碰其他模块代码;scripts/test.sh 改动属于项目级基础设施修复,不构成跨模块改动。docs/05 修订属于本模块自己的 API 契约同步。) | |
| 83 | + | |
| 84 | +## ⑧ 偏离 spec 清单 | |
| 85 | + | |
| 86 | +- **REQ-USR-001**: docs/04 § 1.6 描述了 access + refresh token 双 token 模型,本 REQ 只签发 access token;refresh 推迟到后续 REQ。spec 已显式声明此偏离。 | |
| 87 | +- **REQ-USR-001**: docs/04 § 1.6 提及"签发后写 Redis",本 REQ 不实现 Redis 黑名单,JWT 自包含验证。spec 已声明。 | |
| 88 | +- **REQ-USR-002**: REQ 卡片表 1 与 docs/05 在密码字段处理上原本冲突;spec 锁定为"系统生成初始密码 666666"(以 REQ 卡为准),docs/05 同步删除 password 字段与 40002 错误码。 | |
| 89 | +- **跨 REQ 文档冲突**: docs/04 § 1.3 错误码段位(10xxx/20xxx/.../60xxx)与 docs/05 + 代码实际使用的 HTTP-aligned 段位(40001/40101/40301/40901/...)不一致。本模块沿用 docs/05 + HTTP-aligned 方案;建议后续单独 PR 修订 docs/04 § 1.3 表述以拉齐 SSoT。 | |
| 90 | + | |
| 91 | +## ⑨ AI reviewer 报告汇总 | |
| 92 | + | |
| 93 | +- REQ-USR-001: round 1 — request-changes(4 high + 7 medium,已落地 5 项修复);round 2 — approve(4 nice-to-have) | |
| 94 | +- REQ-USR-002: round 1 — approve(13 nice-to-have,0 must-fix) | |
| 95 | +- REQ-USR-003: round 1 — approve(6 nice-to-have,0 must-fix;归档时顺手补 docs/05 PUT § 40101) | |
| 96 | +- REQ-USR-004: round 1 — request-changes(2 medium);round 2 — approve(4 nice-to-have) | |
| 97 | + | |
| 98 | +## ⑩ 已知问题 | |
| 99 | + | |
| 100 | +1. **spec § 15 唯一索引兜底(REQ-USR-002)未单测**: UserCreateServiceImpl 在 DataIntegrityViolationException 时通过 message 文本匹配 `uk_sys_user_username` / `uk_sys_user_code` 转 40901/40902;与 MySQL 驱动版本/locale 强耦合,缺 @SpyBean 模拟回归测试。建议后续用 `SQLState='23000' + 错误号 1062` 解析 + 单测覆盖。 | |
| 101 | +2. **权限分类批量插入未走 batchInsert**: REQ-USR-002 / 003 用 for + 单条 insert(N 次 IO)。当前 N < 20 可接受,建议未来补 `insertBatch`。 | |
| 102 | +3. **permissionCategoryIds 差集无乐观锁**: REQ-USR-003 并发 PUT 同一用户可能产生交叉写入。建议未来引入 `sys_user.iVersion` 做乐观锁。 | |
| 103 | +4. **ErrorCode.COMPANY_NOT_FOUND 复用语义错位**: REQ-USR-002 / 003 用 40004 抛"权限分类不存在",常量名 COMPANY_NOT_FOUND 与 message 字面冲突。建议重命名为 RELATED_ENTITY_NOT_FOUND 或拆专用 code。 | |
| 104 | +5. **entity Lombok 字段沿用 SQL 列名匈牙利前缀**: 偏离 docs/04 § 1.2 Java 字段小驼峰约定。当前为了让 MyBatis-Plus 零配置映射;建议未来 refactor 用 @TableField 显式映射。 | |
| 105 | +6. **跨文档错误码段位冲突**: docs/04 § 1.3 vs docs/05 + 代码(见 ⑧ 偏离)。 | |
| 106 | +7. **mapper XML ORDER BY 硬编码 u.\* 前缀**: REQ-USR-004 sortField 白名单当前仅 sys_user 表列;未来扩展到 e.* / d.* 列需更新前缀逻辑。 | |
| 107 | +8. **JwtHandlerInterceptor 每请求重查 DB**: 无缓存;后续容量评估后可加 Caffeine 短期缓存。 | |
| 108 | +9. **scripts/test.sh 之前调用不存在的 ./mvnw**: 本模块期间发现并修复(改用系统 mvn);首次 test-gate 失败已记录在 test-gate evidence。 | |
| 109 | + | |
| 110 | +## ⑪ 下一模块预览 | |
| 111 | + | |
| 112 | +后端阶段仅本一个模块(module_usr),全部 REQ 已完成。 | |
| 113 | + | |
| 114 | +**下一步**:本 MR 合并到 master 后,重跑 `/erp-workflow:coding-start`,会自动检测 `backend_done=true && frontend_done=false`,派发 `frontend-start` 进入前端阶段。前端阶段会以 `prototype/` 的 HTML mockup 为权威推导 FE 业务功能清单,按 FE 循环(fe-feature-brainstorm → plan → tdd → verify → review)完成。 | |
| 115 | + | |
| 116 | +**前端预期工作量**:基于已实现的 6 个端点(auth/login、users CRUD + 列表 + 详情),FE 至少需要:登录页、用户列表 + 筛选页、用户新增 / 编辑 Modal 表单。`prototype/` 目前为空,进入前端阶段时 `frontend-start` 会通过 AskUserQuestion 引导补齐 mockup。 | |
| 117 | + | |
| 118 | +## ⑫ MR 链接 | |
| 119 | + | |
| 120 | +- !1 http://git.xlyprint.cn/zhuzc/test5/-/merge_requests/1 | |
| 121 | +- 标题: `feat(module_usr): 用户管理` | |
| 122 | +- 目标分支: master | |
| 123 | +- 源分支: module-module_usr | ... | ... |
docs/superpowers/module-reports/module_usr-test-gate.md
0 → 100644
| 1 | +## Local test gate — module_usr | |
| 2 | + | |
| 3 | +执行时间: 2026-05-15T10:33:28+08:00 | |
| 4 | + | |
| 5 | +### scripts/test.sh (subagent) | |
| 6 | +- 子会话: a9aa7d814ffd7dd13 | |
| 7 | +- 命令: `cd /Users/reporkey/Desktop/test5 && set -a && . ./.env.local && set +a && ./scripts/test.sh` | |
| 8 | +- 退出码: 0 | |
| 9 | +- 通过: 201 / 失败: 0 | |
| 10 | +- 关键 stdout (≤30 行): | |
| 11 | + | |
| 12 | +``` | |
| 13 | +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.746 s -- in com.xly.erp.common.exception.GlobalExceptionHandlerTest | |
| 14 | +[INFO] | |
| 15 | +[INFO] Results: | |
| 16 | +[INFO] | |
| 17 | +[INFO] Tests run: 201, Failures: 0, Errors: 0, Skipped: 0 | |
| 18 | +[INFO] | |
| 19 | +[INFO] ------------------------------------------------------------------------ | |
| 20 | +[INFO] BUILD SUCCESS | |
| 21 | +[INFO] ------------------------------------------------------------------------ | |
| 22 | +[INFO] Total time: 02:32 min | |
| 23 | +[INFO] Finished at: 2026-05-15T10:33:28+08:00 | |
| 24 | +[test.sh] skip frontend test | |
| 25 | +[test.sh] 5/6 E2E | |
| 26 | +[test.sh] e2e 略 (no frontend) | |
| 27 | +[test.sh] 6/6 reset test db | |
| 28 | +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts | |
| 29 | +[test.sh] GREEN | |
| 30 | +``` | |
| 31 | + | |
| 32 | +### 备注 | |
| 33 | + | |
| 34 | +首次运行 test-gate 时 `scripts/test.sh` 在 stage 2/6 build 失败,因为脚本调用 `./mvnw` 而 backend 没有 Maven Wrapper。修复:把 `./mvnw` 全部替换为系统 `mvn`(与 docs/07 § 一 声明的 Maven 3.9.x 依赖一致)。修后 6 个 stage 全部 GREEN。 | |
| 35 | + | |
| 36 | +结论: green | ... | ... |
docs/superpowers/plans/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-001.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-001 用户登录 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `POST /api/v1/auth/login`,按 spec 完成 4 类校验(公司 / 作废 / 锁定 / 密码)+ 失败计数 + 锁定写入 + JWT 签发,并落地后端项目骨架以支撑后续 REQ。 | |
| 12 | + | |
| 13 | +**Architecture:** | |
| 14 | +- Spring Boot 3 + MyBatis-Plus + Flyway + BCrypt + JJWT (HS256);分层 controller → service(impl) → mapper(docs/04 § 1.1)。 | |
| 15 | +- 业务逻辑全部在 `LoginServiceImpl`,事务边界为成功路径的"清零计数 + 更新登录时间 + 签发 JWT"原子提交。 | |
| 16 | +- 失败逻辑(计数累加 + 锁定写入)在独立事务里执行;锁定时间通过 `tLockUntil` 字段比对 `NOW()` 判定(无需 Redis)。 | |
| 17 | +- 错误码集中在 `ErrorCode` 常量类;`GlobalExceptionHandler` 把 `BizException` 转 `Result.fail`。 | |
| 18 | + | |
| 19 | +**Tech Stack:** Spring Boot 3.x(Java 17)/ MyBatis-Plus 最新稳定 / Flyway 10.x(core + mysql)/ Spring Security crypto(BCryptPasswordEncoder,不启用完整 Security filter chain)/ JJWT 0.12.x / Lombok / Jakarta Validation。 | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## Schema 改动 | |
| 24 | + | |
| 25 | +无。V1 已建好 `sys_user` / `sys_company` / `sys_employee`,本 REQ 不动 schema。 | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 文件变更清单 | |
| 30 | + | |
| 31 | +**Bootstrap(首次 REQ 一次性投入,后续 REQ 复用):** | |
| 32 | +- `backend/pom.xml` — Create(Maven POM,声明依赖与插件) | |
| 33 | +- `backend/src/main/java/com/xly/erp/Application.java` — Create(Spring Boot 启动类) | |
| 34 | +- `backend/src/main/resources/application.yml` — Create(主配置,从 `.env.local` / 环境变量读敏感项) | |
| 35 | +- `backend/src/main/resources/application-test.yml` — Create(测试 profile,复用相同 schema 但禁日志彩色) | |
| 36 | +- `backend/src/main/resources/logback-spring.xml` — Create(最小 logback 配置) | |
| 37 | + | |
| 38 | +**通用基础层(首次 REQ 一次性投入):** | |
| 39 | +- `backend/src/main/java/com/xly/erp/common/response/Result.java` — Create | |
| 40 | +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — Create | |
| 41 | +- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — Create | |
| 42 | +- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — Create | |
| 43 | +- `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — Create | |
| 44 | +- `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` — Create(`BCryptPasswordEncoder` Bean) | |
| 45 | + | |
| 46 | +**业务层(REQ-USR-001 专属):** | |
| 47 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` — Create | |
| 48 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` — Create | |
| 49 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` — Create(只读 join 用,最小字段) | |
| 50 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Create | |
| 51 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` — Create | |
| 52 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` — Create | |
| 53 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` — Create | |
| 54 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` — Create | |
| 55 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` — Create | |
| 56 | +- `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` — Create(接口) | |
| 57 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` — Create | |
| 58 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` — Create | |
| 59 | + | |
| 60 | +**测试:** | |
| 61 | +- `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` — Create(unit/integration with Spring Test + 真实 MySQL,按 spec 验收 1-10 项) | |
| 62 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` — Create(`MockMvc` 端到端覆盖错误码 40001 / 40004 / 40101 / 40103 / 42301) | |
| 63 | +- `backend/src/test/resources/sql/req-usr-001-seed.sql` — Create(测试 fixture:1 个启用用户、1 个作废用户、2 个公司、1 个员工) | |
| 64 | + | |
| 65 | +--- | |
| 66 | + | |
| 67 | +## 约束常量(跨任务,写死不允许漂移) | |
| 68 | + | |
| 69 | +**错误码**(`ErrorCode` 常量类): | |
| 70 | + | |
| 71 | +| 常量名 | 值 | HTTP | | |
| 72 | +|---|---|---| | |
| 73 | +| `OK` | `200` | 200 | | |
| 74 | +| `BAD_REQUEST` | `40001` | 400 | | |
| 75 | +| `COMPANY_NOT_FOUND` | `40004` | 400 | | |
| 76 | +| `BAD_CREDENTIALS` | `40101` | 401 | | |
| 77 | +| `ACCOUNT_DELETED` | `40103` | 401 | | |
| 78 | +| `ACCOUNT_LOCKED` | `42301` | 423 | | |
| 79 | +| `INTERNAL_ERROR` | `50000` | 500 | | |
| 80 | + | |
| 81 | +**JWT 常量**:算法 HS256;TTL 7200 秒;claims 名 `sub` / `username` / `userType` / `companyCode` / `language` / `iat` / `exp` / `jti`;签名密钥从 `application.yml` 注入 `${JWT_SECRET}`(已在 `.env.local` 配置)。 | |
| 82 | + | |
| 83 | +**锁定策略**:阈值 5 次(含第 5 次触发锁定);锁定时长 30 分钟。Magic number 集中在 `LoginServiceImpl` 私有常量 `MAX_FAILED_LOGIN_COUNT = 5` 与 `LOCK_DURATION_MINUTES = 30L`。 | |
| 84 | + | |
| 85 | +**API 形状**: | |
| 86 | + | |
| 87 | +``` | |
| 88 | +POST /api/v1/auth/login (公开接口,无需 Bearer) | |
| 89 | +Request: LoginReq { username:String, password:String, companyCode:String } | |
| 90 | +Response: Result<LoginVo> | |
| 91 | + LoginVo { accessToken:String, tokenType:"Bearer", expiresInSec:7200, userInfo:UserInfoVo } | |
| 92 | + UserInfoVo { userId:int, username:String, userType:String, language:String, | |
| 93 | + employeeName:String?, companyCode:String } | |
| 94 | +``` | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +## 任务步骤 | |
| 99 | + | |
| 100 | +### Task 1: Bootstrap Spring Boot 项目骨架 | |
| 101 | + | |
| 102 | +**Files:** | |
| 103 | +- Create: `backend/pom.xml` | |
| 104 | +- Create: `backend/src/main/java/com/xly/erp/Application.java` | |
| 105 | +- Create: `backend/src/main/resources/application.yml` | |
| 106 | +- Create: `backend/src/main/resources/application-test.yml` | |
| 107 | +- Create: `backend/src/main/resources/logback-spring.xml` | |
| 108 | +- Create: `backend/src/test/java/com/xly/erp/ApplicationContextTest.java` | |
| 109 | + | |
| 110 | +**配置要点**(POM 内容由 TDD 实现,签名约束如下): | |
| 111 | +- Parent: `spring-boot-starter-parent:3.3.x`,Java 17 | |
| 112 | +- Dependencies: `spring-boot-starter-web`, `spring-boot-starter-validation`, `spring-boot-starter-test`, `mybatis-plus-spring-boot3-starter:3.5.x`, `mysql-connector-j` (runtime), `flyway-core` + `flyway-mysql`, `spring-security-crypto`(**不**引入 `spring-boot-starter-security`,避免开启 filter chain),`io.jsonwebtoken:jjwt-api/jjwt-impl/jjwt-jackson:0.12.5`, `lombok` | |
| 113 | +- Flyway 自动启用:默认指向 classpath:db/migration,本项目改为 `classpath:db/migration` 同步指向仓库根 `sql/migrations/` —— **由 Spring Boot 配置加载实现**:在 `application.yml` 写 `spring.flyway.locations: filesystem:../sql/migrations`(相对 backend/ 目录) | |
| 114 | +- `application.yml` 必须用 `${VAR_NAME}` 注入 DB / JWT 凭据(来源 `.env.local`,启动时由 maven-dotenv-plugin 或 `EnvironmentPostProcessor` 加载——TDD 选择最简单方案 `spring-boot-dotenv` 第三方 starter 或 spring-cloud-context 都不引入,改用 `application.yml` 占位 + 启动命令显式 `--spring.config.location` 或 OS 环境变量;**推荐**:要求开发者把 `.env.local` 中的变量 export 到 shell 后 `./mvnw spring-boot:run`;测试场景由 surefire `<environmentVariables>` 注入或测试自行 `@TestPropertySource` 覆盖) | |
| 115 | + | |
| 116 | +> 注:以上"如何加载 .env.local"是 Spring Boot 项目的通用工程难题,本任务**最小可行实现**:测试用 `@DynamicPropertySource` 从 `System.getenv()` 注入;启动用 OS 环境变量。生产部署后续 REQ 再优化。 | |
| 117 | + | |
| 118 | +**API shape**: | |
| 119 | +- `Application.main(String[])` — 标准 `SpringApplication.run` | |
| 120 | +- `ApplicationContextTest#contextLoads()` — Spring Test 验证 ApplicationContext 启动 | |
| 121 | + | |
| 122 | +- [ ] **Step 1: 写失败测试** | |
| 123 | + - 测试名: `ApplicationContextTest#contextLoads` | |
| 124 | + - 意图: 验证 Spring Boot context 能启动;Flyway 能连接到 .env.local 指定的 MySQL 并发现 V1 已 apply(含 `flyway_schema_history` 表) | |
| 125 | + - 子会话确认 FAIL(pom.xml 不存在 / Application 类不存在) | |
| 126 | + | |
| 127 | +- [ ] **Step 2: 实现最小代码** | |
| 128 | + - 写 pom.xml(依赖如上)、Application.java、application.yml、application-test.yml、logback-spring.xml | |
| 129 | + - 在 `application-test.yml` 中通过 `@DynamicPropertySource` 或 `spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_SCHEMA}` 占位符让测试读 env | |
| 130 | + | |
| 131 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 132 | + - 子会话跑 `cd backend && ./mvnw -B test -Dtest=ApplicationContextTest` 应绿 | |
| 133 | + | |
| 134 | +- [ ] **Step 4: Commit** | |
| 135 | + - `git add backend/pom.xml backend/src/main/java/com/xly/erp/Application.java backend/src/main/resources/ backend/src/test/java/com/xly/erp/ApplicationContextTest.java` | |
| 136 | + - `git commit -m "feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001"` | |
| 137 | + | |
| 138 | +--- | |
| 139 | + | |
| 140 | +### Task 2: 通用响应包装 + 异常处理 | |
| 141 | + | |
| 142 | +**Files:** | |
| 143 | +- Create: `backend/src/main/java/com/xly/erp/common/response/Result.java` | |
| 144 | +- Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 145 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java` | |
| 146 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` | |
| 147 | +- Create: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` | |
| 148 | + | |
| 149 | +**API shape:** | |
| 150 | +- `Result<T> { int code; String message; T data; long timestamp; static <T> Result<T> ok(T data); static Result<?> fail(int code, String message); }` | |
| 151 | +- `ErrorCode` — 常量类,含上方"约束常量"表中所有 code(int 字段) | |
| 152 | +- `BizException extends RuntimeException { int code; String message; BizException(int code, String message); }` | |
| 153 | +- `GlobalExceptionHandler` — `@RestControllerAdvice`,handles: | |
| 154 | + - `BizException` → 按其 `code` 映射到 `ResponseEntity<Result>`,HTTP 状态按 ErrorCode 表 | |
| 155 | + - `MethodArgumentNotValidException` / `ConstraintViolationException` → `Result.fail(40001, ...)`,HTTP 400 | |
| 156 | + - `Exception`(兜底) → `Result.fail(50000, "服务器内部错误")`,HTTP 500,记 ERROR 日志,**不**回显堆栈到 message | |
| 157 | + | |
| 158 | +- [ ] **Step 1: 写失败测试** | |
| 159 | + - 测试名: `GlobalExceptionHandlerTest#bizException_returnsCodeAndHttpStatus` 等 3 个测试 | |
| 160 | + - 意图: 用 `MockMvc` 配合一个 `/_test/throw-biz` 测试 controller 触发 `BizException(42301, "...")`,断言 HTTP 423 + body `code=42301`;类似覆盖 `BizException(40101, ...)` → 401,以及兜底 `RuntimeException` → 500 且 message 不含 "java." 前缀 | |
| 161 | + - 子会话确认 FAIL | |
| 162 | + | |
| 163 | +- [ ] **Step 2: 实现最小代码** | |
| 164 | + | |
| 165 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 166 | + | |
| 167 | +- [ ] **Step 4: Commit** | |
| 168 | + - `git commit -m "feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001"` | |
| 169 | + | |
| 170 | +--- | |
| 171 | + | |
| 172 | +### Task 3: JWT 工具 + 密码编码器 Bean | |
| 173 | + | |
| 174 | +**Files:** | |
| 175 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` | |
| 176 | +- Create: `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` | |
| 177 | +- Create: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` | |
| 178 | + | |
| 179 | +**API shape:** | |
| 180 | +- `JwtUtil#issue(Map<String,Object> claims, long ttlSec) : String` — 用 `${JWT_SECRET}`(注入 `@Value`)签发 HS256 JWT,含 `iat` / `exp` / `jti(=UUID)` | |
| 181 | +- `JwtUtil#parse(String token) : Map<String,Object>` — 验签 + 解析,签名错或过期抛 `BizException(40101, ...)` | |
| 182 | +- `PasswordEncoderConfig#passwordEncoder() : BCryptPasswordEncoder` — Spring Bean,strength=10 | |
| 183 | + | |
| 184 | +- [ ] **Step 1: 写失败测试** | |
| 185 | + - 测试名: `JwtUtilTest#issuedToken_canBeParsedBackToClaims` / `JwtUtilTest#tamperedToken_throwsBizException` / `JwtUtilTest#expiredToken_throwsBizException`(用 ttl=0 模拟) | |
| 186 | + - 意图: 验证签发→解析往返一致;篡改任意一字节抛 40101;过期抛 40101 | |
| 187 | + | |
| 188 | +- [ ] **Step 2: 实现最小代码** | |
| 189 | + - JWT_SECRET 来自 `application-test.yml` 的 `jwt.secret: ${JWT_SECRET:test-secret-256bit-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}` | |
| 190 | + | |
| 191 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 192 | + | |
| 193 | +- [ ] **Step 4: Commit** | |
| 194 | + - `git commit -m "feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001"` | |
| 195 | + | |
| 196 | +--- | |
| 197 | + | |
| 198 | +### Task 4: Entity + Mapper(sys_user / sys_company / sys_employee) | |
| 199 | + | |
| 200 | +**Files:** | |
| 201 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` | |
| 202 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` | |
| 203 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` | |
| 204 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` | |
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` | |
| 206 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` | |
| 207 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` | |
| 208 | +- Create: `backend/src/test/resources/sql/req-usr-001-seed.sql` | |
| 209 | + | |
| 210 | +**API shape:** | |
| 211 | +- `SysUser` — 含 `iIncrement / sUsername / sUserCode / sPasswordHash / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / iFailedLoginCount / tLockUntil / tLastLoginDate / sCreatedBy / sUpdatedBy / tUpdatedDate` 字段(命名按 docs/03,TableField 映射);`@TableName("sys_user")`,`@TableId(value="iIncrement", type=IdType.AUTO)` | |
| 212 | +- `SysCompany` — 含 `iIncrement / sCompanyCode / sCompanyName / iIsDeleted`(最小字段) | |
| 213 | +- `SysEmployee` — 含 `iIncrement / sEmployeeName / iDepartmentId`(最小字段) | |
| 214 | +- Mapper 三个均 `extends BaseMapper<T>`(MyBatis-Plus);`SysUserMapper` 额外定义一个方法:`selectByUsername(String username) : SysUser`(@Select 注解),用于登录查找 | |
| 215 | + | |
| 216 | +**Seed SQL** 内容(写死): | |
| 217 | +```sql | |
| 218 | +-- req-usr-001-seed.sql | |
| 219 | +DELETE FROM sys_user_permission_category; | |
| 220 | +DELETE FROM sys_user; | |
| 221 | +DELETE FROM sys_employee; | |
| 222 | +DELETE FROM sys_company; | |
| 223 | + | |
| 224 | +INSERT INTO sys_company (sCompanyName, sCompanyCode, iIsDeleted) VALUES | |
| 225 | + ('总部', 'HQ', 0), | |
| 226 | + ('已删公司', 'DEL_CO', 1); | |
| 227 | + | |
| 228 | +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) | |
| 229 | +SELECT '张三', 'E001', 1 FROM (SELECT 1) t | |
| 230 | +WHERE EXISTS (SELECT 1 FROM sys_department LIMIT 1); | |
| 231 | +-- 若 sys_department 为空,先插入一行 | |
| 232 | +INSERT INTO sys_department (sDepartmentName, sDepartmentCode) VALUES ('技术部', 'TECH'); | |
| 233 | +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) | |
| 234 | + SELECT '张三', 'E001', iIncrement FROM sys_department WHERE sDepartmentCode='TECH' LIMIT 1; | |
| 235 | + | |
| 236 | +-- password = 'Password1!' 的 BCrypt(strength=10) 哈希(TDD 阶段实际生成填入) | |
| 237 | +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, iEmployeeId, sUserType, sLanguage, iIsDeleted, iFailedLoginCount, sCreatedBy) | |
| 238 | + SELECT 'alice', 'U001', '<BCRYPT_HASH_OF_Password1!>', iIncrement, 'NORMAL', 'zh-CN', 0, 0, 'system' | |
| 239 | + FROM sys_employee WHERE sEmployeeCode='E001'; | |
| 240 | + | |
| 241 | +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, sUserType, sLanguage, iIsDeleted, sCreatedBy) | |
| 242 | + VALUES ('bob_deleted', 'U002', '<BCRYPT_HASH_OF_Password1!>', 'NORMAL', 'zh-CN', 1, 'system'); | |
| 243 | +``` | |
| 244 | +> `<BCRYPT_HASH_OF_Password1!>` 在 Task 4 实现时通过一次性 java main 或 `BCryptPasswordEncoder` 调用生成后填入;不允许保留占位符进 commit。 | |
| 245 | + | |
| 246 | +- [ ] **Step 1: 写失败测试** | |
| 247 | + - 测试名: `SysUserMapperTest#selectByUsername_returnsUserWithAllFields` / `SysUserMapperTest#selectByUsername_returnsNullWhenNotFound` | |
| 248 | + - 意图: seed 后查 `alice` → 字段完整;查 `nobody` → null | |
| 249 | + | |
| 250 | +- [ ] **Step 2: 实现最小代码** | |
| 251 | + | |
| 252 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 253 | + | |
| 254 | +- [ ] **Step 4: Commit** | |
| 255 | + - `git commit -m "feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001"` | |
| 256 | + | |
| 257 | +--- | |
| 258 | + | |
| 259 | +### Task 5: LoginReq DTO + LoginVo + UserInfoVo | |
| 260 | + | |
| 261 | +**Files:** | |
| 262 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` | |
| 263 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` | |
| 264 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` | |
| 265 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java` | |
| 266 | + | |
| 267 | +**API shape:** | |
| 268 | +- `LoginReq { @NotBlank @Size(max=50) String username; @NotBlank @Size(max=128) String password; @NotBlank @Size(max=50) String companyCode; }` | |
| 269 | +- `LoginVo { String accessToken; String tokenType; long expiresInSec; UserInfoVo userInfo; }` | |
| 270 | +- `UserInfoVo { Integer userId; String username; String userType; String language; String employeeName; String companyCode; }` | |
| 271 | + | |
| 272 | +- [ ] **Step 1: 写失败测试** | |
| 273 | + - 测试名: `LoginReqValidationTest#blankUsername_fails` / `LoginReqValidationTest#tooLongUsername_fails` / `LoginReqValidationTest#allFieldsPresent_passes` | |
| 274 | + - 意图: 用 `Validator` 校验 jakarta 约束注解工作正常 | |
| 275 | + | |
| 276 | +- [ ] **Step 2: 实现最小代码** | |
| 277 | + | |
| 278 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 279 | + | |
| 280 | +- [ ] **Step 4: Commit** | |
| 281 | + - `git commit -m "feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001"` | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +### Task 6: LoginService 接口骨架 | |
| 286 | + | |
| 287 | +**Files:** | |
| 288 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | |
| 289 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 290 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 291 | + | |
| 292 | +**API shape:** | |
| 293 | +- `LoginService#login(String username, String password, String companyCode) : LoginVo` — 业务方法签名 | |
| 294 | +- `LoginServiceImpl implements LoginService` — `@Service`,注入 `SysUserMapper`、`SysCompanyMapper`、`SysEmployeeMapper`、`BCryptPasswordEncoder`、`JwtUtil` | |
| 295 | +- `LoginServiceImpl` 私有常量:`MAX_FAILED_LOGIN_COUNT = 5`、`LOCK_DURATION_MINUTES = 30L`、`TOKEN_TTL_SEC = 7200L` | |
| 296 | + | |
| 297 | +本 task 仅产出**接口 + 空实现 + 一个 baseline 测试**(直接抛 UnsupportedOperationException),用于建立后续 task 的脚手架。 | |
| 298 | + | |
| 299 | +- [ ] **Step 1: 写失败测试** | |
| 300 | + - 测试名: `LoginServiceImplTest#contextLoads`(验证 LoginService Bean 注入成功;与 `@SpringBootTest` + seed.sql 一起工作) | |
| 301 | + - 意图: Bean 装配可用 | |
| 302 | + | |
| 303 | +- [ ] **Step 2: 实现最小代码** | |
| 304 | + | |
| 305 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 306 | + | |
| 307 | +- [ ] **Step 4: Commit** | |
| 308 | + - `git commit -m "feat(usr): LoginService 接口骨架 REQ-USR-001"` | |
| 309 | + | |
| 310 | +--- | |
| 311 | + | |
| 312 | +### Task 7: Login — 公司不存在或已删 → 40004 | |
| 313 | + | |
| 314 | +**Files:** | |
| 315 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 316 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 317 | + | |
| 318 | +**API behavior**:当 `companyCode` 在 `sys_company` 查不到(不存在 OR `iIsDeleted=1`)→ 抛 `BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除")`;**不**触碰 sys_user。 | |
| 319 | + | |
| 320 | +- [ ] **Step 1: 写失败测试** | |
| 321 | + - 测试名: `LoginServiceImplTest#login_unknownCompany_throws40004` / `login_softDeletedCompany_throws40004` | |
| 322 | + - 意图: 用 seed 中的 `HQ`(正常)vs `NOPE`(不存在)vs `DEL_CO`(软删)触发不同分支;断言抛 `BizException` 且 `code == 40004`;同时断言 `alice` 的 `iFailedLoginCount` 仍为 0(未被错误计入失败) | |
| 323 | + | |
| 324 | +- [ ] **Step 2: 实现最小代码** | |
| 325 | + | |
| 326 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 327 | + | |
| 328 | +- [ ] **Step 4: Commit** | |
| 329 | + - `git commit -m "feat(usr): 登录校验公司存在性 REQ-USR-001"` | |
| 330 | + | |
| 331 | +--- | |
| 332 | + | |
| 333 | +### Task 8: Login — 用户不存在 / 密码错 → 40101(同文案,含失败计数) | |
| 334 | + | |
| 335 | +**Files:** | |
| 336 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 337 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 338 | + | |
| 339 | +**API behavior**: | |
| 340 | +- 用户名不存在 → `BizException(40101, "用户名或密码错误")`;不写 DB(无 user 行可写) | |
| 341 | +- 用户存在 + 密码 hash 不匹配 → `sys_user.iFailedLoginCount += 1`,返 `BizException(40101, "用户名或密码错误")` | |
| 342 | + | |
| 343 | +- [ ] **Step 1: 写失败测试** | |
| 344 | + - 测试名: | |
| 345 | + - `login_unknownUser_throws40101_noDbWrite` | |
| 346 | + - `login_badPassword_throws40101_andIncrementsFailCount` (断言一次错误后 `iFailedLoginCount == 1`) | |
| 347 | + - 意图: 防用户名枚举 + 失败计数累加 | |
| 348 | + | |
| 349 | +- [ ] **Step 2: 实现最小代码** | |
| 350 | + | |
| 351 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 352 | + | |
| 353 | +- [ ] **Step 4: Commit** | |
| 354 | + - `git commit -m "feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001"` | |
| 355 | + | |
| 356 | +--- | |
| 357 | + | |
| 358 | +### Task 9: Login — 失败 5 次锁定 → 第 6 次返 42301 | |
| 359 | + | |
| 360 | +**Files:** | |
| 361 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 362 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 363 | + | |
| 364 | +**API behavior**: | |
| 365 | +- 累计第 5 次错误密码时,在同一事务里写 `tLockUntil = NOW() + 30 分钟`;本次响应仍返 `40101`(**不**在第 5 次直接返锁定,避免泄露阈值) | |
| 366 | +- 后续请求遇到 `tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 直接抛 `BizException(ACCOUNT_LOCKED, "账号已锁定,请稍后再试")`,HTTP 423;**不**计入失败次数 | |
| 367 | +- `tLockUntil <= NOW()`(锁定到期)→ 视为已解锁,正常进入密码校验流程(即仍允许累加失败、仍允许成功) | |
| 368 | + | |
| 369 | +- [ ] **Step 1: 写失败测试** | |
| 370 | + - 测试名: | |
| 371 | + - `login_5thBadPassword_setsLockUntil_andStillReturns40101`(断言 `iFailedLoginCount == 5` 且 `tLockUntil` 不为空 ≥ NOW()+29min) | |
| 372 | + - `login_duringLockWindow_throws42301_noCountIncrement` | |
| 373 | + - `login_afterLockExpired_allowsNewAttempt`(用 SQL 把 tLockUntil 改成过去时刻,再次登录正确密码 → 应成功并清零计数;属于先做小验证,可在 Task 10 完整覆盖成功路径) | |
| 374 | + - 意图: 锁定语义闭环 | |
| 375 | + | |
| 376 | +- [ ] **Step 2: 实现最小代码** | |
| 377 | + | |
| 378 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 379 | + | |
| 380 | +- [ ] **Step 4: Commit** | |
| 381 | + - `git commit -m "feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001"` | |
| 382 | + | |
| 383 | +--- | |
| 384 | + | |
| 385 | +### Task 10: Login — 作废账号 → 40103;成功 → 签 JWT + 清零 + 更新登录时间 | |
| 386 | + | |
| 387 | +**Files:** | |
| 388 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 389 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 390 | + | |
| 391 | +**API behavior**: | |
| 392 | +- `sys_user.iIsDeleted == 1` → `BizException(ACCOUNT_DELETED, "账号已被作废,禁止登录")`,HTTP 401;**不**进入密码校验,**不**累加失败 | |
| 393 | +- 成功路径(`@Transactional`): | |
| 394 | + 1. `iFailedLoginCount = 0`,`tLockUntil = NULL`,`tLastLoginDate = NOW()`(一次 UPDATE) | |
| 395 | + 2. 加载 `sys_employee.sEmployeeName`(若 `iEmployeeId` 非空) | |
| 396 | + 3. 构造 JWT claims(`sub=userId`, `username`, `userType`, `companyCode`, `language`, `jti=UUID`),通过 `JwtUtil.issue(claims, TOKEN_TTL_SEC)` | |
| 397 | + 4. 返回 `LoginVo` | |
| 398 | + | |
| 399 | +判定顺序(先后明确):1) 公司校验 → 2) 用户查找 → 3) 作废校验 → 4) 锁定校验 → 5) 密码校验 → 6) 成功路径。 | |
| 400 | + | |
| 401 | +- [ ] **Step 1: 写失败测试** | |
| 402 | + - 测试名: | |
| 403 | + - `login_deletedUser_throws40103_noCountIncrement` | |
| 404 | + - `login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin` | |
| 405 | + - `login_success_jwtParsesBack_with_sub_username_companyCode` | |
| 406 | + - 意图: 成功路径与作废路径都覆盖;JWT 验签往返 | |
| 407 | + | |
| 408 | +- [ ] **Step 2: 实现最小代码** | |
| 409 | + | |
| 410 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 411 | + | |
| 412 | +- [ ] **Step 4: Commit** | |
| 413 | + - `git commit -m "feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001"` | |
| 414 | + | |
| 415 | +--- | |
| 416 | + | |
| 417 | +### Task 11: AuthController + 端到端 MockMvc 测试 | |
| 418 | + | |
| 419 | +**Files:** | |
| 420 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` | |
| 421 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` | |
| 422 | + | |
| 423 | +**API shape:** | |
| 424 | +- `AuthController` — `@RestController @RequestMapping("/api/v1/auth")` | |
| 425 | +- `POST /api/v1/auth/login` 方法 `login(@RequestBody @Valid LoginReq req) : Result<LoginVo>`,委托给 `LoginService` | |
| 426 | + | |
| 427 | +- [ ] **Step 1: 写失败测试** | |
| 428 | + - 测试名(`AuthControllerTest` 用 `@SpringBootTest + MockMvc`): | |
| 429 | + - `post_login_success_returns200_andLoginVo` | |
| 430 | + - `post_login_badCredentials_returns401_code40101` | |
| 431 | + - `post_login_lockedAccount_returns423_code42301` | |
| 432 | + - `post_login_deletedAccount_returns401_code40103` | |
| 433 | + - `post_login_unknownCompany_returns400_code40004` | |
| 434 | + - `post_login_blankUsername_returns400_code40001` | |
| 435 | + - 意图: 6 个 HTTP 路径全部覆盖 spec § 验收 1-10 | |
| 436 | + | |
| 437 | +- [ ] **Step 2: 实现最小代码** | |
| 438 | + | |
| 439 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 440 | + | |
| 441 | +- [ ] **Step 4: Commit** | |
| 442 | + - `git commit -m "feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001"` | |
| 443 | + | |
| 444 | +--- | |
| 445 | + | |
| 446 | +## 提交计划 | |
| 447 | + | |
| 448 | +按 task 顺序产生 11 个 commit: | |
| 449 | + | |
| 450 | +| Task | Commit message | | |
| 451 | +|---|---| | |
| 452 | +| 1 | `feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001` | | |
| 453 | +| 2 | `feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001` | | |
| 454 | +| 3 | `feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001` | | |
| 455 | +| 4 | `feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001` | | |
| 456 | +| 5 | `feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001` | | |
| 457 | +| 6 | `feat(usr): LoginService 接口骨架 REQ-USR-001` | | |
| 458 | +| 7 | `feat(usr): 登录校验公司存在性 REQ-USR-001` | | |
| 459 | +| 8 | `feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001` | | |
| 460 | +| 9 | `feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001` | | |
| 461 | +| 10 | `feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001` | | |
| 462 | +| 11 | `feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001` | | ... | ... |
docs/superpowers/plans/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-05-15 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-002.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-002 新增用户 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** `POST /api/v1/users` 接口,超级管理员新建用户(初始密码 `666666` 系统生成 + 哈希),同时落地 JWT 鉴权 HandlerInterceptor + `@RequireSuperAdmin` 角色守卫基础设施。 | |
| 12 | + | |
| 13 | +**Architecture:** | |
| 14 | +- 鉴权:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,通过 `WebMvcConfigurer` 注册,匹配 `/api/v1/**`,放行 `/api/v1/auth/login`;通过 `LoginContext` ThreadLocal 把当前用户上下文传递到 controller / service。 | |
| 15 | +- 角色守卫:`@RequireSuperAdmin` 方法级注解;同一 interceptor 在 `handler instanceof HandlerMethod` 时检查注解 + `LoginContext.userType`。 | |
| 16 | +- 业务:`UserCreateService` 单一职责(创建用户 + 写权限分类授权),事务边界一整个写入流程;唯一性预检 + DB 唯一索引兜底;外键存在性预检(employee + permissionCategory)。 | |
| 17 | +- docs/05 同步:删除 CreateUserReq 的 `password` 字段与 `40002` 错误码(在 Task 1 一并完成)。 | |
| 18 | + | |
| 19 | +**Tech Stack:** 复用 REQ-USR-001 已建(Spring Boot 3 / MyBatis-Plus / BCrypt / JJWT);本 REQ 新增依赖:无。 | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## Schema 改动 | |
| 24 | + | |
| 25 | +无。V1 已建。 | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 文件变更清单 | |
| 30 | + | |
| 31 | +**基础设施(鉴权 / 角色守卫)**: | |
| 32 | +- `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` — Create(ThreadLocal 工具) | |
| 33 | +- `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` — Create | |
| 34 | +- `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` — Create(注解) | |
| 35 | +- `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` — Create(注册 interceptor) | |
| 36 | +- `backend/src/main/resources/application.yml` — Modify:21(启用 `spring.jackson.deserialization.fail-on-unknown-properties: true`) | |
| 37 | + | |
| 38 | +**业务(REQ-USR-002 专属)**: | |
| 39 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` — Create | |
| 40 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` — Create | |
| 41 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Modify(新增 `selectByUserCode` + `existsByUsername` + `existsByUserCode` 方法) | |
| 42 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` — Create | |
| 43 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` — Create | |
| 44 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` — Create | |
| 45 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` — Create | |
| 46 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` — Create(接口) | |
| 47 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` — Create | |
| 48 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — Create | |
| 49 | + | |
| 50 | +**文档同步**: | |
| 51 | +- `docs/05-API接口契约.md` — Modify(去掉 `password` + `40002`) | |
| 52 | + | |
| 53 | +**测试**: | |
| 54 | +- `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` — Create(鉴权 / 角色守卫单测,含 MockMvc) | |
| 55 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` — Create(service 集成测) | |
| 56 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` — Create(controller 端到端) | |
| 57 | +- `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — Modify(扩展 seed:添加 SUPER_ADMIN 用户 + 2 个权限分类) | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 约束常量 | |
| 62 | + | |
| 63 | +**错误码新增**(添加到 `ErrorCode`): | |
| 64 | + | |
| 65 | +| 常量 | 值 | HTTP | | |
| 66 | +|---|---|---| | |
| 67 | +| `FORBIDDEN` | `40301` | 403 | | |
| 68 | +| `CONFLICT_USERNAME` | `40901` | 409 | | |
| 69 | +| `CONFLICT_USERCODE` | `40902` | 409 | | |
| 70 | + | |
| 71 | +**初始密码**:常量 `UserCreateServiceImpl.INITIAL_PASSWORD = "666666"`。 | |
| 72 | + | |
| 73 | +**LoginContext API**(ThreadLocal 工具): | |
| 74 | +- `LoginContext.set(int userId, String username, String userType, String companyCode)` | |
| 75 | +- `LoginContext.current() : LoginUser`(含上述 4 字段,未登录返 null) | |
| 76 | +- `LoginContext.clear()` | |
| 77 | + | |
| 78 | +**`@RequireSuperAdmin`**:方法级注解,无属性。 | |
| 79 | + | |
| 80 | +**`JwtHandlerInterceptor`** 行为序列(`preHandle`): | |
| 81 | +1. handler 非 HandlerMethod → return true(静态资源等) | |
| 82 | +2. method 标注 `@RequireSuperAdmin` 或在 `/api/v1/**` 路径下(非 login)→ 必须鉴权 | |
| 83 | +3. 取 `Authorization` 头;缺失或无 `Bearer ` 前缀 → `BizException(40101, "未携带 token")` | |
| 84 | +4. `jwtUtil.parse(token)` 取 claims | |
| 85 | +5. `userMapper.selectByUsername(claims.username)` 查最新状态;不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → `BizException(40101, "token 关联用户不可用")` | |
| 86 | +6. `LoginContext.set(...)` | |
| 87 | +7. 若 method 标注 `@RequireSuperAdmin` 且 `userType != "SUPER_ADMIN"` → `BizException(40301, "权限不足,仅超级管理员可调用")` | |
| 88 | +8. return true | |
| 89 | +9. `afterCompletion` 调 `LoginContext.clear()` | |
| 90 | + | |
| 91 | +--- | |
| 92 | + | |
| 93 | +## 任务步骤 | |
| 94 | + | |
| 95 | +### Task 1: docs/05 同步 + ErrorCode 新增 3 个常量 | |
| 96 | + | |
| 97 | +**Files:** | |
| 98 | +- Modify: `docs/05-API接口契约.md` § REQ-USR-002 — 删除 password 字段 + 40002 错误码 | |
| 99 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 新增 FORBIDDEN / CONFLICT_USERNAME / CONFLICT_USERCODE 常量 + 在 `toHttpStatus` 对 40301 / 40901 / 40902 加映射 | |
| 100 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` — Create(覆盖新增 HTTP 映射) | |
| 101 | + | |
| 102 | +**API shape:** | |
| 103 | +- `ErrorCode.FORBIDDEN = 40301` | |
| 104 | +- `ErrorCode.CONFLICT_USERNAME = 40901` | |
| 105 | +- `ErrorCode.CONFLICT_USERCODE = 40902` | |
| 106 | +- `ErrorCode.toHttpStatus(40301) == 403`、`toHttpStatus(40901) == 409`、`toHttpStatus(40902) == 409` | |
| 107 | + | |
| 108 | +- [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes` | |
| 109 | +- [ ] **Step 2: 实现最小代码** + 改 docs/05 | |
| 110 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 111 | +- [ ] **Step 4: Commit** `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` | |
| 112 | + | |
| 113 | +### Task 2: LoginContext ThreadLocal | |
| 114 | + | |
| 115 | +**Files:** | |
| 116 | +- Create: `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` | |
| 117 | +- Create: `backend/src/test/java/com/xly/erp/common/security/LoginContextTest.java` | |
| 118 | + | |
| 119 | +**API shape:** | |
| 120 | +- `LoginContext.LoginUser` — record(userId:Integer, username:String, userType:String, companyCode:String) | |
| 121 | +- `LoginContext.set(LoginUser)` / `LoginContext.current()` / `LoginContext.clear()` | |
| 122 | +- 用 `InheritableThreadLocal` 否,纯 `ThreadLocal`(避免子线程意外继承) | |
| 123 | + | |
| 124 | +- [ ] **Step 1: 写失败测试** | |
| 125 | + - `LoginContextTest#setAndCurrent_isolatedPerThread` — 两个线程 set 不同值,互不影响 | |
| 126 | + - `LoginContextTest#clear_returnsNullForCurrent` | |
| 127 | +- [ ] **Step 2: 实现最小代码** | |
| 128 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 129 | +- [ ] **Step 4: Commit** `feat(usr): LoginContext ThreadLocal REQ-USR-002` | |
| 130 | + | |
| 131 | +### Task 3: @RequireSuperAdmin 注解 + JwtHandlerInterceptor + WebMvcConfig | |
| 132 | + | |
| 133 | +**Files:** | |
| 134 | +- Create: `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` | |
| 135 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` | |
| 136 | +- Create: `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` | |
| 137 | +- Create: `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` | |
| 138 | + | |
| 139 | +**API shape:** | |
| 140 | +- `@RequireSuperAdmin` — `@Target(METHOD) @Retention(RUNTIME)` | |
| 141 | +- `JwtHandlerInterceptor implements HandlerInterceptor` —— 行为见"约束常量" | |
| 142 | +- `WebMvcConfig implements WebMvcConfigurer` —— `addInterceptors(registry)`:注册 JwtHandlerInterceptor,`.addPathPatterns("/api/v1/**").excludePathPatterns("/api/v1/auth/login")` | |
| 143 | + | |
| 144 | +测试用一个 `@TestController` 暴露 `/api/v1/_test/admin-only`(标 `@RequireSuperAdmin`)和 `/api/v1/_test/any-auth`(不标),MockMvc 调用: | |
| 145 | + | |
| 146 | +- [ ] **Step 1: 写失败测试** | |
| 147 | + - `noAuthHeader_returns401_40101` | |
| 148 | + - `invalidToken_returns401_40101` | |
| 149 | + - `tokenForDeletedUser_returns401_40101` | |
| 150 | + - `tokenForLockedUser_returns401_40101` | |
| 151 | + - `validToken_normalUser_canAccessAnyAuthEndpoint` | |
| 152 | + - `validToken_normalUser_cannotAccessAdminOnly_returns403_40301` | |
| 153 | + - `validToken_superAdmin_canAccessAdminOnly` | |
| 154 | + - `loginEndpointPath_skipsInterceptor`(鉴权未注入也能调 /api/v1/auth/login) | |
| 155 | + - `loginContext_clearedAfterRequest`(请求结束后 ThreadLocal 应清空) | |
| 156 | +- [ ] **Step 2: 实现最小代码** | |
| 157 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 158 | +- [ ] **Step 4: Commit** `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` | |
| 159 | + | |
| 160 | +### Task 4: SysPermissionCategory + SysUserPermissionCategory entity + mapper | |
| 161 | + | |
| 162 | +**Files:** | |
| 163 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` | |
| 164 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` | |
| 165 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` | |
| 166 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` | |
| 167 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — 扩展插入 SUPER_ADMIN 用户 + 2 个权限分类,并暴露 alice 的 ID + admin 的 ID + 权限分类 ID 给 Fixture record | |
| 168 | + | |
| 169 | +**API shape:** | |
| 170 | +- entity 字段对齐 docs/03(与 SysUser 一致风格,匈牙利前缀 + Lombok @Data) | |
| 171 | +- `SysPermissionCategoryMapper#countActiveByIds(List<Integer> ids) : int`(@Select 显式列,过滤 iIsDeleted=0) | |
| 172 | +- `SysUserPermissionCategoryMapper extends BaseMapper<...>`,无自定义方法 | |
| 173 | +- Fixture record 扩展:`Fixture(aliceId, bobDeletedId, employeeId, adminId, permissionCategoryIds)` 其中 permissionCategoryIds 是 List<Integer>,admin 用户名常量 `USER_ADMIN = "admin"` | |
| 174 | + | |
| 175 | +- [ ] **Step 1: 写失败测试** | |
| 176 | + - `SysPermissionCategoryMapperTest#countActiveByIds_excludesDeleted` | |
| 177 | + - `LoginTestSeederTest#fixture_includesAdminAndPermissionCategories` | |
| 178 | +- [ ] **Step 2: 实现最小代码** | |
| 179 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 180 | +- [ ] **Step 4: Commit** `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` | |
| 181 | + | |
| 182 | +### Task 5: SysUserMapper 唯一性查询方法 | |
| 183 | + | |
| 184 | +**Files:** | |
| 185 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — 新增 `existsByUsername / existsByUserCode` | |
| 186 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` — 补测试 | |
| 187 | + | |
| 188 | +**API shape:** | |
| 189 | +- `SysUserMapper#existsByUsername(String username) : boolean`(@Select `SELECT COUNT(*) > 0 FROM sys_user WHERE sUsername = #{username}`;MyBatis 把 int 0/1 自动转 boolean,或用 Integer 后 service 层做判断) | |
| 190 | +- `SysUserMapper#existsByUserCode(String userCode) : boolean` | |
| 191 | + | |
| 192 | +- [ ] **Step 1: 写失败测试** | |
| 193 | + - `SysUserMapperTest#existsByUsername_trueForExisting / falseForUnknown` | |
| 194 | + - `SysUserMapperTest#existsByUserCode_trueForExisting / falseForUnknown` | |
| 195 | +- [ ] **Step 2: 实现最小代码** | |
| 196 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 197 | +- [ ] **Step 4: Commit** `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` | |
| 198 | + | |
| 199 | +### Task 6: CreateUserReq + CreateUserVo | |
| 200 | + | |
| 201 | +**Files:** | |
| 202 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` | |
| 203 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` | |
| 204 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/CreateUserReqValidationTest.java` | |
| 205 | +- Modify: `backend/src/main/resources/application.yml` — 启用 `spring.jackson.deserialization.fail-on-unknown-properties: true` | |
| 206 | +- Modify: `backend/src/main/resources/application-test.yml` — 同上 | |
| 207 | + | |
| 208 | +**API shape:** | |
| 209 | +- `CreateUserReq { @NotBlank @Pattern(regexp="^[A-Za-z0-9_]{3,20}$") username, @NotBlank @Size(max=50) userCode, @NotBlank @Pattern(regexp="NORMAL|SUPER_ADMIN") userType, @NotBlank @Pattern(regexp="zh-CN|en-US|zh-TW") language, @NotNull Boolean canEditDocument, Integer employeeId, List<Integer> permissionCategoryIds }` | |
| 210 | +- `CreateUserVo { Integer userId; String username; String userCode; }` + @Builder | |
| 211 | + | |
| 212 | +注意:不定义 `password` 字段;启用 fail-on-unknown-properties 后,请求带 password 会被 Jackson 直接拒绝。Jackson 反序列化异常 → 在 `GlobalExceptionHandler` 已有 `HttpMessageNotReadableException` handler(如果没有,本 Task 顺带补;同 round 1 review 推迟项) | |
| 213 | + | |
| 214 | +> 实际:round 1 没补 HttpMessageNotReadable handler。本 Task 顺手补到 `GlobalExceptionHandler`,让"包含未知字段"返 40001 / 400 而非 50000。 | |
| 215 | + | |
| 216 | +- [ ] **Step 1: 写失败测试** | |
| 217 | + - `CreateUserReqValidationTest`(10 个用例):空 / 越长 / 用户名非法字符 / userType 非枚举 / language 非枚举 / canEditDocument 缺失 / employeeId 可空 / permissionCategoryIds 可空 / 全合法 / userCode 越长 | |
| 218 | +- [ ] **Step 2: 实现最小代码** + 改两份 application yml + 补 HttpMessageNotReadable handler | |
| 219 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 220 | +- [ ] **Step 4: Commit** `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` | |
| 221 | + | |
| 222 | +### Task 7: UserCreateService 接口 + Impl 骨架(仅唯一性 / 外键校验) | |
| 223 | + | |
| 224 | +**Files:** | |
| 225 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` | |
| 226 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` | |
| 227 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` | |
| 228 | + | |
| 229 | +**API shape:** | |
| 230 | +- `UserCreateService#create(CreateUserReq req, String operatorUsername) : CreateUserVo` | |
| 231 | +- `UserCreateServiceImpl` 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / BCryptPasswordEncoder | |
| 232 | +- 常量 `INITIAL_PASSWORD = "666666"` | |
| 233 | +- `@Transactional` | |
| 234 | + | |
| 235 | +本 Task 范围:实现 4 项校验(唯一用户名、唯一用户号、employeeId 存在、permissionCategoryIds 全部存在),但不实现实际写入——直接返回 dummy CreateUserVo(0, ..., ...);写入路径在 Task 8。 | |
| 236 | + | |
| 237 | +- [ ] **Step 1: 写失败测试**(service 集成测,复用扩展后的 LoginTestSeeder) | |
| 238 | + - `create_usernameExists_throws40901` | |
| 239 | + - `create_userCodeExists_throws40902` | |
| 240 | + - `create_employeeIdNotFound_throws40004` | |
| 241 | + - `create_employeeIdSoftDeleted_throws40004` | |
| 242 | + - `create_permissionCategoryNotFound_throws40004`(含数组中混入不存在 ID) | |
| 243 | +- [ ] **Step 2: 实现最小代码** | |
| 244 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 245 | +- [ ] **Step 4: Commit** `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` | |
| 246 | + | |
| 247 | +### Task 8: UserCreateService 写入路径(sys_user + sys_user_permission_category 事务) | |
| 248 | + | |
| 249 | +**Files:** | |
| 250 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` | |
| 251 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` | |
| 252 | + | |
| 253 | +**API behavior:** | |
| 254 | +- 校验全过 → BCrypt encode "666666" → 插入 sys_user(sCreatedBy = operatorUsername)→ 批量插入 sys_user_permission_category(每条 sGrantedBy = operatorUsername)→ 返回 CreateUserVo(新 userId, username, userCode) | |
| 255 | +- 捕获 `DataIntegrityViolationException`(如并发同名)→ 抛 40901 / 40902(按异常消息含 `uk_sys_user_username` / `uk_sys_user_code` 判别) | |
| 256 | + | |
| 257 | +- [ ] **Step 1: 写失败测试** | |
| 258 | + - `create_minimalFields_persistsUserWithInitialPassword` | |
| 259 | + - `create_fullFields_persistsUserAndPermissionMappings` | |
| 260 | + - `create_emptyPermissionCategories_persistsUserOnly`(permissionCategoryIds=空数组 / null 都允许) | |
| 261 | + - `create_initialPasswordMatchesBcrypt666666`(用 BCryptPasswordEncoder.matches("666666", DB hash) == true) | |
| 262 | + - `create_dataIntegrityViolation_username_throws40901`(先 select 返 false 但插入时报 DuplicateKey;用 spy / 模拟难,可在测试用并发场景模拟,或直接抛模拟异常验证转换) | |
| 263 | +- [ ] **Step 2: 实现最小代码** | |
| 264 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 265 | +- [ ] **Step 4: Commit** `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` | |
| 266 | + | |
| 267 | +### Task 9: UserController POST /api/v1/users + 端到端测试 | |
| 268 | + | |
| 269 | +**Files:** | |
| 270 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | |
| 271 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` | |
| 272 | + | |
| 273 | +**API shape:** | |
| 274 | +- `UserController` — `@RestController @RequestMapping("/api/v1/users")` | |
| 275 | +- `POST /api/v1/users`:标注 `@RequireSuperAdmin`,方法签名 `Result<CreateUserVo> create(@RequestBody @Valid CreateUserReq req)`;调用 `userCreateService.create(req, LoginContext.current().username())`;返回 `ResponseEntity.status(201).body(Result.ok(vo))` | |
| 276 | + | |
| 277 | +端到端测试(`@SpringBootTest + @AutoConfigureMockMvc`):用 admin token 调用,覆盖: | |
| 278 | + | |
| 279 | +- [ ] **Step 1: 写失败测试** | |
| 280 | + - `post_users_success_returns201_andCreatedVo` | |
| 281 | + - `post_users_blankUsername_returns400_40001` | |
| 282 | + - `post_users_invalidUserType_returns400_40001` | |
| 283 | + - `post_users_unknownPropertyPassword_returns400_40001`(请求 body 含 `"password":"...".` 字段) | |
| 284 | + - `post_users_noAuthHeader_returns401_40101` | |
| 285 | + - `post_users_normalUserToken_returns403_40301` | |
| 286 | + - `post_users_deletedUserToken_returns401_40101` | |
| 287 | + - `post_users_duplicateUsername_returns409_40901` | |
| 288 | + - `post_users_duplicateUserCode_returns409_40902` | |
| 289 | + - `post_users_unknownEmployee_returns400_40004` | |
| 290 | + - `post_users_unknownPermissionCategory_returns400_40004` | |
| 291 | + - `post_users_success_canLoginWithInitialPassword`(创建后立即调 /auth/login 用 666666 应成功) | |
| 292 | +- [ ] **Step 2: 实现最小代码** | |
| 293 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 294 | +- [ ] **Step 4: Commit** `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` | |
| 295 | + | |
| 296 | +--- | |
| 297 | + | |
| 298 | +## 提交计划 | |
| 299 | + | |
| 300 | +| Task | Commit message | | |
| 301 | +|---|---| | |
| 302 | +| 1 | `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` | | |
| 303 | +| 2 | `feat(usr): LoginContext ThreadLocal REQ-USR-002` | | |
| 304 | +| 3 | `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` | | |
| 305 | +| 4 | `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` | | |
| 306 | +| 5 | `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` | | |
| 307 | +| 6 | `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` | | |
| 308 | +| 7 | `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` | | |
| 309 | +| 8 | `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` | | |
| 310 | +| 9 | `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` | | ... | ... |
docs/superpowers/plans/2026-05-15-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-05-15 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-003.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-003 修改用户 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `GET /api/v1/users/{userId}` + `PUT /api/v1/users/{userId}` 两个端点;PUT 支持部分字段更新 + permissionCategoryIds 增量增删差集 + 自我停用守卫;GET 返回 UserDetailVo(含 employeeName + permissionCategoryIds)。 | |
| 12 | + | |
| 13 | +**Architecture:** | |
| 14 | +- 鉴权 / 角色守卫 / 事务 / Result / 异常处理全部复用 REQ-USR-002 基础设施。 | |
| 15 | +- 新增 `UserUpdateService`(PUT)+ `UserDetailService`(GET),单一职责。 | |
| 16 | +- permissionCategoryIds 用差集:先 select 现有 → 计算 toAdd / toRemove → DELETE + INSERT 在同一事务。 | |
| 17 | +- PATCH 语义简化:缺省 / 显式 null 都视为不变;`employeeId=0` 作为约定的"解除关联"信号。 | |
| 18 | + | |
| 19 | +**Tech Stack:** 复用 REQ-USR-002(Spring Boot 3 + MyBatis-Plus + Jakarta Validation)。 | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## Schema 改动 | |
| 24 | + | |
| 25 | +无。 | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 文件变更清单 | |
| 30 | + | |
| 31 | +**新增**: | |
| 32 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java` | |
| 33 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java` | |
| 34 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java` | |
| 35 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java` | |
| 36 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java` | |
| 37 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` | |
| 38 | + | |
| 39 | +**修改**: | |
| 40 | +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`(新增 40302 / 40401 + 404 映射) | |
| 41 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`(新增 `existsByUserCodeExcludingId`) | |
| 42 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java`(新增 `selectPermissionCategoryIdsByUserId` + `deleteByUserAndCategoryIds`) | |
| 43 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(新增 GET + PUT 方法) | |
| 44 | + | |
| 45 | +**文档**:本 REQ 不改 docs/05(已含 REQ-USR-003 段,但 docs/05 当前漏写 GET 详情接口;本 REQ 补充)。 | |
| 46 | + | |
| 47 | +**测试**: | |
| 48 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java` | |
| 49 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` | |
| 50 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java`(独立文件避免冲击 REQ-USR-002 既有 UserControllerTest) | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 约束常量 | |
| 55 | + | |
| 56 | +**ErrorCode 新增**: | |
| 57 | + | |
| 58 | +| 常量 | 值 | HTTP | | |
| 59 | +|---|---|---| | |
| 60 | +| `USER_FORBIDDEN_SELF_DEACTIVATE` | `40302` | 403 | | |
| 61 | +| `USER_NOT_FOUND` | `40401` | 404 | | |
| 62 | + | |
| 63 | +> `ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,不需新增。 | |
| 64 | + | |
| 65 | +**API 形状**: | |
| 66 | + | |
| 67 | +``` | |
| 68 | +GET /api/v1/users/{userId} @RequireSuperAdmin | |
| 69 | + → Result<UserDetailVo> | |
| 70 | + errors: 40101, 40301, 40401 | |
| 71 | + | |
| 72 | +PUT /api/v1/users/{userId} @RequireSuperAdmin | |
| 73 | + body: UpdateUserReq (PATCH) | |
| 74 | + → Result<UserDetailVo> | |
| 75 | + errors: 40001, 40004, 40101, 40301, 40302, 40401, 40902 | |
| 76 | + | |
| 77 | +UserDetailVo { | |
| 78 | + Integer userId, String username, String userCode, | |
| 79 | + String userType, String language, Boolean canEditDocument, Boolean isDeleted, | |
| 80 | + Integer employeeId, String employeeName, | |
| 81 | + List<Integer> permissionCategoryIds, | |
| 82 | + String updatedBy, LocalDateTime updatedDate | |
| 83 | +} | |
| 84 | + | |
| 85 | +UpdateUserReq { | |
| 86 | + String userCode, // 缺省/null = 不变 | |
| 87 | + String userType, // 同上 | |
| 88 | + String language, // 同上 | |
| 89 | + Boolean canEditDocument, // 同上 | |
| 90 | + Integer employeeId, // 缺省/null = 不变;0 = 解除关联;正整数 = 更新 | |
| 91 | + Boolean isDeleted, // 同上 | |
| 92 | + List<Integer> permissionCategoryIds // 缺省/null = 不变;空数组 = 清空;非空 = 增删差集 | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +**PATCH 语义约定**(写死,跨任务一致):所有字段缺省 / null 视为 "保持原值";`employeeId == 0` 视为 "解除关联",DB 写 NULL。其他字段无清除语义。 | |
| 97 | + | |
| 98 | +--- | |
| 99 | + | |
| 100 | +## 任务步骤 | |
| 101 | + | |
| 102 | +### Task 1: ErrorCode 新增 40302 / 40401 + 404 映射 + docs/05 补 GET 详情段 | |
| 103 | + | |
| 104 | +**Files:** | |
| 105 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 106 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` | |
| 107 | +- Modify: `docs/05-API接口契约.md` § REQ-USR-003 之前加 `GET /api/v1/users/{userId}` 段 | |
| 108 | + | |
| 109 | +**API shape:** | |
| 110 | +- `ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE = 40302` | |
| 111 | +- `ErrorCode.USER_NOT_FOUND = 40401` | |
| 112 | +- `ErrorCode.toHttpStatus(40302) == 403` | |
| 113 | +- `ErrorCode.toHttpStatus(40401) == 404` | |
| 114 | + | |
| 115 | +docs/05 补充内容(追加到现 REQ-USR-003 段之前): | |
| 116 | + | |
| 117 | +``` | |
| 118 | +### REQ-USR-003 GET 用户详情 | |
| 119 | + | |
| 120 | +- Method: GET | |
| 121 | +- Path: /api/v1/users/{userId} | |
| 122 | +- Auth: Bearer Token;仅 userType=SUPER_ADMIN 可调用 | |
| 123 | +- 请求: Path userId: int | |
| 124 | +- 响应: JSON UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds[], updatedBy, updatedDate } | |
| 125 | + | |
| 126 | +#### 错误码 | |
| 127 | +- 40101 — 未携带或无效 Token | |
| 128 | +- 40301 — 当前用户非超级管理员,无权调用 | |
| 129 | +- 40401 — 用户不存在 | |
| 130 | +``` | |
| 131 | + | |
| 132 | +- [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes_v003` | |
| 133 | +- [ ] **Step 2: 实现最小代码** + docs/05 改动 | |
| 134 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 135 | +- [ ] **Step 4: Commit** `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003` | |
| 136 | + | |
| 137 | +### Task 2: UpdateUserReq + UserDetailVo | |
| 138 | + | |
| 139 | +**Files:** | |
| 140 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java` | |
| 141 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java` | |
| 142 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java` | |
| 143 | + | |
| 144 | +**API shape**: 见 "约束常量"。 | |
| 145 | + | |
| 146 | +校验注解(仅在字段非 null 时生效,jakarta `@Pattern` 等天然 null-safe): | |
| 147 | +- `userCode`: `@Size(max=50)` | |
| 148 | +- `userType`: `@Pattern(regexp="NORMAL|SUPER_ADMIN")` | |
| 149 | +- `language`: `@Pattern(regexp="zh-CN|en-US|zh-TW")` | |
| 150 | +- `employeeId`: `@Min(0)` (0 = 解除关联约定) | |
| 151 | +- 其余字段无 @ 注解 | |
| 152 | + | |
| 153 | +- [ ] **Step 1: 写失败测试** `UpdateUserReqValidationTest` 覆盖: | |
| 154 | + - 全空请求体合法(PATCH,所有字段可选) | |
| 155 | + - userType 非枚举 → 失败 | |
| 156 | + - language 非枚举 → 失败 | |
| 157 | + - userCode 越长 → 失败 | |
| 158 | + - userCode 空字符串 → 失败(@Size 不限定下界,但建议加 @Pattern("\S+") 防空白;本任务不强制) | |
| 159 | + - employeeId=-1 → 失败 | |
| 160 | + - employeeId=0 合法(约定的解除关联) | |
| 161 | +- [ ] **Step 2: 实现最小代码** | |
| 162 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 163 | +- [ ] **Step 4: Commit** `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003` | |
| 164 | + | |
| 165 | +### Task 3: SysUserMapper.existsByUserCodeExcludingId | |
| 166 | + | |
| 167 | +**Files:** | |
| 168 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` | |
| 169 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` | |
| 170 | + | |
| 171 | +**API shape:** | |
| 172 | +- `SysUserMapper#existsByUserCodeExcludingId(String userCode, Integer excludedUserId) : boolean` | |
| 173 | +- SQL: `SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})` | |
| 174 | + | |
| 175 | +- [ ] **Step 1: 写失败测试** | |
| 176 | + - `existsByUserCodeExcludingId_otherUserHasCode_returnsTrue` (alice U001, query U001 excluding admin → true) | |
| 177 | + - `existsByUserCodeExcludingId_selfHasCode_returnsFalse` (alice U001, query U001 excluding alice → false) | |
| 178 | + - `existsByUserCodeExcludingId_unknownCode_returnsFalse` | |
| 179 | +- [ ] **Step 2: 实现最小代码** | |
| 180 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 181 | +- [ ] **Step 4: Commit** `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003` | |
| 182 | + | |
| 183 | +### Task 4: SysUserPermissionCategoryMapper 增删辅助方法 | |
| 184 | + | |
| 185 | +**Files:** | |
| 186 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` | |
| 187 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java` | |
| 188 | + | |
| 189 | +**API shape:** | |
| 190 | +- `SysUserPermissionCategoryMapper#selectPermissionCategoryIdsByUserId(Integer userId) : List<Integer>` | |
| 191 | + - SQL: `SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}` | |
| 192 | +- `SysUserPermissionCategoryMapper#deleteByUserAndCategoryIds(@Param("userId") Integer userId, @Param("ids") List<Integer> categoryIds) : int` | |
| 193 | + - SQL: `DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN (...)` (@Select script) | |
| 194 | + | |
| 195 | +- [ ] **Step 1: 写失败测试** | |
| 196 | + - 准备:admin user + 授权 {PUR, SAL} | |
| 197 | + - `selectPermissionCategoryIdsByUserId_returnsAllCurrent` | |
| 198 | + - `deleteByUserAndCategoryIds_onlyDeletesGivenSubset`(删 {PUR} 后剩 {SAL}) | |
| 199 | + - `deleteByUserAndCategoryIds_nonMatchingIds_noop_returns0` | |
| 200 | +- [ ] **Step 2: 实现最小代码** | |
| 201 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 202 | +- [ ] **Step 4: Commit** `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003` | |
| 203 | + | |
| 204 | +### Task 5: UserDetailService 接口 + 实现 | |
| 205 | + | |
| 206 | +**Files:** | |
| 207 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java` | |
| 208 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java` | |
| 209 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java` | |
| 210 | + | |
| 211 | +**API shape:** | |
| 212 | +- `UserDetailService#getById(Integer userId) : UserDetailVo` | |
| 213 | +- 找不到用户 → `BizException(ErrorCode.USER_NOT_FOUND, "用户不存在")` | |
| 214 | +- 包含作废用户(用于恢复启用场景的回显) | |
| 215 | +- 装配逻辑:select sys_user → 若 iEmployeeId 非空 select sys_employee.sEmployeeName → selectPermissionCategoryIdsByUserId | |
| 216 | + | |
| 217 | +- [ ] **Step 1: 写失败测试** | |
| 218 | + - `getById_existingActiveUser_returnsFullVo`(含 employeeName + permissionCategoryIds) | |
| 219 | + - `getById_userWithoutEmployee_employeeNameIsNull` | |
| 220 | + - `getById_userWithoutPermissions_emptyList` | |
| 221 | + - `getById_deletedUser_stillReturned`(iIsDeleted=1 的用户也能查到,VO.isDeleted=true) | |
| 222 | + - `getById_unknownId_throws40401` | |
| 223 | +- [ ] **Step 2: 实现最小代码** | |
| 224 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 225 | +- [ ] **Step 4: Commit** `feat(usr): UserDetailService 用户详情查询 REQ-USR-003` | |
| 226 | + | |
| 227 | +### Task 6: UserUpdateService 接口 + 校验骨架 | |
| 228 | + | |
| 229 | +**Files:** | |
| 230 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java` | |
| 231 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` | |
| 232 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` | |
| 233 | + | |
| 234 | +**API shape:** | |
| 235 | +- `UserUpdateService#update(Integer userId, UpdateUserReq req, Integer operatorUserId, String operatorUsername) : UserDetailVo` | |
| 236 | +- `@Transactional` | |
| 237 | +- 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / UserDetailService(复用详情装配返回最终 VO) | |
| 238 | + | |
| 239 | +本 Task 范围:校验路径(不写入),全部抛 BizException 路径覆盖: | |
| 240 | +1. `userId` 不存在 → 40401 | |
| 241 | +2. `req.isDeleted == true && userId == operatorUserId` → 40302 | |
| 242 | +3. `req.userCode` 非 null 且 `existsByUserCodeExcludingId(userCode, userId)` → 40902 | |
| 243 | +4. `req.employeeId` 是正整数(非 0)且不存在或软删 → 40004 | |
| 244 | +5. `req.permissionCategoryIds` 非 null 且非空且含不存在 ID → 40004 | |
| 245 | + | |
| 246 | +- [ ] **Step 1: 写失败测试** 5 个测试覆盖以上路径 | |
| 247 | +- [ ] **Step 2: 实现最小代码**(更新方法体仅 throw,不写 DB) | |
| 248 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 249 | +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003` | |
| 250 | + | |
| 251 | +### Task 7: UserUpdateService 部分字段写入 + employee 三态 | |
| 252 | + | |
| 253 | +**Files:** | |
| 254 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` | |
| 255 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` | |
| 256 | + | |
| 257 | +**API behavior:** | |
| 258 | +- 校验全过后用 `UpdateWrapper<SysUser>().eq("iIncrement", userId)` 链式 set: | |
| 259 | + - `if (req.userCode != null)` → set sUserCode | |
| 260 | + - `if (req.userType != null)` → set sUserType | |
| 261 | + - `if (req.language != null)` → set sLanguage | |
| 262 | + - `if (req.canEditDocument != null)` → set iCanEditDocument (true → 1, false → 0) | |
| 263 | + - `if (req.employeeId != null)`:req.employeeId == 0 → set iEmployeeId NULL;正整数 → set iEmployeeId 值 | |
| 264 | + - `if (req.isDeleted != null)` → set iIsDeleted (true → 1, false → 0) | |
| 265 | + - 总是 set sUpdatedBy=operatorUsername、tUpdatedDate=NOW() | |
| 266 | +- 不处理 permissionCategoryIds(推到 Task 8) | |
| 267 | + | |
| 268 | +- [ ] **Step 1: 写失败测试** | |
| 269 | + - `update_userCode_only_persisted_otherFieldsUnchanged` | |
| 270 | + - `update_userType_language_canEditDocument` | |
| 271 | + - `update_employeeId_positiveInteger_setsToValue` | |
| 272 | + - `update_employeeId_zero_setsToNull` | |
| 273 | + - `update_employeeId_unchanged_preservesOriginalValue`(缺省字段不变) | |
| 274 | + - `update_isDeleted_true_marksUserDeleted` | |
| 275 | + - `update_isDeleted_false_revivesUser` | |
| 276 | + - `update_emptyRequest_onlyUpdatesAuditFields`(sUpdatedBy + tUpdatedDate) | |
| 277 | +- [ ] **Step 2: 实现最小代码** | |
| 278 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 279 | +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003` | |
| 280 | + | |
| 281 | +### Task 8: UserUpdateService permissionCategoryIds 增量差集 | |
| 282 | + | |
| 283 | +**Files:** | |
| 284 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` | |
| 285 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` | |
| 286 | + | |
| 287 | +**API behavior:** | |
| 288 | +- 仅当 `req.permissionCategoryIds != null` 时执行: | |
| 289 | + - `current = selectPermissionCategoryIdsByUserId(userId)`(Set 化以便比较) | |
| 290 | + - `target = new HashSet<>(req.permissionCategoryIds)`(dedup) | |
| 291 | + - `toRemove = current \ target` → `deleteByUserAndCategoryIds(userId, toRemove)`(若 toRemove 非空) | |
| 292 | + - `toAdd = target \ current` → 循环 insert SysUserPermissionCategory(sGrantedBy=operator) | |
| 293 | +- 返回 `userDetailService.getById(userId)` 作为响应(聚合最新状态) | |
| 294 | + | |
| 295 | +- [ ] **Step 1: 写失败测试** | |
| 296 | + - `update_permissionCategories_emptyList_clearsAll`(初始 {PUR,SAL} → 请求 [] → 最终 ∅) | |
| 297 | + - `update_permissionCategories_subsetDelta_addsAndRemoves`(初始 {PUR,SAL} → 请求 [SAL, 新分类 X] → 最终 {SAL, X});断言 SAL 行 iIncrement 不变(差集而非全量替换) | |
| 298 | + - `update_permissionCategories_omitted_preservesExisting`(请求中无 permissionCategoryIds → 不动) | |
| 299 | + - `update_permissionCategories_duplicateInRequest_deduped`(请求 [PUR, PUR, SAL] → 最终 {PUR, SAL}) | |
| 300 | + - `update_returnsUserDetailVoReflectingFinalState` | |
| 301 | +- [ ] **Step 2: 实现最小代码** | |
| 302 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 303 | +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003` | |
| 304 | + | |
| 305 | +### Task 9: UserController GET + PUT + 端到端测试 | |
| 306 | + | |
| 307 | +**Files:** | |
| 308 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(追加 GET + PUT 方法) | |
| 309 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java` | |
| 310 | + | |
| 311 | +**API shape:** | |
| 312 | +- `@GetMapping("/{userId}") @RequireSuperAdmin getById(@PathVariable Integer userId) : Result<UserDetailVo>` | |
| 313 | +- `@PutMapping("/{userId}") @RequireSuperAdmin update(@PathVariable Integer userId, @RequestBody @Valid UpdateUserReq req) : Result<UserDetailVo>` | |
| 314 | +- PUT 调用 `userUpdateService.update(userId, req, LoginContext.current().userId(), LoginContext.current().username())` | |
| 315 | + | |
| 316 | +端到端测试(覆盖 spec § 验收 1-23): | |
| 317 | + | |
| 318 | +**GET(5 个)**: | |
| 319 | +- `get_existingUser_returns200_andFullVo` | |
| 320 | +- `get_unknownUser_returns404_40401` | |
| 321 | +- `get_normalUser_returns403_40301` | |
| 322 | +- `get_noAuthHeader_returns401_40101` | |
| 323 | +- `get_deletedUser_stillReturns200` | |
| 324 | + | |
| 325 | +**PUT(16 个)**: | |
| 326 | +- `put_updateUserCodeAndType_returns200` | |
| 327 | +- `put_updateEmployeeId_toAnotherEmployee` | |
| 328 | +- `put_updateEmployeeId_zero_clearsRelation` | |
| 329 | +- `put_updateEmployeeId_unknown_returns400_40004` | |
| 330 | +- `put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall`(修改后用旧 token 调 GET 应得 40101) | |
| 331 | +- `put_permissionCategories_subsetDelta`(断言差集行为) | |
| 332 | +- `put_permissionCategories_emptyArray_clearsAll` | |
| 333 | +- `put_permissionCategories_unknownId_returns400_40004_andRollsBack`(断言事务回滚——sys_user 也未被改) | |
| 334 | +- `put_duplicateUserCode_returns409_40902` | |
| 335 | +- `put_userCodeUnchangedSameAsSelf_returns200` | |
| 336 | +- `put_selfDeactivate_returns403_40302` | |
| 337 | +- `put_unknownProperty_username_returns400_40001` | |
| 338 | +- `put_unknownProperty_password_returns400_40001` | |
| 339 | +- `put_unknownUserId_returns404_40401` | |
| 340 | +- `put_normalUser_returns403_40301` | |
| 341 | +- `put_emptyBody_only_updates_audit_fields` | |
| 342 | + | |
| 343 | +- [ ] **Step 1: 写失败测试** (21 个测试) | |
| 344 | +- [ ] **Step 2: 实现最小代码**(在现有 UserController 上追加方法) | |
| 345 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 346 | +- [ ] **Step 4: Commit** `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003` | |
| 347 | + | |
| 348 | +--- | |
| 349 | + | |
| 350 | +## 提交计划 | |
| 351 | + | |
| 352 | +| Task | Commit message | | |
| 353 | +|---|---| | |
| 354 | +| 1 | `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003` | | |
| 355 | +| 2 | `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003` | | |
| 356 | +| 3 | `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003` | | |
| 357 | +| 4 | `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003` | | |
| 358 | +| 5 | `feat(usr): UserDetailService 用户详情查询 REQ-USR-003` | | |
| 359 | +| 6 | `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003` | | |
| 360 | +| 7 | `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003` | | |
| 361 | +| 8 | `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003` | | |
| 362 | +| 9 | `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003` | | ... | ... |
docs/superpowers/plans/2026-05-15-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-15 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-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:** 实现 `GET /api/v1/users` 分页 + 多字段筛选 + 排序的用户列表查询;服务端做白名单 + 类型转换 + 越界矫正;输出 PageResult<UserListItemVo>(JOIN sys_employee/sys_department 取员工名 / 部门名)。 | |
| 12 | + | |
| 13 | +**Architecture:** | |
| 14 | +- 复用 REQ-USR-002 / 003 已建的鉴权 + 角色守卫 + 异常处理。 | |
| 15 | +- 新增 `UserListService` 单一职责;动态 SQL 通过 MyBatis XML(@Select script 也可,本任务用 XML 便于维护)实现 JOIN + WHERE 动态拼接。 | |
| 16 | +- 白名单映射:queryField / sortField / matchMode / sortOrder 全部在 service 层校验后才进 SQL。 | |
| 17 | +- 越界矫正在 service 层:先查目标 page,若 records 为空但 total>0 → 重算 lastPage 再查。 | |
| 18 | +- PageResult 引入为通用类(放 common.response 包,供后续 REQ 复用)。 | |
| 19 | + | |
| 20 | +**Tech Stack:** 复用 Spring Boot 3 + MyBatis-Plus(本 REQ 用 mapper XML 写动态查询)+ Jakarta Validation。 | |
| 21 | + | |
| 22 | +--- | |
| 23 | + | |
| 24 | +## Schema 改动 | |
| 25 | + | |
| 26 | +无。 | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## 文件变更清单 | |
| 31 | + | |
| 32 | +**新增(通用)**: | |
| 33 | +- `backend/src/main/java/com/xly/erp/common/response/PageResult.java` | |
| 34 | + | |
| 35 | +**新增(业务)**: | |
| 36 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryReq.java` | |
| 37 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVo.java` | |
| 38 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java` | |
| 39 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java` | |
| 40 | +- `backend/src/main/resources/mapper/usr/SysUserMapper.xml` | |
| 41 | + | |
| 42 | +**修改**: | |
| 43 | +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`(新增 INVALID_ENUM_PARAM=40003) | |
| 44 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`(新增 selectByQuery + countByQuery) | |
| 45 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(新增 GET / list 方法) | |
| 46 | + | |
| 47 | +**测试**: | |
| 48 | +- `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryReqValidationTest.java` | |
| 49 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java` | |
| 50 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java` | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 约束常量 | |
| 55 | + | |
| 56 | +**ErrorCode 新增**: | |
| 57 | + | |
| 58 | +| 常量 | 值 | HTTP | | |
| 59 | +|---|---|---| | |
| 60 | +| `INVALID_ENUM_PARAM` | `40003` | 400 | | |
| 61 | + | |
| 62 | +> ErrorCode.toHttpStatus(40003) → 400/100=400,已在现有映射,新增常量即可。 | |
| 63 | + | |
| 64 | +**白名单常量**(全部定义在 `UserListServiceImpl` 的 `static final Map`): | |
| 65 | + | |
| 66 | +``` | |
| 67 | +SORT_FIELDS = {"tCreateDate", "tLastLoginDate", "sUsername", "sUserCode"} | |
| 68 | + | |
| 69 | +QUERY_FIELD_TO_SQL = { | |
| 70 | + "username": "u.sUsername", | |
| 71 | + "employeeName": "e.sEmployeeName", | |
| 72 | + "userCode": "u.sUserCode", | |
| 73 | + "departmentName": "d.sDepartmentName", | |
| 74 | + "userType": "u.sUserType", | |
| 75 | + "isDeleted": "u.iIsDeleted", | |
| 76 | + "lastLoginDate": "u.tLastLoginDate", | |
| 77 | + "createdBy": "u.sCreatedBy" | |
| 78 | +} | |
| 79 | + | |
| 80 | +MATCH_MODES = {"contains", "notContains", "equals"} | |
| 81 | +SORT_ORDERS = {"asc", "desc"} | |
| 82 | + | |
| 83 | +USER_TYPES = {"NORMAL", "SUPER_ADMIN"} | |
| 84 | + | |
| 85 | +DEFAULT_PAGE = 1 | |
| 86 | +DEFAULT_SIZE = 20 | |
| 87 | +MAX_SIZE = 100 | |
| 88 | +DEFAULT_SORT_FIELD = "tCreateDate" | |
| 89 | +DEFAULT_SORT_ORDER = "desc" | |
| 90 | +DEFAULT_MATCH_MODE = "contains" | |
| 91 | +``` | |
| 92 | + | |
| 93 | +**API 形状**: | |
| 94 | + | |
| 95 | +``` | |
| 96 | +GET /api/v1/users?page=1&size=20&sortField=tCreateDate&sortOrder=desc | |
| 97 | + &queryField=username&matchMode=contains&queryValue=ali | |
| 98 | + &userType=NORMAL&isDeleted=false | |
| 99 | +@RequireSuperAdmin | |
| 100 | +→ Result<PageResult<UserListItemVo>> | |
| 101 | + | |
| 102 | +PageResult<T> { | |
| 103 | + List<T> records; | |
| 104 | + long total; | |
| 105 | + int page; | |
| 106 | + int size; | |
| 107 | +} | |
| 108 | + | |
| 109 | +UserListItemVo { | |
| 110 | + Integer userId, String username, String employeeName, String userCode, | |
| 111 | + String departmentName, String userType, String language, | |
| 112 | + Boolean isDeleted, LocalDateTime lastLoginDate, | |
| 113 | + String createdBy, LocalDateTime createdDate | |
| 114 | +} | |
| 115 | + | |
| 116 | +UserQueryReq { | |
| 117 | + Integer page, // @Min(1) | |
| 118 | + Integer size, // @Min(1) @Max(100) | |
| 119 | + String sortField, | |
| 120 | + String sortOrder, | |
| 121 | + String queryField, | |
| 122 | + String matchMode, | |
| 123 | + String queryValue, | |
| 124 | + String userType, | |
| 125 | + Boolean isDeleted | |
| 126 | +} | |
| 127 | +``` | |
| 128 | + | |
| 129 | +--- | |
| 130 | + | |
| 131 | +## 任务步骤 | |
| 132 | + | |
| 133 | +### Task 1: ErrorCode 新增 40003 + PageResult 通用类 | |
| 134 | + | |
| 135 | +**Files:** | |
| 136 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 137 | +- Create: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` | |
| 138 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` | |
| 139 | + | |
| 140 | +**API shape:** | |
| 141 | +- `ErrorCode.INVALID_ENUM_PARAM = 40003` | |
| 142 | +- `ErrorCode.toHttpStatus(40003) == 400` | |
| 143 | +- `PageResult<T> { records: List<T>; total: long; page: int; size: int }` + @Builder | |
| 144 | + | |
| 145 | +- [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes_v004` 验 40003→400 | |
| 146 | +- [ ] **Step 2: 实现最小代码** | |
| 147 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 148 | +- [ ] **Step 4: Commit** `feat(usr): ErrorCode 新增 40003 + PageResult 通用类 REQ-USR-004` | |
| 149 | + | |
| 150 | +### Task 2: UserQueryReq DTO + UserListItemVo | |
| 151 | + | |
| 152 | +**Files:** | |
| 153 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryReq.java` | |
| 154 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVo.java` | |
| 155 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryReqValidationTest.java` | |
| 156 | + | |
| 157 | +**API shape:** | |
| 158 | +- `UserQueryReq` 所有字段可选;jakarta 注解只用 `@Min(1)`(page)、`@Min(1) @Max(100)`(size);其他枚举值在 service 层做白名单校验(不用 @Pattern,因为 @Pattern 失败会落到 40001,本 REQ 要 40003) | |
| 159 | +- `UserListItemVo` 字段同 spec § 输出 | |
| 160 | + | |
| 161 | +- [ ] **Step 1: 写失败测试** 5 个用例: | |
| 162 | + - 全空合法(PATCH 风格) | |
| 163 | + - page=0 → @Min(1) 失败 | |
| 164 | + - size=101 → @Max(100) 失败 | |
| 165 | + - size=0 → @Min(1) 失败 | |
| 166 | + - 全合法字段 → pass | |
| 167 | +- [ ] **Step 2: 实现最小代码** | |
| 168 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 169 | +- [ ] **Step 4: Commit** `feat(usr): UserQueryReq + UserListItemVo + PageResult REQ-USR-004` | |
| 170 | + | |
| 171 | +### Task 3: SysUserMapper.selectByQuery + countByQuery (XML) | |
| 172 | + | |
| 173 | +**Files:** | |
| 174 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`(声明方法) | |
| 175 | +- Create: `backend/src/main/resources/mapper/usr/SysUserMapper.xml`(动态 SQL) | |
| 176 | +- Modify: `backend/src/main/resources/application.yml`(mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml) | |
| 177 | +- Modify: `backend/src/main/resources/application-test.yml`(同上) | |
| 178 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperQueryTest.java` | |
| 179 | + | |
| 180 | +**API shape:** | |
| 181 | +- `SysUserMapper#selectByQuery(@Param("p") QueryParams p) : List<UserListItemRow>` | |
| 182 | +- `SysUserMapper#countByQuery(@Param("p") QueryParams p) : long` | |
| 183 | +- `QueryParams` — 内部 record / DTO,包含已通过白名单校验的字段:`sqlSortField`(列名), `sqlSortOrder`(asc/desc), `sqlQueryColumn`(已映射列名 OR null), `matchMode`, `queryValue`, `userType`, `isDeleted`(Integer 0/1 或 null), `offset`, `limit` | |
| 184 | + | |
| 185 | +> service 层把 spec 入参规范化为 `QueryParams`,mapper XML 用 `${}` 拼接 `sqlSortField` / `sqlSortOrder` / `sqlQueryColumn`(白名单值),用 `#{}` 绑定 queryValue / userType / isDeleted / offset / limit。 | |
| 186 | + | |
| 187 | +XML 关键片段(仅作为 plan 锁定的契约,TDD 实现可改细节): | |
| 188 | + | |
| 189 | +```xml | |
| 190 | +<sql id="baseFrom"> | |
| 191 | + FROM sys_user u | |
| 192 | + LEFT JOIN sys_employee e ON e.iIncrement = u.iEmployeeId | |
| 193 | + LEFT JOIN sys_department d ON d.iIncrement = e.iDepartmentId | |
| 194 | +</sql> | |
| 195 | + | |
| 196 | +<sql id="whereClause"> | |
| 197 | + <where> | |
| 198 | + <if test="p.sqlQueryColumn != null and p.queryValue != null and p.queryValue != ''"> | |
| 199 | + <choose> | |
| 200 | + <when test="p.matchMode == 'contains'"> | |
| 201 | + AND ${p.sqlQueryColumn} LIKE CONCAT('%', #{p.queryValue}, '%') | |
| 202 | + </when> | |
| 203 | + <when test="p.matchMode == 'notContains'"> | |
| 204 | + AND (${p.sqlQueryColumn} NOT LIKE CONCAT('%', #{p.queryValue}, '%') | |
| 205 | + OR ${p.sqlQueryColumn} IS NULL) | |
| 206 | + </when> | |
| 207 | + <otherwise> | |
| 208 | + AND ${p.sqlQueryColumn} = #{p.queryValue} | |
| 209 | + </otherwise> | |
| 210 | + </choose> | |
| 211 | + </if> | |
| 212 | + <if test="p.userType != null">AND u.sUserType = #{p.userType}</if> | |
| 213 | + <if test="p.isDeleted != null">AND u.iIsDeleted = #{p.isDeleted}</if> | |
| 214 | + </where> | |
| 215 | +</sql> | |
| 216 | + | |
| 217 | +<select id="selectByQuery" resultType="com.xly.erp.module.usr.vo.UserListItemVo"> | |
| 218 | + SELECT u.iIncrement AS userId, u.sUsername AS username, | |
| 219 | + e.sEmployeeName AS employeeName, u.sUserCode AS userCode, | |
| 220 | + d.sDepartmentName AS departmentName, u.sUserType AS userType, | |
| 221 | + u.sLanguage AS language, u.iIsDeleted AS isDeleted, | |
| 222 | + u.tLastLoginDate AS lastLoginDate, u.sCreatedBy AS createdBy, | |
| 223 | + u.tCreateDate AS createdDate | |
| 224 | + <include refid="baseFrom"/> | |
| 225 | + <include refid="whereClause"/> | |
| 226 | + ORDER BY u.${p.sqlSortField} ${p.sqlSortOrder} | |
| 227 | + LIMIT #{p.offset}, #{p.limit} | |
| 228 | +</select> | |
| 229 | + | |
| 230 | +<select id="countByQuery" resultType="long"> | |
| 231 | + SELECT COUNT(*) | |
| 232 | + <include refid="baseFrom"/> | |
| 233 | + <include refid="whereClause"/> | |
| 234 | +</select> | |
| 235 | +``` | |
| 236 | + | |
| 237 | +> MyBatis-Plus 默认 `mapper-locations: classpath*:/mapper/**/*.xml`,但需在 application.yml 显式声明以确保 XML 被加载。当前 application.yml 仅声明了 mybatis-plus 配置项,未声明 mapper-locations;本任务添加。 | |
| 238 | + | |
| 239 | +- [ ] **Step 1: 写失败测试** `SysUserMapperQueryTest`: | |
| 240 | + - `count_noFilters_returnsAllRows` | |
| 241 | + - `select_withSortByUsername_ascending` | |
| 242 | + - `select_withQueryFieldUsername_contains` | |
| 243 | + - `select_joinsEmployeeAndDepartment_returnsBothNames` | |
| 244 | +- [ ] **Step 2: 实现最小代码** | |
| 245 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 246 | +- [ ] **Step 4: Commit** `feat(usr): SysUserMapper 动态查询 XML + JOIN 员工/部门 REQ-USR-004` | |
| 247 | + | |
| 248 | +### Task 4: UserListService 白名单 + 越界矫正 | |
| 249 | + | |
| 250 | +**Files:** | |
| 251 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java` | |
| 252 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java` | |
| 253 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java` | |
| 254 | + | |
| 255 | +**API shape:** | |
| 256 | +- `UserListService#list(UserQueryReq req) : PageResult<UserListItemVo>` | |
| 257 | +- 内部规范化流程: | |
| 258 | + 1. 应用默认值(page=1, size=20, sortField=tCreateDate, sortOrder=desc, matchMode=contains) | |
| 259 | + 2. 白名单校验:sortField / sortOrder / queryField / matchMode / userType — 不在白名单抛 `BizException(40003)` 或 `BizException(40001)` 按入参类型决定 | |
| 260 | + 3. queryField→sqlQueryColumn 映射;queryValue 转换(对 isDeleted 列:'true'→1, 'false'→0;其他不在 {true,false,0,1} 抛 40001) | |
| 261 | + 4. 越界矫正:先查 `selectByQuery(目标 page)`;若 records 空 && total>0 → 重算 lastPage 再查;响应 page 反映实际页 | |
| 262 | + | |
| 263 | +> userType 入参既可作 explicit query param 也可作 queryField=userType+queryValue。两条路径都要走白名单校验。 | |
| 264 | + | |
| 265 | +- [ ] **Step 1: 写失败测试** 12 个用例覆盖 spec 验收 5-21 | |
| 266 | +- [ ] **Step 2: 实现最小代码** | |
| 267 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 268 | +- [ ] **Step 4: Commit** `feat(usr): UserListService 白名单校验 + 动态查询 + 越界矫正 REQ-USR-004` | |
| 269 | + | |
| 270 | +### Task 5: UserController GET / + 端到端测试 | |
| 271 | + | |
| 272 | +**Files:** | |
| 273 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(追加 GET / 方法) | |
| 274 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java` | |
| 275 | + | |
| 276 | +**API shape:** | |
| 277 | +- `@GetMapping @RequireSuperAdmin list(@Valid UserQueryReq req) : Result<PageResult<UserListItemVo>>` | |
| 278 | +- 用 `@ModelAttribute` 或省略让 Spring 默认从 query 绑定 DTO | |
| 279 | + | |
| 280 | +端到端测试(覆盖 spec § 验收 1-26): | |
| 281 | + | |
| 282 | +GET 路径(admin token): | |
| 283 | +- `list_default_returnsAllUsersSortedByCreateDateDesc` | |
| 284 | +- `list_pagination_secondPage` | |
| 285 | +- `list_sizeOver100_returns400_40001` | |
| 286 | +- `list_pageZero_returns400_40001` | |
| 287 | +- `list_sortByUsernameAsc` | |
| 288 | +- `list_sortFieldInvalid_returns400_40003` | |
| 289 | +- `list_sortOrderInvalid_returns400_40001` | |
| 290 | +- `list_queryByUsernameContains` | |
| 291 | +- `list_queryByUsernameEquals_returnsExactOne` | |
| 292 | +- `list_queryByUsernameNotContains` | |
| 293 | +- `list_queryByEmployeeName_joinsCorrectly` | |
| 294 | +- `list_queryByDepartmentName_multiLevelJoin` | |
| 295 | +- `list_queryByUserType_equals` | |
| 296 | +- `list_queryByIsDeletedTrue_filtersDeleted` | |
| 297 | +- `list_queryFieldInvalid_returns400_40003` | |
| 298 | +- `list_matchModeInvalid_returns400_40003` | |
| 299 | +- `list_queryFieldWithoutValue_skipsCondition` | |
| 300 | +- `list_explicitUserTypeFilter` | |
| 301 | +- `list_explicitUserTypeInvalid_returns400_40001` | |
| 302 | +- `list_explicitIsDeletedFalse_filtersActiveOnly` | |
| 303 | +- `list_composedFilters_andSemantics`(queryField+queryValue + userType + isDeleted) | |
| 304 | +- `list_pageBeyondTotal_returnsLastPage` | |
| 305 | +- `list_normalUserToken_returns403_40301` | |
| 306 | +- `list_noAuthHeader_returns401_40101` | |
| 307 | +- `list_responseDoesNotContainPasswordField` | |
| 308 | +- `list_emptyTable_returnsZeroTotal`(drop + recreate 用户为空时) | |
| 309 | + | |
| 310 | +- [ ] **Step 1: 写失败测试** | |
| 311 | +- [ ] **Step 2: 实现最小代码** | |
| 312 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 313 | +- [ ] **Step 4: Commit** `feat(usr): GET /api/v1/users controller + 端到端测试 REQ-USR-004` | |
| 314 | + | |
| 315 | +--- | |
| 316 | + | |
| 317 | +## 提交计划 | |
| 318 | + | |
| 319 | +| Task | Commit message | | |
| 320 | +|---|---| | |
| 321 | +| 1 | `feat(usr): ErrorCode 新增 40003 + PageResult 通用类 REQ-USR-004` | | |
| 322 | +| 2 | `feat(usr): UserQueryReq + UserListItemVo + PageResult REQ-USR-004` | | |
| 323 | +| 3 | `feat(usr): SysUserMapper 动态查询 XML + JOIN 员工/部门 REQ-USR-004` | | |
| 324 | +| 4 | `feat(usr): UserListService 白名单校验 + 动态查询 + 越界矫正 REQ-USR-004` | | |
| 325 | +| 5 | `feat(usr): GET /api/v1/users controller + 端到端测试 REQ-USR-004` | | ... | ... |
docs/superpowers/reviews/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 2 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-001 — round 2 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:28 — incrementFailedLoginCountAtomic 依赖 MySQL `SET` 子句左到右求值语义;建议加链接 dev.mysql.com/doc/refman/8.0/en/update.html 提醒切换数据库方言时复查 | |
| 19 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java:66 — lockUntil 在 service 层 ISO 字符串化;建议改为直接放 LocalDateTime 让 Jackson 全局规则统一处理 | |
| 20 | +- backend/src/main/java/com/xly/erp/common/exception/BizException.java:25 — (int, String, Throwable) 构造的 data=null 是合理默认;未来若有"包装异常 + data"诉求再加 4 参构造 | |
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java:171 — 并发测试用 2×2 低于阈值;可补 3×2 高于阈值场景断言 lock 触发 + count 准确 | |
| 22 | + | |
| 23 | +## 反例 / 测试覆盖缺口 | |
| 24 | + | |
| 25 | +Round 1 标记『推迟』的 4 项(docs/04 vs docs/05 跨文档不一致、HttpMessageNotReadableException 兜底、ErrorCode.toHttpStatus future-proof、application.yml flyway 路径)本轮未触及,符合 round 1 review 约定,不视为 gap。Reviewer 沙箱因无 MySQL 连接产生 28 个测试 error,已通过 surefire 报告确认为环境问题;主会话 34 测试全过。 | |
| 26 | + | |
| 27 | +## 本轮变更归档 | |
| 28 | + | |
| 29 | +Round 1 修复全部正确落地: | |
| 30 | + | |
| 31 | +| # | 项目 | 状态 | | |
| 32 | +|---|------|-----| | |
| 33 | +| H1 | 42301 data.lockUntil | ✓ BizException 扩 data 链路完整 | | |
| 34 | +| H2 | 失败计数原子 UPDATE | ✓ 单 SQL,去 noRollbackFor,并发回归覆盖 | | |
| 35 | +| H3 | SELECT * → 显式列 | ✓ 两个 mapper 都改 | | |
| 36 | +| M5 | JWT 短密钥静默补零 | ✓ < 32 字节 PostConstruct 硬抛 | | |
| 37 | +| M6 | loginSuccess 双路径 | ✓ 去 entity,走 markLoginSuccess SQL | | |
| 38 | +| NTH | exp-iat==7200 / lockUntil / 并发原子累加 测试 | ✓ 全部新增 | | |
| 39 | + | |
| 40 | +未引入新回归。Approve。 | ... | ... |
docs/superpowers/reviews/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-002 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:82 — DataIntegrityViolationException 转 40901/40902 用 message 文本匹配,与驱动版本/locale 强耦合;建议改判 SQLState='23000' + 错误号 1062,并补 @SpyBean Mockito 回归测试(spec § 15 唯一索引并发兜底测试缺口) | |
| 19 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:93 — 权限分类批量插入用 for + 单条 insert(N 次 IO);建议改 saveBatch 或在注释里写明 'N < 20 不批量' | |
| 20 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:57 — countActiveByIds 用 COUNT(*) + size 比较,重复 ID(如 [1,1,2])会误判 40004;建议先 dedup 或改用 COUNT(DISTINCT iIncrement) | |
| 21 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:52 — employee/permissionCategory 校验失败抛 COMPANY_NOT_FOUND 但 message 含义错位;后续可拆 RESOURCE_NOT_FOUND 段位或重命名 COMPANY_NOT_FOUND → REFERENCE_NOT_FOUND | |
| 22 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:48 — 每请求 1 次 selectByUsername DB 查询;后续可加 Caffeine 短期缓存(容量评估时再做) | |
| 23 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:60 — claims.get('companyCode') 未做 null 校验;建议在 LoginUser 处文档化或加 log.warn | |
| 24 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:66 — set LoginContext 早于角色守卫;建议加注释说明该顺序是有意的,afterCompletion 保证清理 | |
| 25 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:35 — plan 声明 'handler 非 HandlerMethod → return true',实际未实现该顶部短路;建议补齐对齐 plan | |
| 26 | +- backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java:124 — plan 列出 `loginContext_clearedAfterRequest` 测试用例但未实现;建议补显式清理回归 | |
| 27 | +- backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java:24 — userType/language 用 @Pattern 枚举校验失类型安全;可改为 service 层 toEnum 模式 | |
| 28 | +- backend/src/main/resources/application.yml:8 — fail-on-unknown-properties=true 是全局开关;建议在 docs/04 § 1.3 留痕说明所有 DTO 必须完整定义 | |
| 29 | +- backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java:28 — LoginContext.current() 未 null 校验;建议加防御性 Optional.ofNullable | |
| 30 | +- backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java:17 — entity 含未写入的多租户字段(sId/sBrandsId 等),与 SysUser 风格一致,可接受 | |
| 31 | + | |
| 32 | +## 反例 / 测试覆盖缺口 | |
| 33 | + | |
| 34 | +1. spec § 15「唯一索引兜底(DataIntegrityViolationException 路径)」未实测——UserCreateServiceImplTest 缺乏对 catch 分支的回归 | |
| 35 | +2. plan § Step 1 列出 `loginContext_clearedAfterRequest` 但实际未实现,ThreadLocal 清理路径无显式回归 | |
| 36 | +3. docs/04 § 1.3 段位(10xxx/20xxx/...)与代码 / docs/05 实际使用的 HTTP-aligned 段位(40001/40101/40301/...)冲突——round 1 REQ-USR-001 已记录,本 REQ 沿用未拉齐;建议单独 PR 修订 docs/04 § 1.3 | |
| 37 | +4. `permissionCategoryIds` 含重复 ID 行为未定义(countActiveByIds 用 COUNT(*) + size 会 false-negative),无对应测试 | |
| 38 | + | |
| 39 | +## 总结 | |
| 40 | + | |
| 41 | +REQ-USR-002 整体实现质量较高:鉴权 / 角色守卫拦截器流程清晰、事务边界正确、唯一性预检 + DB 兜底双层防御、初始密码 BCrypt 哈希、Jackson 严格反序列化拒绝 password 字段、85 测试覆盖 spec 验收 1-14。文档同步(docs/05 删除 password+40002)已完成,错误码新增 40301/40901/40902 + HTTP 映射准确。功能、安全、文档与 plan/spec 三方一致,无高/中级阻塞问题。Approve。 | ... | ... |
docs/superpowers/reviews/2026-05-15-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-003 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- docs/05-API接口契约.md PUT § REQ-USR-003 错误码列表缺 40101(GET 段已列)— 本轮归档前已修 | |
| 19 | +- docs/superpowers/specs/2026-05-15-REQ-USR-003.md § 输入表行写 "显式 null 表示解除关联",与 § PATCH 语义实现细节的 "null = 不变;employeeId=0 = 解除关联" 约定矛盾;代码遵循后者,建议日后回填修订前者 | |
| 20 | +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:52 — existsByUserCodeExcludingId 未过滤 iIsDeleted;如希望软删用户不参与唯一性,可补 `AND iIsDeleted=0`,但当前 spec 未要求 | |
| 21 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:108 — permissionCategoryIds 差集流程在 @Transactional 内无行锁;并发 PUT 同一用户可能出现交叉写入。建议未来引入 sys_user.iVersion 做乐观锁 | |
| 22 | +- backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java:127 — update_employeeId_positiveInteger_setsToValue 注释 '避开自我停用守卫' 误导(本测试不会触发守卫);建议改用合法对手用户或删除该注释 | |
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:78 — 复用 COMPANY_NOT_FOUND(40004)抛"权限分类不存在",常量名与 message 字面冲突;建议未来将 40004 重命名为更通用的 RELATED_ENTITY_NOT_FOUND 或拆分专用 code(跨模块技术债) | |
| 24 | + | |
| 25 | +## 反例 / 测试覆盖缺口 | |
| 26 | + | |
| 27 | +无功能或安全级 gap。spec 验收 1-22 全部映射到测试;验收 23(作废用户登录 40103)spec 明确声明属 REQ-USR-001 既有路径,符合约定。事务边界(@Transactional + BizException extends RuntimeException + Spring 默认回滚)正确;自我停用守卫精确;userCode 唯一性"自身同值跳过查询"优化有效;employee 三态(null/0/正整数)与 permissionCategoryIds 三态(缺省/空数组/非空)语义清晰且测试覆盖;作废即时生效由 JwtHandlerInterceptor 每请求重查保证;权限分类差集策略保留项 iIncrement 不变已用单测显式断言。N+1 仅出现在 UserDetailService(user + employee + pcIds 三查),单详情场景可接受。 | |
| 28 | + | |
| 29 | +## 总结 | |
| 30 | + | |
| 31 | +REQ-USR-003 实现严格对齐 spec / plan:GET + PUT 共用 UserDetailVo、PATCH 三态、permissionCategoryIds 增删差集、自我停用守卫、userCode 唯一性排除自身、作废即时生效、事务回滚全部正确实现,147 测试覆盖 spec 验收 1-22。仅有若干文档不一致与可读性建议,本轮归档前已补 docs/05 PUT 错误码 40101。无功能/安全/正确性阻塞。Approve。 | ... | ... |
docs/superpowers/reviews/2026-05-15-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 2 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-004 — round 2 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java:148 — `normalizeQueryValue` 用全限定 `java.time.LocalDate.parse` 而非顶部 import,纯风格瑕疵 | |
| 19 | +- matchMode 白名单校验在 forced-equals 覆盖之前;调用方传 matchMode=contains + queryField=isDeleted 时白名单接受 contains 再静默覆盖为 equals — 行为正确但稍不透明,可加 logger.debug 留痕 | |
| 20 | +- UserListServiceImplTest 末尾 `@Autowired JdbcTemplate jdbc` 字段定义在测试方法之后,风格不一致;建议挪到类顶部 | |
| 21 | +- round 1 已标记『推迟』的 4 项保持不变(queryField=userType 不走 USER_TYPES 白名单;UserQueryParams public 可变字段;list_emptyTable_returnsZeroTotal 命名;mapper ORDER BY 硬编码 u.* 前缀) | |
| 22 | + | |
| 23 | +## 反例 / 测试覆盖缺口 | |
| 24 | + | |
| 25 | +Round 2 测试覆盖完整:spec § 业务规则 3 的 isDeleted/lastLoginDate matchMode 强制 equals + 类型归一化由 3 个新测试明确断言;spec § 验收 1-26 由 198+3 测试映射;spec § 验收 26 空表场景由 list_emptyTable_returnsZeroTotal(虽命名不准)覆盖。Reviewer 子会话无本地 MySQL,未独立复跑 mvn test;信任 commit 8bf84c9 + 主会话 feature-verify 报告的 201/0 结果。 | |
| 26 | + | |
| 27 | +## 本轮变更归档 | |
| 28 | + | |
| 29 | +Round 1 全部修复落地: | |
| 30 | + | |
| 31 | +| # | 项目 | 状态 | | |
| 32 | +|---|------|-----| | |
| 33 | +| M1 | EQUALS_ONLY_FIELDS 强制 matchMode=equals | ✓ 仅在 queryField+queryValue 都非空时覆盖,scoped 严格,不影响其他路径 | | |
| 34 | +| M2 | lastLoginDate 类型归一化 | ✓ 支持 LocalDateTime / LocalDate,非法值抛 40001 | | |
| 35 | +| M3 | queryValue 用 isBlank 判空 | ✓ | | |
| 36 | +| M4 | docs/05 错误码补 sortField + 40101 | ✓ | | |
| 37 | +| Test | 新增 3 个回归测试 | ✓ | | |
| 38 | + | |
| 39 | +未引入新回归。verdict=approve。 | ... | ... |
docs/superpowers/specs/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-001 — 用户登录 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +提供用户名 + 密码 + 公司编码的登录接口,校验通过后签发 JWT access token(HS256,TTL 2 小时)并返回当前用户基础信息,用于后续接口的 Bearer 鉴权。本 REQ 仅签发 access token,refresh token 推迟到后续 REQ(与 docs/05 LoginVo 一致;docs/04 § 1.6 的 refresh 描述视为未来增量,本 REQ 范围内不实现)。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +HTTP 入口 `POST /api/v1/auth/login`,公开接口(无需 Bearer)。 | |
| 16 | + | |
| 17 | +**请求体 LoginReq**(JSON): | |
| 18 | + | |
| 19 | +| 字段 | 类型 | 必填 | 校验规则(登录场景,宽松,不复用创建场景的强度规则) | | |
| 20 | +|---|---|---|---| | |
| 21 | +| `username` | string | 是 | 非空;长度 1-50(容纳潜在历史账号;越界返 `40001`) | | |
| 22 | +| `password` | string | 是 | 非空;长度 1-128(密码强度只在创建/重置流程校验,登录只校验匹配;越界返 `40001`) | | |
| 23 | +| `companyCode` | string | 是 | 非空;最大长度 50;命中 `sys_company.sCompanyCode AND iIsDeleted=0`,否则返 `40004` | | |
| 24 | + | |
| 25 | +> **登录与创建的校验差异**:REQ-USR-002 创建用户时密码必须 8-20 位含大小写字母和数字(强度规则);本 REQ 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。 | |
| 26 | + | |
| 27 | +## 输出 / 结果 | |
| 28 | + | |
| 29 | +**成功 200**:`Result<LoginVo>`,其中 `LoginVo`: | |
| 30 | + | |
| 31 | +```json | |
| 32 | +{ | |
| 33 | + "accessToken": "<JWT HS256, TTL=7200s>", | |
| 34 | + "tokenType": "Bearer", | |
| 35 | + "expiresInSec": 7200, | |
| 36 | + "userInfo": { | |
| 37 | + "userId": 123, | |
| 38 | + "username": "alice", | |
| 39 | + "userType": "NORMAL", | |
| 40 | + "language": "zh-CN", | |
| 41 | + "employeeName": "张三", | |
| 42 | + "companyCode": "HQ" | |
| 43 | + } | |
| 44 | +} | |
| 45 | +``` | |
| 46 | + | |
| 47 | +- `employeeName` 通过 `sys_user.iEmployeeId` JOIN `sys_employee.sEmployeeName` 取得;未绑定职员时字段省略。 | |
| 48 | +- 副作用:写库 `sys_user.iFailedLoginCount = 0`、`sys_user.tLockUntil = NULL`、`sys_user.tLastLoginDate = NOW()`。 | |
| 49 | + | |
| 50 | +**失败**:见错误码段,全部走全局异常处理器映射为 `Result.fail(code, message)`。错误码段位与 docs/05 一致: | |
| 51 | + | |
| 52 | +| HTTP | code | 含义 | 触发条件 | | |
| 53 | +|---|---|---|---| | |
| 54 | +| 400 | 40001 | 用户名或密码格式错误 | 必填字段缺失 / 类型错 / 长度越界 | | |
| 55 | +| 400 | 40004 | 公司不存在或已删除 | `companyCode` 在 `sys_company` 查不到(含 `iIsDeleted=1`) | | |
| 56 | +| 401 | 40101 | 用户名或密码错误 | 用户不存在 OR 密码哈希不匹配(统一文案,不区分两者,防用户名枚举) | | |
| 57 | +| 401 | 40103 | 账号已被作废,禁止登录 | `sys_user.iIsDeleted = 1` | | |
| 58 | +| 423 | 42301 | 账号已锁定,请稍后再试 | `sys_user.tLockUntil IS NOT NULL AND tLockUntil > NOW()`;`data.lockUntil` 返回 ISO 8601 剩余截止时刻 | | |
| 59 | + | |
| 60 | +## 业务规则 | |
| 61 | + | |
| 62 | +1. **账号查找**:以 `sUsername` 全等匹配(大小写敏感,与建表 collation `utf8mb4_unicode_ci` 行为一致,登录时不做规范化)。 | |
| 63 | +2. **作废态优先**:若用户记录 `iIsDeleted = 1`,直接返 `40103`,不进入密码校验,**不**累加 `iFailedLoginCount`。 | |
| 64 | +3. **锁定优先**:若用户 `tLockUntil` 不为空且大于当前时间,直接返 `42301`,**不**进入密码校验,**不**累加。锁定到期(`tLockUntil <= NOW()`)视为已解锁,正常进入密码校验。 | |
| 65 | +4. **密码校验**:使用 Spring Security `BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)`。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——`BCryptPasswordEncoder` 是 Spring Security 默认实现,无额外依赖;strength=10。 | |
| 66 | +5. **失败计数**:密码不匹配时 `sys_user.iFailedLoginCount += 1`。 | |
| 67 | + - 阈值:累计达到 **5 次**(含第 5 次)时,**同步**写 `sys_user.tLockUntil = NOW() + 30 分钟`;下一次(第 6 次)请求会落到锁定分支返 `42301`。 | |
| 68 | + - 阈值前的失败仍返 `40101`,**不**主动告诉客户端剩余尝试次数(防探测)。 | |
| 69 | +6. **登录成功**:原子事务(service 层 `@Transactional`)内: | |
| 70 | + - `sys_user.iFailedLoginCount = 0` | |
| 71 | + - `sys_user.tLockUntil = NULL` | |
| 72 | + - `sys_user.tLastLoginDate = NOW()` | |
| 73 | + - 然后签发 JWT(事务内构造 claim,事务提交后返响应)。 | |
| 74 | +7. **JWT claims**: | |
| 75 | + - `sub` = `sys_user.iIncrement`(数字主键,字符串化) | |
| 76 | + - `username` = `sys_user.sUsername` | |
| 77 | + - `userType` = `sys_user.sUserType` | |
| 78 | + - `companyCode` = 请求传入的 `companyCode`(已校验存在) | |
| 79 | + - `language` = `sys_user.sLanguage` | |
| 80 | + - `iat` / `exp` = 标准时间 claims(TTL 7200s) | |
| 81 | + - `jti` = UUID(为未来 refresh / 黑名单预留,本 REQ 不消费) | |
| 82 | +8. **JWT 密钥**:从 `.env.local` 的 `JWT_SECRET` 读取;`application.yml` 用 `${JWT_SECRET}` 占位符注入,代码层不允许硬编码。 | |
| 83 | + | |
| 84 | +## 边界与约束 | |
| 85 | + | |
| 86 | +- **HTTPS 传输**:密码以明文走 HTTPS body,生产部署由 Nginx 终止 TLS(docs/07 § 二);本 REQ 不在应用层加密 / 解密密码。 | |
| 87 | +- **统一响应**:全部走 `Result` / `Result.fail`(docs/04 § 1.3);错误响应禁回显堆栈。 | |
| 88 | +- **审计日志**:登录成功 / 失败 / 锁定均通过 Logback 写 INFO / WARN 日志(含 traceId 与 username),日志位置遵循 docs/04 / docs/07;本 REQ 不写额外的 DB 审计表。 | |
| 89 | +- **Redis**:docs/04 § 1.6 提及"签发后写 Redis",**本 REQ 不实现**——签发后不写 Redis,JWT 自包含可独立验证;登出黑名单功能推迟到后续 REQ。`pom.xml` 不强制要求 redis 依赖(如已加入也不使用)。 | |
| 90 | +- **并发安全**:失败计数 / 锁定写入位于同一事务;MySQL 默认 RR 隔离 + 行锁可避免并发同账号失败计数竞争。 | |
| 91 | +- **不实现**:管理员手动解锁、密码重置、refresh token、登出、多端互踢、Redis 黑名单——全部推迟到后续 REQ。 | |
| 92 | + | |
| 93 | +## 依赖的 schema 表 / 字段 | |
| 94 | + | |
| 95 | +读 + 写 `sys_user`(V1 已建): | |
| 96 | +- 读:`iIncrement`, `sUsername`, `sPasswordHash`, `sUserType`, `sLanguage`, `iEmployeeId`, `iIsDeleted`, `iFailedLoginCount`, `tLockUntil` | |
| 97 | +- 写:`iFailedLoginCount`, `tLockUntil`, `tLastLoginDate` | |
| 98 | + | |
| 99 | +只读 `sys_company`(V1 已建): | |
| 100 | +- 读:`sCompanyCode`, `iIsDeleted` | |
| 101 | + | |
| 102 | +只读 JOIN `sys_employee`(V1 已建): | |
| 103 | +- 读:`sEmployeeName` | |
| 104 | + | |
| 105 | +**本 REQ 不需要新增 migration**(schema 已就绪)。 | |
| 106 | + | |
| 107 | +## 依赖的接口 | |
| 108 | + | |
| 109 | +- 本 REQ 提供:`POST /api/v1/auth/login`(docs/05 § module_usr)。 | |
| 110 | +- 外部依赖(前端会调,但本 REQ 范围不实现):`GET /api/v1/companies`(公司下拉),由后续运营模块或前端阶段使用 fixture 数据替代。 | |
| 111 | + | |
| 112 | +## 验收标准 | |
| 113 | + | |
| 114 | +后端集成测试(不依赖前端): | |
| 115 | + | |
| 116 | +1. **正确凭据 + 启用账号 + 存在公司** → 200,返回 `accessToken`(非空 JWT),`userInfo` 字段完整;`sys_user.tLastLoginDate` 更新、`iFailedLoginCount = 0`、`tLockUntil IS NULL`。 | |
| 117 | +2. **JWT 可验签**:用 `JWT_SECRET` HS256 验签通过;`sub` = `userId` 字符串;`exp - iat == 7200`。 | |
| 118 | +3. **错误密码 N 次(N<5)** → 401 / 40101;`iFailedLoginCount = N`;`tLockUntil` 仍为 NULL。 | |
| 119 | +4. **第 5 次错误密码** → 401 / 40101;`iFailedLoginCount = 5`;`tLockUntil` 被写为 NOW() + 30 分钟。 | |
| 120 | +5. **锁定期间第 6 次(任意密码)** → 423 / 42301;返回 `data.lockUntil` 字段;`iFailedLoginCount` 不再累加。 | |
| 121 | +6. **锁定到期后** → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。 | |
| 122 | +7. **作废账号(iIsDeleted=1)正确密码** → 401 / 40103;`iFailedLoginCount` 不变。 | |
| 123 | +8. **用户名不存在** → 401 / 40101(与密码错误同文案)。 | |
| 124 | +9. **公司不存在 / 已删除** → 400 / 40004,**不**累加失败计数(先于密码校验完成公司校验)。 | |
| 125 | +10. **请求体缺字段 / 越长** → 400 / 40001。 | |
| 126 | +11. **响应不回显堆栈**:故意制造服务端异常时,响应 `message` 为通用文案,无 stack trace。 | |
| 127 | +12. **日志记录**:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。 | ... | ... |
docs/superpowers/specs/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-05-15 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-002 — 新增用户 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +超级管理员通过 `POST /api/v1/users` 新建用户账号。账号立即生效;初始密码由系统统一设为 `666666`(哈希后存入);可选关联职员;同时按勾选写入权限分类授权关系。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +HTTP 入口 `POST /api/v1/users`。要求请求头 `Authorization: Bearer <accessToken>`。 | |
| 16 | + | |
| 17 | +**请求体 CreateUserReq**(JSON): | |
| 18 | + | |
| 19 | +| 字段 | 类型 | 必填 | 校验规则 | | |
| 20 | +|---|---|---|---| | |
| 21 | +| `username` | string | 是 | 非空;3-20 位字母数字下划线(正则 `^[A-Za-z0-9_]{3,20}$`);系统内唯一(命中 `sys_user.sUsername` 返 40901) | | |
| 22 | +| `userCode` | string | 是 | 非空;最大 50;系统内唯一(命中 `sys_user.sUserCode` 返 40902) | | |
| 23 | +| `userType` | string | 是 | 枚举 `NORMAL` / `SUPER_ADMIN` | | |
| 24 | +| `language` | string | 是 | 枚举 `zh-CN` / `en-US` / `zh-TW` | | |
| 25 | +| `canEditDocument` | boolean | 是 | true / false | | |
| 26 | +| `employeeId` | int | 否 | 若不为 null,必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004 | | |
| 27 | +| `permissionCategoryIds` | int[] | 否 | 可为空数组或省略;每个元素必须命中 `sys_permission_category.iIncrement` AND `iIsDeleted=0`,否则返 40004 | | |
| 28 | + | |
| 29 | +> **本 REQ 不接受 `password` 字段**:用户提交的任何 password 字段被忽略(或返 40001)。docs/05 应同步删除 `password` 与 `40002` —— 本 REQ 落地时由实现方在 docs/05 一并修订(spec 自审范畴)。 | |
| 30 | + | |
| 31 | +## 输出 / 结果 | |
| 32 | + | |
| 33 | +**成功 201 Created**:`Result<CreateUserVo>` | |
| 34 | + | |
| 35 | +```json | |
| 36 | +{ "code": 200, "message": "操作成功", "data": { "userId": 42, "username": "alice", "userCode": "U001" }, "timestamp": ... } | |
| 37 | +``` | |
| 38 | + | |
| 39 | +> HTTP 201 体现新资源创建;body 仍走统一 `Result` 包装(docs/04 § 1.3)。 | |
| 40 | + | |
| 41 | +副作用(同一事务): | |
| 42 | +1. `sys_user` 插入一条新记录: | |
| 43 | + - `sPasswordHash` = `BCryptPasswordEncoder.encode("666666")` | |
| 44 | + - `iFailedLoginCount` = 0、`tLockUntil` = NULL、`tLastLoginDate` = NULL | |
| 45 | + - `iIsDeleted` = 0 | |
| 46 | + - `sCreatedBy` = 当前登录用户 username(来自 JWT claim) | |
| 47 | +2. `sys_user_permission_category` 按 `permissionCategoryIds` 批量插入授权记录,`sGrantedBy` = 当前登录用户 username | |
| 48 | + | |
| 49 | +**失败**: | |
| 50 | + | |
| 51 | +| HTTP | code | 含义 | 触发条件 | | |
| 52 | +|---|---|---|---| | |
| 53 | +| 400 | 40001 | 必填字段缺失或格式错误 | jakarta 校验失败(@NotBlank / @Pattern / 枚举不合法);请求包含 `password` 字段(本 REQ 不允许) | | |
| 54 | +| 400 | 40004 | 员工或权限分类不存在 | `employeeId` 或 `permissionCategoryIds` 中任一不命中 | | |
| 55 | +| 401 | 40101 | 未携带或无效 Token | Authorization 缺失 / 解析失败 / 用户已作废 / 用户已锁定 | | |
| 56 | +| 403 | 40301 | 非超级管理员调用 | JWT claim `userType != SUPER_ADMIN` | | |
| 57 | +| 409 | 40901 | 用户名已存在 | sUsername 唯一冲突(DB 唯一键 `uk_sys_user_username` 兜底;service 层 select 预检以返友好错误) | | |
| 58 | +| 409 | 40902 | 用户号已存在 | sUserCode 唯一冲突 | | |
| 59 | + | |
| 60 | +## 业务规则 | |
| 61 | + | |
| 62 | +1. **鉴权**:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,注册到 `WebMvcConfigurer`,匹配 `/api/v1/**` 但排除 `/api/v1/auth/login`: | |
| 63 | + - 读 `Authorization: Bearer <token>` 头;缺失 → 抛 `BizException(40101, "未携带 token")` | |
| 64 | + - `JwtUtil.parse(token)` 抛 BizException 时透传(JwtUtil 内部已返 40101) | |
| 65 | + - 解析 claims → 查 `sys_user.iIncrement = sub`;若不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → 抛 `BizException(40101, "token 关联用户不可用")` | |
| 66 | + - 把 username / userType / userId / companyCode 放入 `LoginContext`(ThreadLocal 工具)供后续使用 | |
| 67 | + - 业务方法结束时(`afterCompletion`)清理 ThreadLocal | |
| 68 | +2. **角色守卫**:用自定义注解 `@RequireSuperAdmin`(标注在 controller 方法上)+ 同一 interceptor 检查 `handler instanceof HandlerMethod && method.isAnnotationPresent(...)` → 校验 `LoginContext.userType == SUPER_ADMIN`;不匹配 → 抛 `BizException(40301, "权限不足,仅超级管理员可调用")` | |
| 69 | +3. **唯一性校验**:在 service 写入前用 `selectByUsername` / `selectByUserCode` 查重(友好返 40901 / 40902);DB 唯一索引兜底(捕获 `DataIntegrityViolationException` 转 40901 或 40902,避免堆栈泄漏) | |
| 70 | +4. **外键校验**:employeeId / permissionCategoryIds 在写入前一次性 select 校验(SELECT iIncrement FROM ... WHERE iIncrement IN(...) AND iIsDeleted=0),数量不齐 → 40004 | |
| 71 | +5. **初始密码**:固定字符串 `"666666"`,通过 `BCryptPasswordEncoder.encode` 哈希;常量定义在 `LoginServiceImpl.INITIAL_PASSWORD`(已存在)或新建 `UserCreateServiceImpl.INITIAL_PASSWORD` | |
| 72 | +6. **事务**:`@Transactional`,sys_user 插入 + sys_user_permission_category 批量插入在同一事务;唯一冲突或 FK 不存在均回滚 | |
| 73 | +7. **本 REQ 不接受 password 字段**:DTO 不定义 password 属性;若客户端误传,Jackson 默认配置(`FAIL_ON_UNKNOWN_PROPERTIES=false`)会忽略——但为防御性,建议 `application.yml` 设 `spring.jackson.deserialization.fail-on-unknown-properties: true`,确保多余字段返 40001 | |
| 74 | + | |
| 75 | +## 边界与约束 | |
| 76 | + | |
| 77 | +- **HTTPS / 鉴权 / 统一响应 / 异常**:复用 REQ-USR-001 已建的全部基础设施 | |
| 78 | +- **HandlerInterceptor 位置**:放在 `backend/src/main/java/com/xly/erp/common/security/`,与 `JwtUtil` 同包 | |
| 79 | +- **ThreadLocal 工具**:`LoginContext` 单例,提供 `current() / set() / clear()`;测试场景可手工 set 以模拟登录态 | |
| 80 | +- **@RequireSuperAdmin 注解**:放在 `backend/src/main/java/com/xly/erp/common/security/` | |
| 81 | +- **不实现**: | |
| 82 | + - 修改密码 / 重置密码(推迟到后续 REQ) | |
| 83 | + - 用户软删除接口(推迟到 REQ-USR-003 的"作废"路径) | |
| 84 | + - 权限分类的新增 / 编辑(运营模块) | |
| 85 | + - 员工的 CRUD(HR 模块) | |
| 86 | + - JWT 黑名单 / refresh token / 多端互踢(推迟) | |
| 87 | +- **docs/05 同步修订**:本 REQ 实现时需同步修订 docs/05 § REQ-USR-002,去掉 `password` 字段与 `40002` 错误码(与 REQ 卡片对齐为"系统生成初始密码") | |
| 88 | + | |
| 89 | +## 依赖的 schema 表 / 字段 | |
| 90 | + | |
| 91 | +写 `sys_user`(V1 已建): | |
| 92 | +- 写入:`sUsername`, `sUserCode`, `sPasswordHash`, `iEmployeeId`, `sUserType`, `sLanguage`, `iCanEditDocument`, `iIsDeleted=0`, `iFailedLoginCount=0`, `sCreatedBy` | |
| 93 | + | |
| 94 | +写 `sys_user_permission_category`(V1 已建): | |
| 95 | +- 写入:`iUserId`, `iPermissionCategoryId`, `sGrantedBy` | |
| 96 | + | |
| 97 | +只读 `sys_employee`(V1 已建): | |
| 98 | +- 读:`iIncrement`, `iIsDeleted`(校验 employeeId 存在) | |
| 99 | + | |
| 100 | +只读 `sys_permission_category`(V1 已建): | |
| 101 | +- 读:`iIncrement`, `iIsDeleted`(校验 permissionCategoryIds 全部存在) | |
| 102 | + | |
| 103 | +**本 REQ 不需要新增 migration**。 | |
| 104 | + | |
| 105 | +## 依赖的接口 | |
| 106 | + | |
| 107 | +- 本 REQ 提供:`POST /api/v1/users` | |
| 108 | +- 鉴权前置依赖:JWT 由 REQ-USR-001 签发 | |
| 109 | +- 下游会消费但本 REQ 不实现的接口: | |
| 110 | + - `GET /api/v1/employees`(员工下拉,后续 HR 模块) | |
| 111 | + - `GET /api/v1/permission-categories`(权限分类下拉,后续运营模块) | |
| 112 | + | |
| 113 | +## 验收标准 | |
| 114 | + | |
| 115 | +后端集成测试: | |
| 116 | + | |
| 117 | +1. **正常路径(最小字段)**:管理员 token + 合法 username / userCode / userType / language / canEditDocument,不带 employeeId / permissionCategoryIds → 201;DB 出现新用户记录,`sPasswordHash` 非空且可被 `BCryptPasswordEncoder.matches("666666", ...)` 验证;`iIsDeleted=0`,`iFailedLoginCount=0`,`sCreatedBy` = 管理员 username | |
| 118 | +2. **正常路径(完整字段)**:带 employeeId + permissionCategoryIds(2 条)→ 201;DB sys_user 一行 + sys_user_permission_category 两行;`sGrantedBy` 正确 | |
| 119 | +3. **新用户立刻可登录**:调用 `POST /api/v1/auth/login` 用初始密码 `666666` + 任意有效公司 → 200 + token | |
| 120 | +4. **缺 Authorization 头** → 401 / 40101 | |
| 121 | +5. **Token 无效(篡改)** → 401 / 40101 | |
| 122 | +6. **Token 关联用户已作废** → 401 / 40101 | |
| 123 | +7. **NORMAL 用户 token 调用** → 403 / 40301 | |
| 124 | +8. **username 重复** → 409 / 40901;DB 没有新用户被插入 | |
| 125 | +9. **userCode 重复** → 409 / 40902;DB 没有新用户被插入 | |
| 126 | +10. **employeeId 不存在 / 已软删** → 400 / 40004 | |
| 127 | +11. **permissionCategoryIds 含不存在 ID** → 400 / 40004;事务回滚(sys_user 也未插入) | |
| 128 | +12. **请求体缺 username / username 含非法字符** → 400 / 40001 | |
| 129 | +13. **请求体携带 password 字段** → 400 / 40001(Jackson 严格反序列化) | |
| 130 | +14. **userType / language 非枚举值** → 400 / 40001 | |
| 131 | +15. **唯一索引兜底**:模拟绕过 service 层预检的并发场景(DataIntegrityViolationException 路径),断言返 40901 而非 50000 | |
| 132 | + | |
| 133 | +测试基础:复用 `LoginTestSeeder` + 扩展 `UserCreateTestSeeder`(管理员 token 注入 + 权限分类 fixture)。 | ... | ... |
docs/superpowers/specs/2026-05-15-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-05-15 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-003 — 修改用户 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +超级管理员对已有用户的非密码、非用户名字段做部分更新(PATCH 语义),并支持增量增删权限分类授权。同时提供 GET 详情接口供前端表单回显(与修改入参 / 返回字段同源)。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +两个 HTTP 入口(均需 `Authorization: Bearer <accessToken>` 且 `userType=SUPER_ADMIN`): | |
| 16 | + | |
| 17 | +### 1. `GET /api/v1/users/{userId}` 用户详情 | |
| 18 | + | |
| 19 | +Path:`userId: int`(命中 `sys_user.iIncrement`,否则 40401)。 | |
| 20 | + | |
| 21 | +无请求体。 | |
| 22 | + | |
| 23 | +### 2. `PUT /api/v1/users/{userId}` 修改用户 | |
| 24 | + | |
| 25 | +Path:`userId: int`。 | |
| 26 | + | |
| 27 | +**请求体 UpdateUserReq**(JSON,PATCH 语义;任一字段缺省视为不变): | |
| 28 | + | |
| 29 | +| 字段 | 类型 | 必填 | 校验 | | |
| 30 | +|---|---|---|---| | |
| 31 | +| `userCode` | string | 否 | 提供时 `@Size(max=50) @NotBlank`;若与其他用户的 sUserCode 冲突返 40902(当前用户自身的同值视为不变,跳过冲突判定)| | |
| 32 | +| `userType` | string | 否 | 提供时 `NORMAL` / `SUPER_ADMIN` | | |
| 33 | +| `language` | string | 否 | 提供时 `zh-CN` / `en-US` / `zh-TW` | | |
| 34 | +| `canEditDocument` | boolean | 否 | true / false | | |
| 35 | +| `employeeId` | int 或 null | 否 | 提供为非 null 时必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004;显式传 `null` 表示解除关联,DB 写 NULL | | |
| 36 | +| `isDeleted` | boolean | 否 | true 表示作废 / false 表示恢复启用;尝试停用当前登录用户自己返 40302 | | |
| 37 | +| `permissionCategoryIds` | int[] | 否 | 提供时按差集做增删;每个元素必须命中 `sys_permission_category.iIncrement AND iIsDeleted=0`,否则返 40004;缺省表示权限分类不变 | | |
| 38 | + | |
| 39 | +**严禁字段**:请求体含 `username` 或 `password` 字段直接返 40001(Jackson `fail-on-unknown-properties=true` 已在 REQ-USR-002 启用全局,缺省命中此防御)。 | |
| 40 | + | |
| 41 | +### permissionCategoryIds 增删差集策略 | |
| 42 | + | |
| 43 | +- 设当前已授权集合 `current = {pcId1, pcId2, ...}`(从 `sys_user_permission_category WHERE iUserId=?` 查) | |
| 44 | +- 设请求集合 `target = req.permissionCategoryIds` | |
| 45 | +- `toRemove = current \ target` → DELETE FROM sys_user_permission_category WHERE iUserId=? AND iPermissionCategoryId IN (toRemove) | |
| 46 | +- `toAdd = target \ current` → INSERT 对应行,`sGrantedBy = 当前登录 username` | |
| 47 | +- 同 `target ∩ current` 项保持不动(iIncrement / tCreateDate 不变) | |
| 48 | + | |
| 49 | +## 输出 / 结果 | |
| 50 | + | |
| 51 | +两个接口共用 `UserDetailVo`: | |
| 52 | + | |
| 53 | +```json | |
| 54 | +{ | |
| 55 | + "userId": 42, | |
| 56 | + "username": "alice", | |
| 57 | + "userCode": "U001", | |
| 58 | + "userType": "NORMAL", | |
| 59 | + "language": "zh-CN", | |
| 60 | + "canEditDocument": false, | |
| 61 | + "isDeleted": false, | |
| 62 | + "employeeId": 7, | |
| 63 | + "employeeName": "张三", | |
| 64 | + "permissionCategoryIds": [1, 2], | |
| 65 | + "updatedBy": "admin", | |
| 66 | + "updatedDate": "2026-05-15T09:30:00" | |
| 67 | +} | |
| 68 | +``` | |
| 69 | + | |
| 70 | +- `employeeName` 通过 `iEmployeeId` JOIN `sys_employee.sEmployeeName`;未关联职员时省略 | |
| 71 | +- `permissionCategoryIds` 是当前授权集合(增删后的最终状态);空数组表示无授权 | |
| 72 | +- `updatedBy` / `updatedDate` 由本接口在更新时写入(`sUpdatedBy` / `tUpdatedDate`);GET 取 DB 现有值,可能为 null | |
| 73 | + | |
| 74 | +**成功 200 OK**:`Result<UserDetailVo>` | |
| 75 | + | |
| 76 | +**失败**: | |
| 77 | + | |
| 78 | +| HTTP | code | 含义 | 触发条件 | | |
| 79 | +|---|---|---|---| | |
| 80 | +| 400 | 40001 | 请求体格式错误 / 含未知字段(username/password) | jakarta 校验 OR Jackson fail-on-unknown | | |
| 81 | +| 400 | 40004 | 员工或权限分类不存在 | employeeId / permissionCategoryIds 校验失败 | | |
| 82 | +| 401 | 40101 | 未携带或无效 Token | 鉴权层 | | |
| 83 | +| 403 | 40301 | 非超级管理员调用 | 角色守卫 | | |
| 84 | +| 403 | 40302 | 试图停用当前登录用户自己 | `req.isDeleted == true && userId == LoginContext.userId()` | | |
| 85 | +| 404 | 40401 | 用户不存在 | `userId` 不命中 `sys_user.iIncrement` | | |
| 86 | +| 409 | 40902 | 用户号已被占用 | 提供的 userCode 命中其他用户的 sUserCode(排除自身) | | |
| 87 | + | |
| 88 | +## 业务规则 | |
| 89 | + | |
| 90 | +1. **鉴权 / 角色守卫**:复用 REQ-USR-002 的 JwtHandlerInterceptor + `@RequireSuperAdmin`。两个接口都标 `@RequireSuperAdmin`。 | |
| 91 | +2. **存在性校验**:先查 `sys_user.iIncrement = userId AND iIsDeleted ∈ {0,1}`(包含作废用户,因为恢复启用允许);找不到 → 40401。 | |
| 92 | +3. **自我停用守卫**(PUT 专属):`req.isDeleted == true && userId == LoginContext.current().userId()` → 40302。**注意**:恢复启用(`isDeleted == false`)即便针对自己也允许(不会有此场景,因为已登录用户必然非作废,但仍保留对称语义)。 | |
| 93 | +4. **userCode 唯一性**(PUT 专属):仅当 `req.userCode != null && !req.userCode.equals(currentUser.sUserCode)` 时才检查;用 `selectByUserCodeExcludingId(userCode, userId)` 排除自身。冲突 → 40902。 | |
| 94 | +5. **外键校验**:employeeId(非 null)和 permissionCategoryIds(非 null)按 REQ-USR-002 同样的方式校验。`employeeId == null` 显式表示解除关联(DB 置 NULL),与字段缺省不同(缺省 = 不变)。 | |
| 95 | +6. **部分更新**:只更新请求体中显式提供的字段;用 MyBatis-Plus `UpdateWrapper` 显式列出 set 项。`sUpdatedBy = LoginContext.current().username()`、`tUpdatedDate = NOW()` 一定写。 | |
| 96 | +7. **作废即时生效**:PUT 把 `iIsDeleted=1` 写库后,该用户已签发的 token 在下一次请求时会被 JwtHandlerInterceptor 检测到 iIsDeleted=1 并返 40101(已由 REQ-USR-002 基础设施保证)。 | |
| 97 | +8. **GET 详情**:聚合 sys_user + sys_employee(JOIN)+ sys_user_permission_category(IN 查询)一次返回;不查询作废过滤之外的额外字段。 | |
| 98 | + | |
| 99 | +## PATCH 语义实现细节 | |
| 100 | + | |
| 101 | +`UpdateUserReq` 用包装类型(Integer / Boolean / String / List)+ 自定义 `JsonNode` 检测"字段是否在 JSON 中出现"以区分"显式 null"与"缺省"。 | |
| 102 | + | |
| 103 | +具体方案: | |
| 104 | +- `userCode` / `userType` / `language` / `canEditDocument` / `isDeleted` / `permissionCategoryIds` — 缺省 = 不变;提供(非 null)= 更新;**不接受**显式 null(@NotNull 在 service 层不强制,但缺省即视为 null 表示"不变") | |
| 105 | +- `employeeId` — 三态:缺省(不变)/ 非 null 整数(更新)/ 显式 null(解除关联)。用 `JsonNullable<Integer>`(来自 `openapi-generator` 工具库)实现三态。**最小可行替代**:在 controller 层用 `JsonNode` 解析 `employeeId` 字段,传给 service 一个 `EmployeeIdUpdate` 三态枚举(`UNCHANGED / SET(value) / UNSET`)。 | |
| 106 | + | |
| 107 | +> **简化决策**:本 REQ 不引入 `jakarta.json` 或 `JsonNullable` 第三方库(违反技术栈表)。采用如下约定: | |
| 108 | +> - 请求体仅当字段**存在且值非 null**时才更新;字段**完全缺省**视为不变;字段**显式 null** 视为不变(即不区分缺省与显式 null) | |
| 109 | +> - 单独提供"清除关联"语义:`employeeId == 0` 视为解除关联(DB 写 NULL)。这是一个约定(非业界标准 PATCH),spec 必须明示 | |
| 110 | + | |
| 111 | +实现简化后:`UpdateUserReq` 所有字段都是普通可空包装类型;`employeeId` 取值规则:null / 缺省 → 不变;`0` → 解除关联;正整数 → 更新到该 ID。 | |
| 112 | + | |
| 113 | +## 边界与约束 | |
| 114 | + | |
| 115 | +- **基础设施复用**:鉴权 / GlobalExceptionHandler / Result / BizException / LoginContext / JwtUtil / BCryptPasswordEncoder / SeederFixture 全部复用 REQ-USR-002 | |
| 116 | +- **ErrorCode 新增**:`USER_FORBIDDEN_SELF_DEACTIVATE = 40302`(HTTP 403)、`USER_NOT_FOUND = 40401`(HTTP 404)。`ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,本 REQ 不需要新增映射。 | |
| 117 | +- **不实现**: | |
| 118 | + - 用户名 / 密码修改(推迟到独立 REQ) | |
| 119 | + - 批量修改(YAGNI) | |
| 120 | + - 修改历史审计表(推迟) | |
| 121 | + - GET 列表(REQ-USR-004 范围) | |
| 122 | + | |
| 123 | +## 依赖的 schema 表 / 字段 | |
| 124 | + | |
| 125 | +读 + 写 `sys_user`(V1 已建): | |
| 126 | +- 读:iIncrement / sUsername / sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sCreatedBy / sUpdatedBy / tUpdatedDate / tCreateDate(详情查询需要) | |
| 127 | +- 写:sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sUpdatedBy / tUpdatedDate | |
| 128 | + | |
| 129 | +读 + 写 `sys_user_permission_category`(V1 已建): | |
| 130 | +- 增删差集 | |
| 131 | + | |
| 132 | +只读 `sys_employee`(V1 已建): | |
| 133 | +- 校验 employeeId 存在 + 取 sEmployeeName | |
| 134 | + | |
| 135 | +只读 `sys_permission_category`(V1 已建): | |
| 136 | +- countActiveByIds 校验 | |
| 137 | + | |
| 138 | +**本 REQ 不需要新增 migration**。 | |
| 139 | + | |
| 140 | +## 依赖的接口 | |
| 141 | + | |
| 142 | +- 本 REQ 提供: | |
| 143 | + - `GET /api/v1/users/{userId}` — 用户详情 | |
| 144 | + - `PUT /api/v1/users/{userId}` — 修改用户 | |
| 145 | +- 前置依赖:JWT 由 REQ-USR-001 签发;JwtHandlerInterceptor 由 REQ-USR-002 提供 | |
| 146 | + | |
| 147 | +## 验收标准 | |
| 148 | + | |
| 149 | +后端集成测试: | |
| 150 | + | |
| 151 | +### GET 详情 | |
| 152 | + | |
| 153 | +1. **admin token + 存在用户** → 200,UserDetailVo 完整(含 employeeName + permissionCategoryIds) | |
| 154 | +2. **admin token + 不存在 userId** → 404 / 40401 | |
| 155 | +3. **NORMAL token** → 403 / 40301 | |
| 156 | +4. **无 Authorization** → 401 / 40101 | |
| 157 | +5. **作废用户** → 200(详情查询包含作废用户;不过滤) | |
| 158 | + | |
| 159 | +### PUT 修改 | |
| 160 | + | |
| 161 | +6. **修改 userCode + userType + language**(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变 | |
| 162 | +7. **修改 employeeId 为另一个有效员工** → 200,DB 写新值 | |
| 163 | +8. **修改 employeeId=0 解除关联** → 200,DB 写 NULL | |
| 164 | +9. **修改 employeeId=99999 不存在** → 400 / 40004 | |
| 165 | +10. **修改 isDeleted=true** → 200,DB 写 1;该用户原 token 下一次请求返 40101 | |
| 166 | +11. **修改 permissionCategoryIds(差集增删)**:初始权限 `[1,2]`,请求 `[2,3]` → 200,最终 DB 状态 `[2,3]`;分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换) | |
| 167 | +12. **permissionCategoryIds 为空数组** → 200,最终授权清空 | |
| 168 | +13. **permissionCategoryIds 含不存在 ID** → 400 / 40004,事务回滚(DB 授权无变化) | |
| 169 | +14. **修改 userCode 冲突(其他用户已用)** → 409 / 40902 | |
| 170 | +15. **修改 userCode 等于自身原值** → 200,无 40902 | |
| 171 | +16. **试图停用自己**(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302 | |
| 172 | +17. **请求体含 username 字段** → 400 / 40001 | |
| 173 | +18. **请求体含 password 字段** → 400 / 40001 | |
| 174 | +19. **userId 不存在** → 404 / 40401 | |
| 175 | +20. **NORMAL token 调用 PUT** → 403 / 40301 | |
| 176 | +21. **空请求体 `{}`** → 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变) | |
| 177 | + | |
| 178 | +### PUT + 立即可观察 | |
| 179 | + | |
| 180 | +22. **修改后调用 GET 详情** → 返回的字段反映 PUT 的写入值 | |
| 181 | +23. **作废用户尝试登录** → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致) | ... | ... |
docs/superpowers/specs/2026-05-15-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-15 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-004 — 查询用户 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +`GET /api/v1/users`:超级管理员分页 + 多字段筛选 + 排序查询用户列表,单条记录聚合 sys_user + sys_employee + sys_department 信息(部门名、员工名)。只读,无写副作用,不返回密码。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +HTTP 入口 `GET /api/v1/users`,要求 `Authorization: Bearer <accessToken>` + `userType=SUPER_ADMIN`。 | |
| 16 | + | |
| 17 | +**Query 参数**(全部可选): | |
| 18 | + | |
| 19 | +| 参数 | 类型 | 默认 | 校验 | | |
| 20 | +|---|---|---|---| | |
| 21 | +| `page` | int | 1 | `@Min(1)`;< 1 返 40001;大于总页数返"最后一页"数据(不是空列表) | | |
| 22 | +| `size` | int | 20 | `@Min(1) @Max(100)`;越界返 40001 | | |
| 23 | +| `sortField` | string | `tCreateDate` | 白名单:`tCreateDate` / `tLastLoginDate` / `sUsername` / `sUserCode`;不在白名单返 40003 | | |
| 24 | +| `sortOrder` | string | `desc` | `asc` / `desc`;其他值返 40001 | | |
| 25 | +| `queryField` | string | (不筛选) | 白名单:`username` / `employeeName` / `userCode` / `departmentName` / `userType` / `isDeleted` / `lastLoginDate` / `createdBy`;不在白名单返 40003 | | |
| 26 | +| `matchMode` | string | `contains` | `contains` / `notContains` / `equals`;不在白名单返 40003 | | |
| 27 | +| `queryValue` | string | (不筛选) | 任意字符串;空字符串或 null 视为不应用此条件;与 queryField 配对使用——只提供 queryField 不提供 queryValue 也视为不应用 | | |
| 28 | +| `userType` | string | (不筛选) | 若提供,必须是 `NORMAL` / `SUPER_ADMIN`;其他返 40001 | | |
| 29 | +| `isDeleted` | boolean | (不筛选) | true / false | | |
| 30 | + | |
| 31 | +> **复合规则**:所有筛选条件用 `AND` 拼接:queryField+queryValue 提供时构成一条动态条件;userType / isDeleted 作为额外固定条件叠加。 | |
| 32 | + | |
| 33 | +## 输出 / 结果 | |
| 34 | + | |
| 35 | +**成功 200**:`Result<PageResult<UserListItemVo>>` | |
| 36 | + | |
| 37 | +```json | |
| 38 | +{ | |
| 39 | + "code": 200, | |
| 40 | + "message": "操作成功", | |
| 41 | + "data": { | |
| 42 | + "records": [ | |
| 43 | + { | |
| 44 | + "userId": 42, | |
| 45 | + "username": "alice", | |
| 46 | + "employeeName": "张三", | |
| 47 | + "userCode": "U001", | |
| 48 | + "departmentName": "技术部", | |
| 49 | + "userType": "NORMAL", | |
| 50 | + "language": "zh-CN", | |
| 51 | + "isDeleted": false, | |
| 52 | + "lastLoginDate": "2026-05-15T08:00:00", | |
| 53 | + "createdBy": "admin", | |
| 54 | + "createdDate": "2026-05-15T07:00:00" | |
| 55 | + } | |
| 56 | + ], | |
| 57 | + "total": 17, | |
| 58 | + "page": 1, | |
| 59 | + "size": 20 | |
| 60 | + } | |
| 61 | +} | |
| 62 | +``` | |
| 63 | + | |
| 64 | +字段: | |
| 65 | +- `userId` = sys_user.iIncrement | |
| 66 | +- `username` / `userCode` / `userType` / `language` / `isDeleted` / `lastLoginDate` / `createdBy` / `createdDate` 直接来自 sys_user | |
| 67 | +- `employeeName` = LEFT JOIN sys_employee.sEmployeeName(用户未关联职员时返回 null) | |
| 68 | +- `departmentName` = LEFT JOIN sys_department.sDepartmentName via sys_employee.iDepartmentId(未关联或部门软删时返回 null) | |
| 69 | + | |
| 70 | +**失败**: | |
| 71 | + | |
| 72 | +| HTTP | code | 含义 | 触发 | | |
| 73 | +|---|---|---|---| | |
| 74 | +| 400 | 40001 | 分页 / 类型 / 排序参数错误 | page<1 / size<1 / size>100 / sortOrder 非 asc-desc / userType 非枚举 | | |
| 75 | +| 400 | 40003 | queryField / matchMode / sortField 不在白名单 | 用户传非法枚举值 | | |
| 76 | +| 401 | 40101 | 未携带或无效 Token | 鉴权层 | | |
| 77 | +| 403 | 40301 | 非超级管理员 | 角色守卫 | | |
| 78 | + | |
| 79 | +## 业务规则 | |
| 80 | + | |
| 81 | +1. **鉴权**:复用 `@RequireSuperAdmin` + JwtHandlerInterceptor。 | |
| 82 | +2. **白名单映射**(service 层维护静态 Map<String, String>): | |
| 83 | + - `sortField` 入参 → SQL 列名:`tCreateDate`/`tLastLoginDate`/`sUsername`/`sUserCode` 均与列名同名(直接使用)。**禁止用户传任意列名**——必须白名单匹配。 | |
| 84 | + - `queryField` 入参 → SQL 列引用(含 JOIN 别名): | |
| 85 | + - `username` → `u.sUsername` | |
| 86 | + - `employeeName` → `e.sEmployeeName` | |
| 87 | + - `userCode` → `u.sUserCode` | |
| 88 | + - `departmentName` → `d.sDepartmentName` | |
| 89 | + - `userType` → `u.sUserType` | |
| 90 | + - `isDeleted` → `u.iIsDeleted` | |
| 91 | + - `lastLoginDate` → `u.tLastLoginDate` | |
| 92 | + - `createdBy` → `u.sCreatedBy` | |
| 93 | +3. **matchMode 处理**: | |
| 94 | + - `contains` → `LIKE CONCAT('%', #{queryValue}, '%')` | |
| 95 | + - `notContains` → `NOT LIKE CONCAT('%', #{queryValue}, '%') OR <col> IS NULL` | |
| 96 | + - `equals` → `= #{queryValue}` | |
| 97 | + - 对 `isDeleted`(int)/ `lastLoginDate`(datetime)这类非字符串字段:无论 matchMode 一律按 `equals` 处理(service 层规范化 queryValue 为对应类型;非法值返 40001)。 | |
| 98 | +4. **空 queryValue**:queryField 给了但 queryValue 为 null / 空串 → 跳过此条件,不参与 WHERE。 | |
| 99 | +5. **空 queryField + 有 queryValue**:跳过(缺 queryField 没法应用)。 | |
| 100 | +6. **越界 page**:先按入参 page/size 查;若返回 records 为空但 total > 0,service 层用 `lastPage = (total + size - 1) / size` 重新查一次并返回 lastPage 的数据。响应 `page` 字段反映**实际返回的页码**(即 lastPage),让前端能感知矫正。 | |
| 101 | +7. **排序 SQL**:在 ORDER BY 前用白名单映射列名 + asc/desc 拼接;用 MyBatis 字符串替换(`${}`)但只限白名单值(白名单已校验,安全)。 | |
| 102 | +8. **不返回密码**:UserListItemVo 不含 sPasswordHash 字段;mapper SELECT 列表显式列出业务列。 | |
| 103 | +9. **N+1 防御**:JOIN 而非多次查询;单查询返回所有字段。 | |
| 104 | +10. **空查询**:所有筛选都空时返回全表分页(admin 用例需要"全部 用户"视图)。 | |
| 105 | + | |
| 106 | +## 边界与约束 | |
| 107 | + | |
| 108 | +- **白名单兜底**:所有动态字段(queryField / matchMode / sortField / sortOrder)必须 service 层先做白名单检查,再拼到 SQL;用户输入永远不直接进 ORDER BY / SELECT。 | |
| 109 | +- **MyBatis 注入**:用 `#{}` 参数化输入;只有列名 / 排序方向用 `${}`(已白名单约束)。 | |
| 110 | +- **size 上限 100**:与 docs/04 § 3.2 一致。 | |
| 111 | +- **作废用户参与查询**:默认不过滤;用户通过 `isDeleted=false` 显式过滤启用账号。 | |
| 112 | +- **登录追踪**:本 REQ 不修改 tLastLoginDate / iFailedLoginCount / tLockUntil — 纯查询。 | |
| 113 | +- **不实现**: | |
| 114 | + - 多字段同时筛选(spec 仅允许单一 queryField,多字段筛选推迟) | |
| 115 | + - 自定义列展示(前端事) | |
| 116 | + - 导出 / 导入(YAGNI) | |
| 117 | + | |
| 118 | +## 依赖的 schema 表 / 字段 | |
| 119 | + | |
| 120 | +只读 `sys_user`(V1 已建): | |
| 121 | +- 读列:iIncrement, sUsername, sUserCode, sUserType, sLanguage, iIsDeleted, tLastLoginDate, sCreatedBy, tCreateDate, iEmployeeId | |
| 122 | +- 排序 / 筛选列:sUsername, sUserCode, sUserType, iIsDeleted, tLastLoginDate, sCreatedBy, tCreateDate | |
| 123 | + | |
| 124 | +只读 `sys_employee`(V1 已建): | |
| 125 | +- LEFT JOIN:iIncrement = u.iEmployeeId | |
| 126 | +- 读 / 筛选列:sEmployeeName, iDepartmentId | |
| 127 | + | |
| 128 | +只读 `sys_department`(V1 已建): | |
| 129 | +- LEFT JOIN:iIncrement = e.iDepartmentId | |
| 130 | +- 读 / 筛选列:sDepartmentName | |
| 131 | + | |
| 132 | +**本 REQ 不需要新增 migration**。 | |
| 133 | + | |
| 134 | +## 依赖的接口 | |
| 135 | + | |
| 136 | +- 本 REQ 提供:`GET /api/v1/users` | |
| 137 | + | |
| 138 | +## 验收标准 | |
| 139 | + | |
| 140 | +后端集成测试: | |
| 141 | + | |
| 142 | +1. **admin token + 默认参数** → 200,返回所有用户(按 tCreateDate desc);total = 实际行数;不含密码字段 | |
| 143 | +2. **page=2 size=2** → 返回第 2 页 2 条;page=2,total 不变 | |
| 144 | +3. **size > 100** → 400 / 40001 | |
| 145 | +4. **page < 1** → 400 / 40001 | |
| 146 | +5. **sortField=sUsername sortOrder=asc** → 按 username 升序返回 | |
| 147 | +6. **sortField=nonExisting** → 400 / 40003 | |
| 148 | +7. **sortOrder=foo** → 400 / 40001 | |
| 149 | +8. **queryField=username matchMode=contains queryValue=ali** → 返回含 "ali" 的用户 | |
| 150 | +9. **queryField=username matchMode=equals queryValue=alice** → 仅返回 alice | |
| 151 | +10. **queryField=username matchMode=notContains queryValue=ali** → 不含 alice 但含 admin / bob_deleted | |
| 152 | +11. **queryField=employeeName matchMode=contains queryValue=张** → 返回员工名含张的用户(JOIN) | |
| 153 | +12. **queryField=departmentName matchMode=equals queryValue=技术部** → 返回部门=技术部的用户(多级 JOIN) | |
| 154 | +13. **queryField=userType matchMode=equals queryValue=SUPER_ADMIN** → 仅 admin | |
| 155 | +14. **queryField=isDeleted matchMode=equals queryValue=true** → 仅 bob_deleted | |
| 156 | +15. **queryField=invalid** → 400 / 40003 | |
| 157 | +16. **matchMode=invalid** → 400 / 40003 | |
| 158 | +17. **queryField 提供但 queryValue 为空** → 跳过条件,返回全表(不报错) | |
| 159 | +18. **userType=NORMAL** explicit 参数 → 仅 NORMAL 用户 | |
| 160 | +19. **userType=INVALID** → 400 / 40001 | |
| 161 | +20. **isDeleted=false** explicit → 仅启用用户 | |
| 162 | +21. **复合:queryField=username queryValue=al userType=NORMAL isDeleted=false** → 仅匹配三条件的用户(alice) | |
| 163 | +22. **page 越界(page=999 size=10,total=3)** → 200,返回最后一页(page=1,1 个 records 或更少),total=3 | |
| 164 | +23. **NORMAL token** → 403 / 40301 | |
| 165 | +24. **无 token** → 401 / 40101 | |
| 166 | +25. **响应不含 sPasswordHash 字段** → JSON 没有 password 相关字段 | |
| 167 | +26. **空表(无用户)** → 200,records=[],total=0 | ... | ... |
scripts/test.sh
| ... | ... | @@ -8,6 +8,13 @@ set -euo pipefail |
| 8 | 8 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 9 | 9 | cd "$PROJECT_ROOT" |
| 10 | 10 | |
| 11 | +# 加载 .env.local,让后续 mvn / setup-test-db 子进程都能继承 DB / JWT 凭据 | |
| 12 | +if [ -f .env.local ]; then | |
| 13 | + set -a | |
| 14 | + . ./.env.local | |
| 15 | + set +a | |
| 16 | +fi | |
| 17 | + | |
| 11 | 18 | # Stack detection (runtime, mode-agnostic) |
| 12 | 19 | HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 |
| 13 | 20 | HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 |
| ... | ... | @@ -20,19 +27,19 @@ echo "[test.sh] 1/6 setup test db" |
| 20 | 27 | ./scripts/setup-test-db.sh |
| 21 | 28 | |
| 22 | 29 | echo "[test.sh] 2/6 build" |
| 23 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && ./mvnw -B -DskipTests package); else echo "[test.sh] skip backend build"; fi | |
| 24 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && pnpm install --frozen-lockfile && pnpm build); else echo "[test.sh] skip frontend build"; fi | |
| 30 | +if [ $HAS_BACKEND -eq 1 ]; then (cd backend && mvn -B -DskipTests clean package); else echo "[test.sh] skip backend build"; fi | |
| 31 | +if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && npm ci && npm run build); else echo "[test.sh] skip frontend build"; fi | |
| 25 | 32 | |
| 26 | 33 | echo "[test.sh] 3/6 lint" |
| 27 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && ./mvnw -B -q -P lint verify -DskipTests || ./mvnw -B -q checkstyle:check spotbugs:check || :); else echo "[test.sh] skip backend lint"; fi | |
| 28 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && pnpm lint); else echo "[test.sh] skip frontend lint"; fi | |
| 34 | +if [ $HAS_BACKEND -eq 1 ]; then (cd backend && mvn -B -q checkstyle:check || mvn -B -q spotless:check || echo "[test.sh] backend lint skipped (no plugin configured)"); else echo "[test.sh] skip backend lint"; fi | |
| 35 | +if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && npm run lint); else echo "[test.sh] skip frontend lint"; fi | |
| 29 | 36 | |
| 30 | 37 | echo "[test.sh] 4/6 unit + integration" |
| 31 | -if [ $HAS_BACKEND -eq 1 ]; then (cd backend && ./mvnw -B test); else echo "[test.sh] skip backend test"; fi | |
| 32 | -if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && pnpm test -- --run); else echo "[test.sh] skip frontend test"; fi | |
| 38 | +if [ $HAS_BACKEND -eq 1 ]; then (cd backend && mvn -B test); else echo "[test.sh] skip backend test"; fi | |
| 39 | +if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && npm run test -- --run); else echo "[test.sh] skip frontend test"; fi | |
| 33 | 40 | |
| 34 | 41 | echo "[test.sh] 5/6 E2E" |
| 35 | -if [ $HAS_FRONTEND -eq 1 ] && [ -f frontend/playwright.config.ts ]; then (cd frontend && pnpm exec playwright test); else echo "[test.sh] e2e 略"; fi | |
| 42 | +if [ $HAS_FRONTEND -eq 1 ]; then (cd frontend && npx playwright test); else echo "[test.sh] e2e 略 (no frontend)"; fi | |
| 36 | 43 | |
| 37 | 44 | echo "[test.sh] 6/6 reset test db" |
| 38 | 45 | ./scripts/setup-test-db.sh | ... | ... |
sql/migrations/V1__initial_schema.sql
| 1 | 1 | -- Flyway migration V1 — initial schema for 小羚羊 |
| 2 | --- Generated: 2026-05-14T01:37:50Z | |
| 2 | +-- Generated: 2026-05-14T15:46:57Z | |
| 3 | 3 | -- Source: 由 A4 db-init 从 docs/03-数据库设计文档.md 翻译生成(schema SSoT 是 docs/03) |
| 4 | 4 | -- This is the FIRST migration; subsequent schema changes must be written as new files sql/migrations/V2__<desc>.sql, V3__... etc. |
| 5 | 5 | -- Apply: Flyway runs this automatically at Spring Boot startup. |
| 6 | 6 | -- Do not hand-edit this file after it is committed; write a new migration instead. |
| 7 | 7 | |
| 8 | --- =========================================================================== | |
| 9 | --- t_user — 系统用户主表,承载登录认证与基础属性 | |
| 10 | --- =========================================================================== | |
| 11 | -CREATE TABLE `t_user` ( | |
| 12 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 13 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 14 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 15 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 16 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 17 | - `sUserNo` VARCHAR(50) NOT NULL COMMENT '用户号;关联职员后自动同步员工号;系统内唯一', | |
| 18 | - `sUserName` VARCHAR(50) NOT NULL COMMENT '登录用户名;系统内唯一;3-50 位', | |
| 19 | - `iEmployeeId` INT NULL DEFAULT NULL COMMENT '关联职员 t_employee.iIncrement;可空(非员工账号如系统管理员)', | |
| 20 | - `sPasswordHash` VARCHAR(255) NOT NULL COMMENT '密码哈希(BCrypt / Argon2);禁止明文;初始密码 666666 哈希后存入', | |
| 21 | - `sUserType` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '用户类型枚举:NORMAL(普通用户)/ SUPER_ADMIN(超级管理员)', | |
| 22 | - `sLanguage` VARCHAR(10) NOT NULL DEFAULT 'zh-CN' COMMENT '语言枚举:zh-CN(中文)/ en-US(英文)/ zh-TW(繁体)', | |
| 23 | - `bModifyDoc` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '单据修改权限:0 否 / 1 是', | |
| 24 | - `bVoid` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '作废标记(软删除):0 启用 / 1 已作废', | |
| 25 | - `iLoginFailCount` INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数;达到阈值触发临时锁定;登录成功后清零', | |
| 26 | - `tLockUntil` DATETIME NULL DEFAULT NULL COMMENT '锁定截止时间;NULL 表示未锁定', | |
| 27 | - `tLastLoginDate` DATETIME NULL DEFAULT NULL COMMENT '最近一次登录时间', | |
| 28 | - `sCreator` VARCHAR(100) NULL DEFAULT NULL COMMENT '制单人(创建该账号的操作员用户名)', | |
| 8 | +-- ============================================================================= | |
| 9 | +-- Table: sys_company — 公司 / 版本字典,登录页下拉选择来源 | |
| 10 | +-- ============================================================================= | |
| 11 | +CREATE TABLE `sys_company` ( | |
| 12 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 13 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 14 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 15 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 16 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 17 | + `sCompanyName` VARCHAR(100) NOT NULL COMMENT '公司 / 版本名称(登录页下拉显示文本)', | |
| 18 | + `sCompanyCode` VARCHAR(50) NOT NULL COMMENT '公司编码(前端唯一识别)', | |
| 19 | + `iSortOrder` INT NOT NULL DEFAULT 0 COMMENT '下拉列表排序权重,升序', | |
| 20 | + `iIsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记,0=正常 1=已删', | |
| 29 | 21 | PRIMARY KEY (`iIncrement`), |
| 30 | - UNIQUE KEY `uk_user_username` (`sUserName`), | |
| 31 | - UNIQUE KEY `uk_user_userno` (`sUserNo`), | |
| 32 | - KEY `idx_user_employee` (`iEmployeeId`), | |
| 33 | - KEY `idx_user_tenant` (`sBrandsId`, `sSubsidiaryId`), | |
| 34 | - KEY `idx_user_void` (`bVoid`) | |
| 35 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统用户主表,承载登录认证与基础属性'; | |
| 36 | - | |
| 37 | --- =========================================================================== | |
| 38 | --- t_employee — 公司职员主档 | |
| 39 | --- =========================================================================== | |
| 40 | -CREATE TABLE `t_employee` ( | |
| 41 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 42 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 43 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 44 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 45 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 46 | - `sEmployeeNo` VARCHAR(50) NOT NULL COMMENT '员工号;系统内唯一', | |
| 47 | - `sName` VARCHAR(100) NOT NULL COMMENT '姓名', | |
| 48 | - `iDepartmentId` INT NULL DEFAULT NULL COMMENT '部门 ID,关联 t_department.iIncrement', | |
| 49 | - `sPhone` VARCHAR(20) NULL DEFAULT NULL COMMENT '手机号', | |
| 50 | - `sEmail` VARCHAR(100) NULL DEFAULT NULL COMMENT '邮箱', | |
| 51 | - `bDisabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否离职:0 在职 / 1 离职', | |
| 22 | + UNIQUE KEY `uk_sys_company_code` (`sCompanyCode`), | |
| 23 | + KEY `idx_sys_company_is_deleted` (`iIsDeleted`, `iSortOrder`) | |
| 24 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司 / 版本字典,登录页下拉选择来源'; | |
| 25 | + | |
| 26 | + | |
| 27 | +-- ============================================================================= | |
| 28 | +-- Table: sys_department — 部门字典,职员归属 | |
| 29 | +-- ============================================================================= | |
| 30 | +CREATE TABLE `sys_department` ( | |
| 31 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 32 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 33 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 34 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 35 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 36 | + `sDepartmentName` VARCHAR(100) NOT NULL COMMENT '部门名称', | |
| 37 | + `sDepartmentCode` VARCHAR(50) NOT NULL COMMENT '部门编码', | |
| 38 | + `iIsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记', | |
| 52 | 39 | PRIMARY KEY (`iIncrement`), |
| 53 | - UNIQUE KEY `uk_employee_no` (`sEmployeeNo`), | |
| 54 | - KEY `idx_employee_dept` (`iDepartmentId`), | |
| 55 | - KEY `idx_employee_name` (`sName`), | |
| 56 | - KEY `idx_employee_tenant` (`sBrandsId`, `sSubsidiaryId`) | |
| 57 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司职员主档'; | |
| 58 | - | |
| 59 | --- =========================================================================== | |
| 60 | --- t_department — 部门组织树 | |
| 61 | --- =========================================================================== | |
| 62 | -CREATE TABLE `t_department` ( | |
| 63 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 64 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 65 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 66 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 67 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 68 | - `sName` VARCHAR(100) NOT NULL COMMENT '部门名称', | |
| 69 | - `sCode` VARCHAR(50) NOT NULL COMMENT '部门编码;系统内唯一', | |
| 70 | - `iParentId` INT NULL DEFAULT NULL COMMENT '上级部门 ID,NULL 表示根部门', | |
| 71 | - `iSortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序值,小者靠前', | |
| 40 | + UNIQUE KEY `uk_sys_department_code` (`sDepartmentCode`) | |
| 41 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门字典,职员归属'; | |
| 42 | + | |
| 43 | + | |
| 44 | +-- ============================================================================= | |
| 45 | +-- Table: sys_employee — 职员档案,员工基础信息 | |
| 46 | +-- ============================================================================= | |
| 47 | +CREATE TABLE `sys_employee` ( | |
| 48 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 49 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 50 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 51 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 52 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 53 | + `sEmployeeName` VARCHAR(50) NOT NULL COMMENT '员工姓名(2-50 字符)', | |
| 54 | + `sEmployeeCode` VARCHAR(50) NOT NULL COMMENT '员工工号(系统内唯一)', | |
| 55 | + `iDepartmentId` INT NOT NULL COMMENT '所属部门 ID(FK → sys_department.iIncrement)', | |
| 56 | + `sPhone` VARCHAR(20) NULL DEFAULT NULL COMMENT '手机号', | |
| 57 | + `sEmail` VARCHAR(100) NULL DEFAULT NULL COMMENT '邮箱', | |
| 58 | + `iIsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记', | |
| 72 | 59 | PRIMARY KEY (`iIncrement`), |
| 73 | - UNIQUE KEY `uk_department_code` (`sCode`), | |
| 74 | - KEY `idx_department_parent` (`iParentId`), | |
| 75 | - KEY `idx_department_tenant` (`sBrandsId`, `sSubsidiaryId`) | |
| 76 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门组织树'; | |
| 77 | - | |
| 78 | --- =========================================================================== | |
| 79 | --- t_permission — 权限分类字典 | |
| 80 | --- =========================================================================== | |
| 81 | -CREATE TABLE `t_permission` ( | |
| 82 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 83 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 84 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 85 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 86 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 87 | - `sCode` VARCHAR(50) NOT NULL COMMENT '权限码,例如 USR:ADD / USR:EDIT;系统内唯一', | |
| 88 | - `sName` VARCHAR(100) NOT NULL COMMENT '权限分类名称(展示用)', | |
| 89 | - `iSortOrder` INT NOT NULL DEFAULT 0 COMMENT '同分类内排序', | |
| 60 | + UNIQUE KEY `uk_sys_employee_code` (`sEmployeeCode`), | |
| 61 | + KEY `idx_sys_employee_department` (`iDepartmentId`), | |
| 62 | + KEY `idx_sys_employee_name` (`sEmployeeName`) | |
| 63 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职员档案,员工基础信息'; | |
| 64 | + | |
| 65 | + | |
| 66 | +-- ============================================================================= | |
| 67 | +-- Table: sys_user — 用户账号(登录认证 + 类型 + 语言 + 状态 + 登录追踪) | |
| 68 | +-- ============================================================================= | |
| 69 | +CREATE TABLE `sys_user` ( | |
| 70 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 71 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 72 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 73 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 74 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 75 | + `sUsername` VARCHAR(50) NOT NULL COMMENT '用户名(登录凭据,系统内全局唯一,3-20 位字母数字下划线)', | |
| 76 | + `sUserCode` VARCHAR(50) NOT NULL COMMENT '用户号(业务展示用编码,系统内唯一)', | |
| 77 | + `sPasswordHash` VARCHAR(255) NOT NULL COMMENT '密码哈希(BCrypt / Argon2,禁明文)', | |
| 78 | + `iEmployeeId` INT NULL DEFAULT NULL COMMENT '关联职员 ID(可选;FK → sys_employee.iIncrement)', | |
| 79 | + `sUserType` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '用户类型枚举:NORMAL=普通用户 / SUPER_ADMIN=超级管理员', | |
| 80 | + `sLanguage` VARCHAR(10) NOT NULL DEFAULT 'zh-CN' COMMENT '语言:zh-CN=中文 / en-US=英文 / zh-TW=繁体', | |
| 81 | + `iCanEditDocument` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '单据修改权限:0=否 1=是', | |
| 82 | + `iIsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否作废:0=启用 1=作废(停用)', | |
| 83 | + `iFailedLoginCount` INT NOT NULL DEFAULT 0 COMMENT '累计登录失败次数,达阈值锁定,登录成功清零', | |
| 84 | + `tLockUntil` DATETIME NULL DEFAULT NULL COMMENT '锁定截止时间,NULL=未锁定,过期自动解锁', | |
| 85 | + `tLastLoginDate` DATETIME NULL DEFAULT NULL COMMENT '最后一次成功登录时间,REQ-USR-004 登录日期来源', | |
| 86 | + `sCreatedBy` VARCHAR(50) NULL DEFAULT NULL COMMENT '制单人(创建该用户的用户名),REQ-USR-002 制单人', | |
| 87 | + `sUpdatedBy` VARCHAR(50) NULL DEFAULT NULL COMMENT '最后修改人用户名', | |
| 88 | + `tUpdatedDate` DATETIME NULL DEFAULT NULL COMMENT '最后修改时间', | |
| 90 | 89 | PRIMARY KEY (`iIncrement`), |
| 91 | - UNIQUE KEY `uk_permission_code` (`sCode`) | |
| 92 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限分类字典'; | |
| 90 | + UNIQUE KEY `uk_sys_user_username` (`sUsername`), | |
| 91 | + UNIQUE KEY `uk_sys_user_code` (`sUserCode`), | |
| 92 | + KEY `idx_sys_user_employee` (`iEmployeeId`), | |
| 93 | + KEY `idx_sys_user_type` (`sUserType`), | |
| 94 | + KEY `idx_sys_user_is_deleted` (`iIsDeleted`), | |
| 95 | + KEY `idx_sys_user_created_by` (`sCreatedBy`) | |
| 96 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户账号(登录认证 + 类型 + 语言 + 状态 + 登录追踪)'; | |
| 97 | + | |
| 93 | 98 | |
| 94 | --- =========================================================================== | |
| 95 | --- t_user_permission — 用户-权限分类关联表 | |
| 96 | --- =========================================================================== | |
| 97 | -CREATE TABLE `t_user_permission` ( | |
| 98 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 99 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 100 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 101 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 102 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 103 | - `iUserId` INT NOT NULL COMMENT '用户 ID,关联 t_user.iIncrement', | |
| 104 | - `iPermissionId` INT NOT NULL COMMENT '权限分类 ID,关联 t_permission.iIncrement', | |
| 99 | +-- ============================================================================= | |
| 100 | +-- Table: sys_permission_category — 权限分类字典 | |
| 101 | +-- ============================================================================= | |
| 102 | +CREATE TABLE `sys_permission_category` ( | |
| 103 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 104 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 105 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 106 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 107 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 108 | + `sCategoryName` VARCHAR(100) NOT NULL COMMENT '权限分类名称(如 采购管理 / 销售管理)', | |
| 109 | + `sCategoryCode` VARCHAR(50) NOT NULL COMMENT '权限分类编码(系统内唯一,代码层引用)', | |
| 110 | + `sCategoryDesc` VARCHAR(255) NULL DEFAULT NULL COMMENT '分类说明', | |
| 111 | + `iSortOrder` INT NOT NULL DEFAULT 0 COMMENT '列表展示顺序', | |
| 112 | + `iIsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除标记', | |
| 105 | 113 | PRIMARY KEY (`iIncrement`), |
| 106 | - UNIQUE KEY `uk_user_perm` (`iUserId`, `iPermissionId`), | |
| 107 | - KEY `idx_user_perm_perm` (`iPermissionId`) | |
| 108 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户-权限分类关联表'; | |
| 109 | - | |
| 110 | --- =========================================================================== | |
| 111 | --- t_company — 公司 / 版本字典 | |
| 112 | --- =========================================================================== | |
| 113 | -CREATE TABLE `t_company` ( | |
| 114 | - `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 115 | - `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 116 | - `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '品牌 ID(多租户隔离,标准列)', | |
| 117 | - `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 118 | - `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 119 | - `sCode` VARCHAR(50) NOT NULL COMMENT '公司 / 版本编码;系统内唯一', | |
| 120 | - `sName` VARCHAR(100) NOT NULL COMMENT '显示名称', | |
| 114 | + UNIQUE KEY `uk_sys_permission_category_code` (`sCategoryCode`), | |
| 115 | + KEY `idx_sys_permission_category_sort` (`iIsDeleted`, `iSortOrder`) | |
| 116 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限分类字典'; | |
| 117 | + | |
| 118 | + | |
| 119 | +-- ============================================================================= | |
| 120 | +-- Table: sys_user_permission_category — 用户 × 权限分类授权关系 | |
| 121 | +-- ============================================================================= | |
| 122 | +CREATE TABLE `sys_user_permission_category` ( | |
| 123 | + `iIncrement` INT NOT NULL AUTO_INCREMENT COMMENT '整数主键 ID(标准列)', | |
| 124 | + `sId` VARCHAR(100) NULL DEFAULT (UUID()) COMMENT '业务 ID(标准列)', | |
| 125 | + `sBrandsId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '母公司 ID(多租户隔离,标准列)', | |
| 126 | + `sSubsidiaryId` VARCHAR(100) NULL DEFAULT '1111111111' COMMENT '子公司 ID(组织层级隔离,标准列)', | |
| 127 | + `tCreateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(标准列)', | |
| 128 | + `iUserId` INT NOT NULL COMMENT '用户 ID(FK → sys_user.iIncrement)', | |
| 129 | + `iPermissionCategoryId` INT NOT NULL COMMENT '权限分类 ID(FK → sys_permission_category.iIncrement)', | |
| 130 | + `sGrantedBy` VARCHAR(50) NULL DEFAULT NULL COMMENT '授予人用户名', | |
| 121 | 131 | PRIMARY KEY (`iIncrement`), |
| 122 | - UNIQUE KEY `uk_company_code` (`sCode`) | |
| 123 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司 / 版本字典'; | |
| 132 | + UNIQUE KEY `uk_sys_user_permission_category` (`iUserId`, `iPermissionCategoryId`), | |
| 133 | + KEY `idx_sys_user_permission_category_category` (`iPermissionCategoryId`) | |
| 134 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 × 权限分类授权关系'; | |
| 124 | 135 | |
| 125 | --- =========================================================================== | |
| 126 | --- 外键约束(统一在最后追加,避免建表顺序依赖) | |
| 127 | --- =========================================================================== | |
| 128 | -ALTER TABLE `t_user` | |
| 129 | - ADD CONSTRAINT `fk_user_employee` FOREIGN KEY (`iEmployeeId`) REFERENCES `t_employee` (`iIncrement`) ON DELETE SET NULL ON UPDATE RESTRICT; | |
| 130 | 136 | |
| 131 | -ALTER TABLE `t_employee` | |
| 132 | - ADD CONSTRAINT `fk_employee_department` FOREIGN KEY (`iDepartmentId`) REFERENCES `t_department` (`iIncrement`) ON DELETE SET NULL ON UPDATE RESTRICT; | |
| 137 | +-- ============================================================================= | |
| 138 | +-- Foreign keys | |
| 139 | +-- ============================================================================= | |
| 140 | +ALTER TABLE `sys_employee` | |
| 141 | + ADD CONSTRAINT `fk_sys_employee_department` | |
| 142 | + FOREIGN KEY (`iDepartmentId`) REFERENCES `sys_department` (`iIncrement`) | |
| 143 | + ON DELETE RESTRICT ON UPDATE CASCADE; | |
| 133 | 144 | |
| 134 | -ALTER TABLE `t_department` | |
| 135 | - ADD CONSTRAINT `fk_department_parent` FOREIGN KEY (`iParentId`) REFERENCES `t_department` (`iIncrement`) ON DELETE RESTRICT ON UPDATE RESTRICT; | |
| 145 | +ALTER TABLE `sys_user` | |
| 146 | + ADD CONSTRAINT `fk_sys_user_employee` | |
| 147 | + FOREIGN KEY (`iEmployeeId`) REFERENCES `sys_employee` (`iIncrement`) | |
| 148 | + ON DELETE SET NULL ON UPDATE CASCADE; | |
| 136 | 149 | |
| 137 | -ALTER TABLE `t_user_permission` | |
| 138 | - ADD CONSTRAINT `fk_userperm_user` FOREIGN KEY (`iUserId`) REFERENCES `t_user` (`iIncrement`) ON DELETE CASCADE ON UPDATE RESTRICT; | |
| 150 | +ALTER TABLE `sys_user_permission_category` | |
| 151 | + ADD CONSTRAINT `fk_sys_upc_user` | |
| 152 | + FOREIGN KEY (`iUserId`) REFERENCES `sys_user` (`iIncrement`) | |
| 153 | + ON DELETE CASCADE ON UPDATE CASCADE; | |
| 139 | 154 | |
| 140 | -ALTER TABLE `t_user_permission` | |
| 141 | - ADD CONSTRAINT `fk_userperm_perm` FOREIGN KEY (`iPermissionId`) REFERENCES `t_permission` (`iIncrement`) ON DELETE RESTRICT ON UPDATE RESTRICT; | |
| 155 | +ALTER TABLE `sys_user_permission_category` | |
| 156 | + ADD CONSTRAINT `fk_sys_upc_permission_category` | |
| 157 | + FOREIGN KEY (`iPermissionCategoryId`) REFERENCES `sys_permission_category` (`iIncrement`) | |
| 158 | + ON DELETE CASCADE ON UPDATE CASCADE; | ... | ... |