Commit 98ab7454a264409d7cfba786ac53d1ff3078cf95
Merge branch 'module-module_usr'
Showing
103 changed files
with
8198 additions
and
632 deletions
Too many changes to show.
To preserve performance only 72 of 103 files are displayed.
CLAUDE.md
| ... | ... | @@ -132,7 +132,7 @@ B 阶段分两段,**全部固化到 skills**。入口:`/erp-workflow:coding- |
| 132 | 132 | |
| 133 | 133 | ### 你禁止做的 🚫 |
| 134 | 134 | |
| 135 | -1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约 | |
| 135 | +1. **主会话直接 `mysql -e` 跑业务 DDL**(只读查询 / 临时本地调试 / A4 `db-init` 首次 apply V1 验证除外)——业务 schema 必须走 `sql/migrations/V_n__*.sql`,详见下方 Schema 演化规约。**A4 例外**:`db-init` 在 A 阶段 setup-test-db 后会一次性手工 `mysql < V1__initial_schema.sql` 把 V1 灌入测试库,并校验 `SHOW TABLES` 行数 = docs/03 表数量,用于 DDL 自检;B 阶段(Spring Boot 启动后)Flyway 会重建 schema 并 apply 全部 migration(包括 V1),手工 apply 不会污染 Flyway 历史。 | |
| 136 | 136 | 2. **手动 Edit `docs/08 § 二/§ 三` 的 `MR:` / `整体 MR:` 字段**,必须要由 `mr-create` 自动回写 |
| 137 | 137 | |
| 138 | 138 | ### Schema 演化规约(Flyway migration) | ... | ... |
backend/pom.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
| 5 | + <modelVersion>4.0.0</modelVersion> | |
| 6 | + | |
| 7 | + <parent> | |
| 8 | + <groupId>org.springframework.boot</groupId> | |
| 9 | + <artifactId>spring-boot-starter-parent</artifactId> | |
| 10 | + <version>3.3.4</version> | |
| 11 | + <relativePath/> | |
| 12 | + </parent> | |
| 13 | + | |
| 14 | + <groupId>com.xly.erp</groupId> | |
| 15 | + <artifactId>xly-erp-backend</artifactId> | |
| 16 | + <version>0.0.1-SNAPSHOT</version> | |
| 17 | + <packaging>jar</packaging> | |
| 18 | + <name>xly-erp-backend</name> | |
| 19 | + <description>小羚羊 ERP 后端</description> | |
| 20 | + | |
| 21 | + <properties> | |
| 22 | + <java.version>17</java.version> | |
| 23 | + <maven.compiler.source>17</maven.compiler.source> | |
| 24 | + <maven.compiler.target>17</maven.compiler.target> | |
| 25 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
| 26 | + <mybatis-plus.version>3.5.7</mybatis-plus.version> | |
| 27 | + <jjwt.version>0.12.5</jjwt.version> | |
| 28 | + <lombok.version>1.18.40</lombok.version> | |
| 29 | + </properties> | |
| 30 | + | |
| 31 | + <dependencies> | |
| 32 | + <dependency> | |
| 33 | + <groupId>org.springframework.boot</groupId> | |
| 34 | + <artifactId>spring-boot-starter-web</artifactId> | |
| 35 | + </dependency> | |
| 36 | + <dependency> | |
| 37 | + <groupId>org.springframework.boot</groupId> | |
| 38 | + <artifactId>spring-boot-starter-validation</artifactId> | |
| 39 | + </dependency> | |
| 40 | + <dependency> | |
| 41 | + <groupId>org.springframework.security</groupId> | |
| 42 | + <artifactId>spring-security-crypto</artifactId> | |
| 43 | + </dependency> | |
| 44 | + | |
| 45 | + <dependency> | |
| 46 | + <groupId>com.baomidou</groupId> | |
| 47 | + <artifactId>mybatis-plus-spring-boot3-starter</artifactId> | |
| 48 | + <version>${mybatis-plus.version}</version> | |
| 49 | + </dependency> | |
| 50 | + | |
| 51 | + <dependency> | |
| 52 | + <groupId>com.mysql</groupId> | |
| 53 | + <artifactId>mysql-connector-j</artifactId> | |
| 54 | + <scope>runtime</scope> | |
| 55 | + </dependency> | |
| 56 | + | |
| 57 | + <dependency> | |
| 58 | + <groupId>org.flywaydb</groupId> | |
| 59 | + <artifactId>flyway-core</artifactId> | |
| 60 | + </dependency> | |
| 61 | + <dependency> | |
| 62 | + <groupId>org.flywaydb</groupId> | |
| 63 | + <artifactId>flyway-mysql</artifactId> | |
| 64 | + </dependency> | |
| 65 | + | |
| 66 | + <dependency> | |
| 67 | + <groupId>io.jsonwebtoken</groupId> | |
| 68 | + <artifactId>jjwt-api</artifactId> | |
| 69 | + <version>${jjwt.version}</version> | |
| 70 | + </dependency> | |
| 71 | + <dependency> | |
| 72 | + <groupId>io.jsonwebtoken</groupId> | |
| 73 | + <artifactId>jjwt-impl</artifactId> | |
| 74 | + <version>${jjwt.version}</version> | |
| 75 | + <scope>runtime</scope> | |
| 76 | + </dependency> | |
| 77 | + <dependency> | |
| 78 | + <groupId>io.jsonwebtoken</groupId> | |
| 79 | + <artifactId>jjwt-jackson</artifactId> | |
| 80 | + <version>${jjwt.version}</version> | |
| 81 | + <scope>runtime</scope> | |
| 82 | + </dependency> | |
| 83 | + | |
| 84 | + <dependency> | |
| 85 | + <groupId>org.projectlombok</groupId> | |
| 86 | + <artifactId>lombok</artifactId> | |
| 87 | + <version>${lombok.version}</version> | |
| 88 | + <optional>true</optional> | |
| 89 | + </dependency> | |
| 90 | + | |
| 91 | + <dependency> | |
| 92 | + <groupId>org.springframework.boot</groupId> | |
| 93 | + <artifactId>spring-boot-starter-test</artifactId> | |
| 94 | + <scope>test</scope> | |
| 95 | + </dependency> | |
| 96 | + </dependencies> | |
| 97 | + | |
| 98 | + <build> | |
| 99 | + <plugins> | |
| 100 | + <plugin> | |
| 101 | + <groupId>org.apache.maven.plugins</groupId> | |
| 102 | + <artifactId>maven-compiler-plugin</artifactId> | |
| 103 | + <configuration> | |
| 104 | + <annotationProcessorPaths> | |
| 105 | + <path> | |
| 106 | + <groupId>org.projectlombok</groupId> | |
| 107 | + <artifactId>lombok</artifactId> | |
| 108 | + <version>${lombok.version}</version> | |
| 109 | + </path> | |
| 110 | + </annotationProcessorPaths> | |
| 111 | + </configuration> | |
| 112 | + </plugin> | |
| 113 | + <plugin> | |
| 114 | + <groupId>org.springframework.boot</groupId> | |
| 115 | + <artifactId>spring-boot-maven-plugin</artifactId> | |
| 116 | + <configuration> | |
| 117 | + <excludes> | |
| 118 | + <exclude> | |
| 119 | + <groupId>org.projectlombok</groupId> | |
| 120 | + <artifactId>lombok</artifactId> | |
| 121 | + </exclude> | |
| 122 | + </excludes> | |
| 123 | + </configuration> | |
| 124 | + </plugin> | |
| 125 | + <plugin> | |
| 126 | + <groupId>org.apache.maven.plugins</groupId> | |
| 127 | + <artifactId>maven-surefire-plugin</artifactId> | |
| 128 | + <configuration> | |
| 129 | + <environmentVariables> | |
| 130 | + <DB_HOST>${env.DB_HOST}</DB_HOST> | |
| 131 | + <DB_PORT>${env.DB_PORT}</DB_PORT> | |
| 132 | + <DB_USER>${env.DB_USER}</DB_USER> | |
| 133 | + <DB_PASSWORD>${env.DB_PASSWORD}</DB_PASSWORD> | |
| 134 | + <DB_SCHEMA>${env.DB_SCHEMA}</DB_SCHEMA> | |
| 135 | + <JWT_SECRET>${env.JWT_SECRET}</JWT_SECRET> | |
| 136 | + </environmentVariables> | |
| 137 | + </configuration> | |
| 138 | + </plugin> | |
| 139 | + </plugins> | |
| 140 | + </build> | |
| 141 | +</project> | ... | ... |
backend/src/main/java/com/xly/erp/Application.java
0 → 100644
| 1 | +package com.xly.erp; | |
| 2 | + | |
| 3 | +import org.mybatis.spring.annotation.MapperScan; | |
| 4 | +import org.springframework.boot.SpringApplication; | |
| 5 | +import org.springframework.boot.autoconfigure.SpringBootApplication; | |
| 6 | + | |
| 7 | +@SpringBootApplication | |
| 8 | +@MapperScan("com.xly.erp.module.*.mapper") | |
| 9 | +public class Application { | |
| 10 | + public static void main(String[] args) { | |
| 11 | + SpringApplication.run(Application.class, args); | |
| 12 | + } | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import org.springframework.context.annotation.Bean; | |
| 4 | +import org.springframework.context.annotation.Configuration; | |
| 5 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * BCrypt 密码编码器 Bean。strength=10(Spring Security 默认)。 | |
| 9 | + * docs/03 sys_user.sPasswordHash + docs/04 § 1.6。 | |
| 10 | + */ | |
| 11 | +@Configuration | |
| 12 | +public class PasswordEncoderConfig { | |
| 13 | + | |
| 14 | + @Bean | |
| 15 | + public BCryptPasswordEncoder passwordEncoder() { | |
| 16 | + return new BCryptPasswordEncoder(10); | |
| 17 | + } | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.security.JwtHandlerInterceptor; | |
| 4 | +import lombok.RequiredArgsConstructor; | |
| 5 | +import org.springframework.context.annotation.Configuration; | |
| 6 | +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | |
| 7 | +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | |
| 8 | + | |
| 9 | +@Configuration | |
| 10 | +@RequiredArgsConstructor | |
| 11 | +public class WebMvcConfig implements WebMvcConfigurer { | |
| 12 | + | |
| 13 | + private final JwtHandlerInterceptor jwtInterceptor; | |
| 14 | + | |
| 15 | + @Override | |
| 16 | + public void addInterceptors(InterceptorRegistry registry) { | |
| 17 | + registry.addInterceptor(jwtInterceptor) | |
| 18 | + .addPathPatterns("/api/v1/**") | |
| 19 | + .excludePathPatterns("/api/v1/auth/login"); | |
| 20 | + } | |
| 21 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/exception/BizException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import lombok.Getter; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 业务异常 — 由 service 层抛出,由 GlobalExceptionHandler 统一转 Result.fail。 | |
| 7 | + * docs/04 § 1.4。 | |
| 8 | + */ | |
| 9 | +@Getter | |
| 10 | +public class BizException extends RuntimeException { | |
| 11 | + private final int code; | |
| 12 | + /** 可选附带的响应数据(例如 42301 锁定返 lockUntil)。null 表示无 data 字段。 */ | |
| 13 | + private final Object data; | |
| 14 | + | |
| 15 | + public BizException(int code, String message) { | |
| 16 | + this(code, message, (Object) null); | |
| 17 | + } | |
| 18 | + | |
| 19 | + public BizException(int code, String message, Object data) { | |
| 20 | + super(message); | |
| 21 | + this.code = code; | |
| 22 | + this.data = data; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public BizException(int code, String message, Throwable cause) { | |
| 26 | + super(message, cause); | |
| 27 | + this.code = code; | |
| 28 | + this.data = null; | |
| 29 | + } | |
| 30 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ErrorCode; | |
| 4 | +import com.xly.erp.common.response.Result; | |
| 5 | +import jakarta.validation.ConstraintViolationException; | |
| 6 | +import lombok.extern.slf4j.Slf4j; | |
| 7 | +import org.springframework.http.ResponseEntity; | |
| 8 | +import org.springframework.http.converter.HttpMessageNotReadableException; | |
| 9 | +import org.springframework.web.bind.MethodArgumentNotValidException; | |
| 10 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 11 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 全局异常处理器。 | |
| 15 | + * 把 BizException / 参数校验异常 / 兜底异常转 Result.fail 统一响应。 | |
| 16 | + * docs/04 § 1.4。 | |
| 17 | + */ | |
| 18 | +@RestControllerAdvice | |
| 19 | +@Slf4j | |
| 20 | +public class GlobalExceptionHandler { | |
| 21 | + | |
| 22 | + @ExceptionHandler(BizException.class) | |
| 23 | + public ResponseEntity<Result<Object>> handleBiz(BizException e) { | |
| 24 | + log.warn("[BizException] code={} message={} hasData={}", e.getCode(), e.getMessage(), e.getData() != null); | |
| 25 | + Result<Object> body = e.getData() != null | |
| 26 | + ? Result.fail(e.getCode(), e.getMessage(), e.getData()) | |
| 27 | + : Result.fail(e.getCode(), e.getMessage()); | |
| 28 | + return ResponseEntity | |
| 29 | + .status(ErrorCode.toHttpStatus(e.getCode())) | |
| 30 | + .body(body); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @ExceptionHandler(MethodArgumentNotValidException.class) | |
| 34 | + public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException e) { | |
| 35 | + String msg = e.getBindingResult().getFieldErrors().stream() | |
| 36 | + .findFirst() | |
| 37 | + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) | |
| 38 | + .orElse("参数校验失败"); | |
| 39 | + return ResponseEntity | |
| 40 | + .status(400) | |
| 41 | + .body(Result.fail(ErrorCode.BAD_REQUEST, msg)); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @ExceptionHandler(ConstraintViolationException.class) | |
| 45 | + public ResponseEntity<Result<Void>> handleConstraint(ConstraintViolationException e) { | |
| 46 | + return ResponseEntity | |
| 47 | + .status(400) | |
| 48 | + .body(Result.fail(ErrorCode.BAD_REQUEST, e.getMessage())); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @ExceptionHandler(HttpMessageNotReadableException.class) | |
| 52 | + public ResponseEntity<Result<Void>> handleNotReadable(HttpMessageNotReadableException e) { | |
| 53 | + log.warn("[HttpMessageNotReadable] {}", e.getMessage()); | |
| 54 | + return ResponseEntity | |
| 55 | + .status(400) | |
| 56 | + .body(Result.fail(ErrorCode.BAD_REQUEST, "请求体格式不合法或包含未知字段")); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @ExceptionHandler(Exception.class) | |
| 60 | + public ResponseEntity<Result<Void>> handleFallback(Exception e) { | |
| 61 | + log.error("[Unhandled] {}", e.getMessage(), e); | |
| 62 | + return ResponseEntity | |
| 63 | + .status(500) | |
| 64 | + .body(Result.fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误")); | |
| 65 | + } | |
| 66 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 全局错误码定义。 | |
| 5 | + * 段位约定见 docs/04 § 1.3。 | |
| 6 | + */ | |
| 7 | +public final class ErrorCode { | |
| 8 | + | |
| 9 | + private ErrorCode() {} | |
| 10 | + | |
| 11 | + public static final int OK = 200; | |
| 12 | + | |
| 13 | + public static final int BAD_REQUEST = 40001; | |
| 14 | + public static final int INVALID_ENUM_PARAM = 40003; | |
| 15 | + public static final int COMPANY_NOT_FOUND = 40004; | |
| 16 | + | |
| 17 | + public static final int BAD_CREDENTIALS = 40101; | |
| 18 | + public static final int ACCOUNT_DELETED = 40103; | |
| 19 | + | |
| 20 | + public static final int FORBIDDEN = 40301; | |
| 21 | + public static final int USER_FORBIDDEN_SELF_DEACTIVATE = 40302; | |
| 22 | + | |
| 23 | + public static final int USER_NOT_FOUND = 40401; | |
| 24 | + | |
| 25 | + public static final int ACCOUNT_LOCKED = 42301; | |
| 26 | + | |
| 27 | + public static final int CONFLICT_USERNAME = 40901; | |
| 28 | + public static final int CONFLICT_USERCODE = 40902; | |
| 29 | + | |
| 30 | + public static final int INTERNAL_ERROR = 50000; | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 业务 code → HTTP 状态码映射。 | |
| 34 | + */ | |
| 35 | + public static int toHttpStatus(int code) { | |
| 36 | + if (code == OK) return 200; | |
| 37 | + if (code == ACCOUNT_LOCKED) return 423; | |
| 38 | + int hundreds = code / 100; | |
| 39 | + if (hundreds == 400) return 400; | |
| 40 | + if (hundreds == 401) return 401; | |
| 41 | + if (hundreds == 403) return 403; | |
| 42 | + if (hundreds == 404) return 404; | |
| 43 | + if (hundreds == 409) return 409; | |
| 44 | + if (hundreds == 423) return 423; | |
| 45 | + if (hundreds == 500) return 500; | |
| 46 | + return 500; | |
| 47 | + } | |
| 48 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/PageResult.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import lombok.Builder; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 通用分页响应包装。docs/04 § 3.2。 | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +@Builder | |
| 13 | +public class PageResult<T> { | |
| 14 | + private List<T> records; | |
| 15 | + private long total; | |
| 16 | + private int page; | |
| 17 | + private int size; | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/Result.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import lombok.Getter; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 统一响应包装。 | |
| 7 | + * docs/04 § 1.3。 | |
| 8 | + */ | |
| 9 | +@Getter | |
| 10 | +public class Result<T> { | |
| 11 | + private final int code; | |
| 12 | + private final String message; | |
| 13 | + private final T data; | |
| 14 | + private final long timestamp; | |
| 15 | + | |
| 16 | + private Result(int code, String message, T data) { | |
| 17 | + this.code = code; | |
| 18 | + this.message = message; | |
| 19 | + this.data = data; | |
| 20 | + this.timestamp = System.currentTimeMillis(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + public static <T> Result<T> ok(T data) { | |
| 24 | + return new Result<>(ErrorCode.OK, "操作成功", data); | |
| 25 | + } | |
| 26 | + | |
| 27 | + public static Result<Void> ok() { | |
| 28 | + return new Result<>(ErrorCode.OK, "操作成功", null); | |
| 29 | + } | |
| 30 | + | |
| 31 | + public static <T> Result<T> fail(int code, String message) { | |
| 32 | + return new Result<>(code, message, null); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @SuppressWarnings("unchecked") | |
| 36 | + public static <T> Result<T> fail(int code, String message, T data) { | |
| 37 | + return new Result<>(code, message, data); | |
| 38 | + } | |
| 39 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 6 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 7 | +import jakarta.servlet.http.HttpServletRequest; | |
| 8 | +import jakarta.servlet.http.HttpServletResponse; | |
| 9 | +import lombok.RequiredArgsConstructor; | |
| 10 | +import lombok.extern.slf4j.Slf4j; | |
| 11 | +import org.springframework.stereotype.Component; | |
| 12 | +import org.springframework.web.method.HandlerMethod; | |
| 13 | +import org.springframework.web.servlet.HandlerInterceptor; | |
| 14 | + | |
| 15 | +import java.time.LocalDateTime; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * REQ-USR-002 鉴权与角色守卫拦截器。 | |
| 20 | + * - 解析 Authorization Bearer → 校验 user 状态 → set LoginContext | |
| 21 | + * - 若 handler 标注 @RequireSuperAdmin,强制 userType == SUPER_ADMIN | |
| 22 | + * - afterCompletion 清理 ThreadLocal | |
| 23 | + */ | |
| 24 | +@Component | |
| 25 | +@RequiredArgsConstructor | |
| 26 | +@Slf4j | |
| 27 | +public class JwtHandlerInterceptor implements HandlerInterceptor { | |
| 28 | + | |
| 29 | + private static final String BEARER_PREFIX = "Bearer "; | |
| 30 | + | |
| 31 | + private final JwtUtil jwtUtil; | |
| 32 | + private final SysUserMapper userMapper; | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { | |
| 36 | + String authHeader = request.getHeader("Authorization"); | |
| 37 | + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { | |
| 38 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "未携带 token"); | |
| 39 | + } | |
| 40 | + String token = authHeader.substring(BEARER_PREFIX.length()); | |
| 41 | + | |
| 42 | + Map<String, Object> claims = jwtUtil.parse(token); | |
| 43 | + String username = (String) claims.get("username"); | |
| 44 | + if (username == null || username.isBlank()) { | |
| 45 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 缺 username claim"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + SysUser user = userMapper.selectByUsername(username); | |
| 49 | + if (user == null) { | |
| 50 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户不存在"); | |
| 51 | + } | |
| 52 | + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { | |
| 53 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已作废"); | |
| 54 | + } | |
| 55 | + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { | |
| 56 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已锁定"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + String companyCode = (String) claims.get("companyCode"); | |
| 60 | + LoginContext.set(new LoginContext.LoginUser( | |
| 61 | + user.getIIncrement(), | |
| 62 | + user.getSUsername(), | |
| 63 | + user.getSUserType(), | |
| 64 | + companyCode)); | |
| 65 | + | |
| 66 | + if (handler instanceof HandlerMethod hm) { | |
| 67 | + if (hm.getMethodAnnotation(RequireSuperAdmin.class) != null | |
| 68 | + && !"SUPER_ADMIN".equals(user.getSUserType())) { | |
| 69 | + throw new BizException(ErrorCode.FORBIDDEN, "权限不足,仅超级管理员可调用"); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return true; | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Override | |
| 76 | + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, | |
| 77 | + Object handler, Exception ex) { | |
| 78 | + LoginContext.clear(); | |
| 79 | + } | |
| 80 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import io.jsonwebtoken.Claims; | |
| 6 | +import io.jsonwebtoken.JwtException; | |
| 7 | +import io.jsonwebtoken.Jwts; | |
| 8 | +import io.jsonwebtoken.security.Keys; | |
| 9 | +import jakarta.annotation.PostConstruct; | |
| 10 | +import org.springframework.beans.factory.annotation.Value; | |
| 11 | +import org.springframework.stereotype.Component; | |
| 12 | + | |
| 13 | +import javax.crypto.SecretKey; | |
| 14 | +import java.nio.charset.StandardCharsets; | |
| 15 | +import java.util.Date; | |
| 16 | +import java.util.HashMap; | |
| 17 | +import java.util.Map; | |
| 18 | +import java.util.UUID; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * JWT 签发与验证工具。HS256,密钥来自 ${JWT_SECRET}。 | |
| 22 | + * docs/04 § 1.6。 | |
| 23 | + */ | |
| 24 | +@Component | |
| 25 | +public class JwtUtil { | |
| 26 | + | |
| 27 | + @Value("${jwt.secret}") | |
| 28 | + private String secret; | |
| 29 | + | |
| 30 | + private SecretKey key; | |
| 31 | + | |
| 32 | + @PostConstruct | |
| 33 | + void init() { | |
| 34 | + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); | |
| 35 | + if (bytes.length < 32) { | |
| 36 | + throw new IllegalStateException( | |
| 37 | + "JWT_SECRET 长度不足 32 字节(HS256 要求),实际 " + bytes.length | |
| 38 | + + " 字节。请在 .env.local 配置至少 256 位的随机字符串。"); | |
| 39 | + } | |
| 40 | + this.key = Keys.hmacShaKeyFor(bytes); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public String issue(Map<String, Object> claims, long ttlSec) { | |
| 44 | + long now = System.currentTimeMillis(); | |
| 45 | + Map<String, Object> all = new HashMap<>(claims); | |
| 46 | + String sub = String.valueOf(all.remove("sub")); | |
| 47 | + String jti = UUID.randomUUID().toString(); | |
| 48 | + return Jwts.builder() | |
| 49 | + .subject(sub) | |
| 50 | + .claims(all) | |
| 51 | + .id(jti) | |
| 52 | + .issuedAt(new Date(now)) | |
| 53 | + .expiration(new Date(now + ttlSec * 1000L)) | |
| 54 | + .signWith(key) | |
| 55 | + .compact(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public Map<String, Object> parse(String token) { | |
| 59 | + try { | |
| 60 | + Claims claims = Jwts.parser() | |
| 61 | + .verifyWith(key) | |
| 62 | + .build() | |
| 63 | + .parseSignedClaims(token) | |
| 64 | + .getPayload(); | |
| 65 | + Map<String, Object> out = new HashMap<>(claims); | |
| 66 | + out.put("sub", claims.getSubject()); | |
| 67 | + out.put("jti", claims.getId()); | |
| 68 | + out.put("iat", claims.getIssuedAt() != null ? claims.getIssuedAt().getTime() / 1000 : null); | |
| 69 | + out.put("exp", claims.getExpiration() != null ? claims.getExpiration().getTime() / 1000 : null); | |
| 70 | + return out; | |
| 71 | + } catch (JwtException e) { | |
| 72 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 无效或已过期"); | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/LoginContext.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 请求级登录上下文 — JwtHandlerInterceptor 在 preHandle 时 set,afterCompletion 时 clear。 | |
| 5 | + * 用普通 ThreadLocal(不用 InheritableThreadLocal)避免子线程意外继承。 | |
| 6 | + */ | |
| 7 | +public final class LoginContext { | |
| 8 | + | |
| 9 | + private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); | |
| 10 | + | |
| 11 | + private LoginContext() {} | |
| 12 | + | |
| 13 | + public static void set(LoginUser user) { | |
| 14 | + HOLDER.set(user); | |
| 15 | + } | |
| 16 | + | |
| 17 | + public static LoginUser current() { | |
| 18 | + return HOLDER.get(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public static void clear() { | |
| 22 | + HOLDER.remove(); | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** 当前登录用户上下文。userType 取值 NORMAL / SUPER_ADMIN。 */ | |
| 26 | + public record LoginUser(Integer userId, String username, String userType, String companyCode) {} | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import java.lang.annotation.ElementType; | |
| 4 | +import java.lang.annotation.Retention; | |
| 5 | +import java.lang.annotation.RetentionPolicy; | |
| 6 | +import java.lang.annotation.Target; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 标注在 controller 方法上,要求当前登录用户 userType == SUPER_ADMIN。 | |
| 10 | + * 由 JwtHandlerInterceptor 在 preHandle 时校验。 | |
| 11 | + */ | |
| 12 | +@Target(ElementType.METHOD) | |
| 13 | +@Retention(RetentionPolicy.RUNTIME) | |
| 14 | +public @interface RequireSuperAdmin { | |
| 15 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.module.usr.dto.LoginReq; | |
| 5 | +import com.xly.erp.module.usr.service.LoginService; | |
| 6 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 7 | +import jakarta.validation.Valid; | |
| 8 | +import lombok.RequiredArgsConstructor; | |
| 9 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 11 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 12 | +import org.springframework.web.bind.annotation.RestController; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 认证入口。REQ-USR-001:POST /api/v1/auth/login。 | |
| 16 | + */ | |
| 17 | +@RestController | |
| 18 | +@RequestMapping("/api/v1/auth") | |
| 19 | +@RequiredArgsConstructor | |
| 20 | +public class AuthController { | |
| 21 | + | |
| 22 | + private final LoginService loginService; | |
| 23 | + | |
| 24 | + @PostMapping("/login") | |
| 25 | + public Result<LoginVo> login(@RequestBody @Valid LoginReq req) { | |
| 26 | + LoginVo vo = loginService.login(req.getUsername(), req.getPassword(), req.getCompanyCode()); | |
| 27 | + return Result.ok(vo); | |
| 28 | + } | |
| 29 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.PageResult; | |
| 4 | +import com.xly.erp.common.response.Result; | |
| 5 | +import com.xly.erp.common.security.LoginContext; | |
| 6 | +import com.xly.erp.common.security.RequireSuperAdmin; | |
| 7 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 8 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 9 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 10 | +import com.xly.erp.module.usr.service.UserCreateService; | |
| 11 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 12 | +import com.xly.erp.module.usr.service.UserListService; | |
| 13 | +import com.xly.erp.module.usr.service.UserUpdateService; | |
| 14 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 15 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 16 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 17 | +import jakarta.validation.Valid; | |
| 18 | +import lombok.RequiredArgsConstructor; | |
| 19 | +import org.springframework.http.HttpStatus; | |
| 20 | +import org.springframework.http.ResponseEntity; | |
| 21 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 22 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 23 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 24 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 25 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 26 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 27 | +import org.springframework.web.bind.annotation.RestController; | |
| 28 | + | |
| 29 | +@RestController | |
| 30 | +@RequestMapping("/api/v1/users") | |
| 31 | +@RequiredArgsConstructor | |
| 32 | +public class UserController { | |
| 33 | + | |
| 34 | + private final UserCreateService userCreateService; | |
| 35 | + private final UserDetailService userDetailService; | |
| 36 | + private final UserUpdateService userUpdateService; | |
| 37 | + private final UserListService userListService; | |
| 38 | + | |
| 39 | + @PostMapping | |
| 40 | + @RequireSuperAdmin | |
| 41 | + public ResponseEntity<Result<CreateUserVo>> create(@RequestBody @Valid CreateUserReq req) { | |
| 42 | + String operator = LoginContext.current().username(); | |
| 43 | + CreateUserVo vo = userCreateService.create(req, operator); | |
| 44 | + return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo)); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @GetMapping | |
| 48 | + @RequireSuperAdmin | |
| 49 | + public Result<PageResult<UserListItemVo>> list(@Valid UserQueryReq req) { | |
| 50 | + return Result.ok(userListService.list(req)); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @GetMapping("/{userId}") | |
| 54 | + @RequireSuperAdmin | |
| 55 | + public Result<UserDetailVo> getById(@PathVariable Integer userId) { | |
| 56 | + return Result.ok(userDetailService.getById(userId)); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @PutMapping("/{userId}") | |
| 60 | + @RequireSuperAdmin | |
| 61 | + public Result<UserDetailVo> update(@PathVariable Integer userId, | |
| 62 | + @RequestBody @Valid UpdateUserReq req) { | |
| 63 | + LoginContext.LoginUser cur = LoginContext.current(); | |
| 64 | + UserDetailVo vo = userUpdateService.update(userId, req, cur.userId(), cur.username()); | |
| 65 | + return Result.ok(vo); | |
| 66 | + } | |
| 67 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.NotNull; | |
| 5 | +import jakarta.validation.constraints.Pattern; | |
| 6 | +import jakarta.validation.constraints.Size; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +@Data | |
| 12 | +public class CreateUserReq { | |
| 13 | + | |
| 14 | + @NotBlank | |
| 15 | + @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", | |
| 16 | + message = "用户名必须为 3-20 位字母数字下划线") | |
| 17 | + private String username; | |
| 18 | + | |
| 19 | + @NotBlank | |
| 20 | + @Size(max = 50) | |
| 21 | + private String userCode; | |
| 22 | + | |
| 23 | + @NotBlank | |
| 24 | + @Pattern(regexp = "NORMAL|SUPER_ADMIN", | |
| 25 | + message = "userType 必须为 NORMAL 或 SUPER_ADMIN") | |
| 26 | + private String userType; | |
| 27 | + | |
| 28 | + @NotBlank | |
| 29 | + @Pattern(regexp = "zh-CN|en-US|zh-TW", | |
| 30 | + message = "language 必须为 zh-CN / en-US / zh-TW") | |
| 31 | + private String language; | |
| 32 | + | |
| 33 | + @NotNull | |
| 34 | + private Boolean canEditDocument; | |
| 35 | + | |
| 36 | + /** 可选;非空则必须命中 sys_employee.iIncrement 且 iIsDeleted=0 */ | |
| 37 | + private Integer employeeId; | |
| 38 | + | |
| 39 | + /** 可选;空数组 / null 都允许;非空时每个 ID 必须命中 sys_permission_category */ | |
| 40 | + private List<Integer> permissionCategoryIds; | |
| 41 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +@Data | |
| 8 | +public class LoginReq { | |
| 9 | + | |
| 10 | + @NotBlank | |
| 11 | + @Size(max = 50) | |
| 12 | + private String username; | |
| 13 | + | |
| 14 | + @NotBlank | |
| 15 | + @Size(max = 128) | |
| 16 | + private String password; | |
| 17 | + | |
| 18 | + @NotBlank | |
| 19 | + @Size(max = 50) | |
| 20 | + private String companyCode; | |
| 21 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Min; | |
| 4 | +import jakarta.validation.constraints.Pattern; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * PATCH 语义:所有字段都可选;缺省 / 显式 null 视为不变。 | |
| 12 | + * 特例:employeeId == 0 视为解除关联(DB 写 NULL)。 | |
| 13 | + */ | |
| 14 | +@Data | |
| 15 | +public class UpdateUserReq { | |
| 16 | + | |
| 17 | + @Size(max = 50) | |
| 18 | + @Pattern(regexp = "^\\S+$", message = "userCode 不可为空白") | |
| 19 | + private String userCode; | |
| 20 | + | |
| 21 | + @Pattern(regexp = "NORMAL|SUPER_ADMIN", | |
| 22 | + message = "userType 必须为 NORMAL 或 SUPER_ADMIN") | |
| 23 | + private String userType; | |
| 24 | + | |
| 25 | + @Pattern(regexp = "zh-CN|en-US|zh-TW", | |
| 26 | + message = "language 必须为 zh-CN / en-US / zh-TW") | |
| 27 | + private String language; | |
| 28 | + | |
| 29 | + private Boolean canEditDocument; | |
| 30 | + | |
| 31 | + @Min(value = 0, message = "employeeId 必须 >= 0;0 表示解除关联") | |
| 32 | + private Integer employeeId; | |
| 33 | + | |
| 34 | + private Boolean isDeleted; | |
| 35 | + | |
| 36 | + private List<Integer> permissionCategoryIds; | |
| 37 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryReq.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Max; | |
| 4 | +import jakarta.validation.constraints.Min; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 用户列表查询请求。所有字段可选;枚举值白名单由 service 层校验。 | |
| 9 | + * REQ-USR-004。 | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +public class UserQueryReq { | |
| 13 | + | |
| 14 | + @Min(value = 1, message = "page 必须 >= 1") | |
| 15 | + private Integer page; | |
| 16 | + | |
| 17 | + @Min(value = 1, message = "size 必须 >= 1") | |
| 18 | + @Max(value = 100, message = "size 不能超过 100") | |
| 19 | + private Integer size; | |
| 20 | + | |
| 21 | + private String sortField; | |
| 22 | + private String sortOrder; | |
| 23 | + private String queryField; | |
| 24 | + private String matchMode; | |
| 25 | + private String queryValue; | |
| 26 | + private String userType; | |
| 27 | + private Boolean isDeleted; | |
| 28 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 公司表实体(只需登录用到的字段)。docs/03 § sys_company。 | |
| 12 | + */ | |
| 13 | +@Data | |
| 14 | +@TableName("sys_company") | |
| 15 | +public class SysCompany { | |
| 16 | + | |
| 17 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 18 | + private Integer iIncrement; | |
| 19 | + | |
| 20 | + private String sId; | |
| 21 | + private String sBrandsId; | |
| 22 | + private String sSubsidiaryId; | |
| 23 | + private LocalDateTime tCreateDate; | |
| 24 | + | |
| 25 | + private String sCompanyName; | |
| 26 | + private String sCompanyCode; | |
| 27 | + private Integer iSortOrder; | |
| 28 | + private Integer iIsDeleted; | |
| 29 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 职员表实体(只读 join,含登录返回 employeeName 所需的最小字段)。 | |
| 12 | + * docs/03 § sys_employee。 | |
| 13 | + */ | |
| 14 | +@Data | |
| 15 | +@TableName("sys_employee") | |
| 16 | +public class SysEmployee { | |
| 17 | + | |
| 18 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 19 | + private Integer iIncrement; | |
| 20 | + | |
| 21 | + private String sId; | |
| 22 | + private String sBrandsId; | |
| 23 | + private String sSubsidiaryId; | |
| 24 | + private LocalDateTime tCreateDate; | |
| 25 | + | |
| 26 | + private String sEmployeeName; | |
| 27 | + private String sEmployeeCode; | |
| 28 | + private Integer iDepartmentId; | |
| 29 | + private String sPhone; | |
| 30 | + private String sEmail; | |
| 31 | + private Integer iIsDeleted; | |
| 32 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@Data | |
| 11 | +@TableName("sys_permission_category") | |
| 12 | +public class SysPermissionCategory { | |
| 13 | + | |
| 14 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 15 | + private Integer iIncrement; | |
| 16 | + | |
| 17 | + private String sId; | |
| 18 | + private String sBrandsId; | |
| 19 | + private String sSubsidiaryId; | |
| 20 | + private LocalDateTime tCreateDate; | |
| 21 | + | |
| 22 | + private String sCategoryName; | |
| 23 | + private String sCategoryCode; | |
| 24 | + private String sCategoryDesc; | |
| 25 | + private Integer iSortOrder; | |
| 26 | + private Integer iIsDeleted; | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 用户表实体。docs/03 § sys_user。 | |
| 12 | + */ | |
| 13 | +@Data | |
| 14 | +@TableName("sys_user") | |
| 15 | +public class SysUser { | |
| 16 | + | |
| 17 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 18 | + private Integer iIncrement; | |
| 19 | + | |
| 20 | + private String sId; | |
| 21 | + private String sBrandsId; | |
| 22 | + private String sSubsidiaryId; | |
| 23 | + private LocalDateTime tCreateDate; | |
| 24 | + | |
| 25 | + private String sUsername; | |
| 26 | + private String sUserCode; | |
| 27 | + private String sPasswordHash; | |
| 28 | + | |
| 29 | + private Integer iEmployeeId; | |
| 30 | + private String sUserType; | |
| 31 | + private String sLanguage; | |
| 32 | + private Integer iCanEditDocument; | |
| 33 | + private Integer iIsDeleted; | |
| 34 | + private Integer iFailedLoginCount; | |
| 35 | + private LocalDateTime tLockUntil; | |
| 36 | + private LocalDateTime tLastLoginDate; | |
| 37 | + private String sCreatedBy; | |
| 38 | + private String sUpdatedBy; | |
| 39 | + private LocalDateTime tUpdatedDate; | |
| 40 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.IdType; | |
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | |
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | |
| 6 | +import lombok.Data; | |
| 7 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@Data | |
| 11 | +@TableName("sys_user_permission_category") | |
| 12 | +public class SysUserPermissionCategory { | |
| 13 | + | |
| 14 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 15 | + private Integer iIncrement; | |
| 16 | + | |
| 17 | + private String sId; | |
| 18 | + private String sBrandsId; | |
| 19 | + private String sSubsidiaryId; | |
| 20 | + private LocalDateTime tCreateDate; | |
| 21 | + | |
| 22 | + private Integer iUserId; | |
| 23 | + private Integer iPermissionCategoryId; | |
| 24 | + private String sGrantedBy; | |
| 25 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | +import org.apache.ibatis.annotations.Select; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface SysCompanyMapper extends BaseMapper<SysCompany> { | |
| 10 | + | |
| 11 | + @Select("SELECT iIncrement, sCompanyCode, sCompanyName, iIsDeleted " + | |
| 12 | + "FROM sys_company WHERE sCompanyCode = #{code} LIMIT 1") | |
| 13 | + SysCompany selectByCode(String code); | |
| 14 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface SysEmployeeMapper extends BaseMapper<SysEmployee> { | |
| 9 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysPermissionCategory; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | +import org.apache.ibatis.annotations.Param; | |
| 7 | +import org.apache.ibatis.annotations.Select; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +@Mapper | |
| 12 | +public interface SysPermissionCategoryMapper extends BaseMapper<SysPermissionCategory> { | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 计算给定 ID 集合中有多少行未删除(iIsDeleted=0)。 | |
| 16 | + * 用于 REQ-USR-002 批量校验 permissionCategoryIds 是否全部存在。 | |
| 17 | + */ | |
| 18 | + @Select({ | |
| 19 | + "<script>", | |
| 20 | + "SELECT COUNT(*) FROM sys_permission_category ", | |
| 21 | + "WHERE iIsDeleted = 0 AND iIncrement IN ", | |
| 22 | + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>", | |
| 23 | + "</script>" | |
| 24 | + }) | |
| 25 | + int countActiveByIds(@Param("ids") List<Integer> ids); | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 5 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 6 | +import org.apache.ibatis.annotations.Mapper; | |
| 7 | +import org.apache.ibatis.annotations.Param; | |
| 8 | +import org.apache.ibatis.annotations.Select; | |
| 9 | +import org.apache.ibatis.annotations.Update; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +@Mapper | |
| 14 | +public interface SysUserMapper extends BaseMapper<SysUser> { | |
| 15 | + | |
| 16 | + String LOGIN_COLUMNS = "iIncrement, sUsername, sUserCode, sPasswordHash, iEmployeeId, " + | |
| 17 | + "sUserType, sLanguage, iCanEditDocument, iIsDeleted, iFailedLoginCount, " + | |
| 18 | + "tLockUntil, tLastLoginDate"; | |
| 19 | + | |
| 20 | + @Select("SELECT " + LOGIN_COLUMNS + " FROM sys_user WHERE sUsername = #{username} LIMIT 1") | |
| 21 | + SysUser selectByUsername(String username); | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 原子累加失败登录次数;达到阈值 maxCount 时同步写 tLockUntil = NOW() + lockMinutes 分钟。 | |
| 25 | + * 单 SQL,DB 层保证并发安全。返回受影响行数(应为 1)。 | |
| 26 | + * MySQL 按 SET 子句从左到右求值,所以放在 +1 之后的引用看到的是新值。 | |
| 27 | + */ | |
| 28 | + @Update("UPDATE sys_user " + | |
| 29 | + "SET iFailedLoginCount = iFailedLoginCount + 1, " + | |
| 30 | + " tLockUntil = IF(iFailedLoginCount >= #{maxCount}, " + | |
| 31 | + " DATE_ADD(NOW(), INTERVAL #{lockMinutes} MINUTE), " + | |
| 32 | + " tLockUntil) " + | |
| 33 | + "WHERE iIncrement = #{userId}") | |
| 34 | + int incrementFailedLoginCountAtomic(@Param("userId") Integer userId, | |
| 35 | + @Param("maxCount") int maxCount, | |
| 36 | + @Param("lockMinutes") long lockMinutes); | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 成功登录写入:清零计数 + 清空锁定 + 更新登录时间。一次 UPDATE。 | |
| 40 | + */ | |
| 41 | + @Update("UPDATE sys_user " + | |
| 42 | + "SET iFailedLoginCount = 0, tLockUntil = NULL, tLastLoginDate = NOW() " + | |
| 43 | + "WHERE iIncrement = #{userId}") | |
| 44 | + int markLoginSuccess(@Param("userId") Integer userId); | |
| 45 | + | |
| 46 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUsername = #{username})") | |
| 47 | + boolean existsByUsername(@Param("username") String username); | |
| 48 | + | |
| 49 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode})") | |
| 50 | + boolean existsByUserCode(@Param("userCode") String userCode); | |
| 51 | + | |
| 52 | + @Select("SELECT EXISTS(SELECT 1 FROM sys_user " + | |
| 53 | + "WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})") | |
| 54 | + boolean existsByUserCodeExcludingId(@Param("userCode") String userCode, | |
| 55 | + @Param("excludedUserId") Integer excludedUserId); | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * REQ-USR-004 动态查询。SQL 在 SysUserMapper.xml 定义。 | |
| 59 | + * QueryParams 必须已通过 service 层白名单校验。 | |
| 60 | + */ | |
| 61 | + List<UserListItemVo> selectByQuery(@Param("p") UserQueryParams p); | |
| 62 | + | |
| 63 | + long countByQuery(@Param("p") UserQueryParams p); | |
| 64 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 5 | +import org.apache.ibatis.annotations.Delete; | |
| 6 | +import org.apache.ibatis.annotations.Mapper; | |
| 7 | +import org.apache.ibatis.annotations.Param; | |
| 8 | +import org.apache.ibatis.annotations.Select; | |
| 9 | + | |
| 10 | +import java.util.List; | |
| 11 | + | |
| 12 | +@Mapper | |
| 13 | +public interface SysUserPermissionCategoryMapper extends BaseMapper<SysUserPermissionCategory> { | |
| 14 | + | |
| 15 | + @Select("SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}") | |
| 16 | + List<Integer> selectPermissionCategoryIdsByUserId(@Param("userId") Integer userId); | |
| 17 | + | |
| 18 | + @Delete({ | |
| 19 | + "<script>", | |
| 20 | + "DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN ", | |
| 21 | + "<foreach item='id' collection='ids' open='(' separator=',' close=')'>#{id}</foreach>", | |
| 22 | + "</script>" | |
| 23 | + }) | |
| 24 | + int deleteByUserAndCategoryIds(@Param("userId") Integer userId, | |
| 25 | + @Param("ids") List<Integer> categoryIds); | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/UserQueryParams.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * SysUserMapper.selectByQuery / countByQuery 入参(service 层规范化白名单后填入)。 | |
| 5 | + * sqlSortField / sqlSortOrder / sqlQueryColumn 必须已通过白名单校验; | |
| 6 | + * mapper XML 直接用 ${} 拼接到 SQL。 | |
| 7 | + */ | |
| 8 | +public class UserQueryParams { | |
| 9 | + public String sqlSortField; | |
| 10 | + public String sqlSortOrder; | |
| 11 | + public String sqlQueryColumn; // null 表示无 queryField 条件 | |
| 12 | + public String matchMode; // contains / notContains / equals | |
| 13 | + public String queryValue; // null/"" 表示跳过该条件 | |
| 14 | + public String userType; // null 表示不过滤 | |
| 15 | + public Integer isDeleted; // null 不过滤;0 / 1 过滤 | |
| 16 | + public Integer offset; | |
| 17 | + public Integer limit; | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 4 | + | |
| 5 | +public interface LoginService { | |
| 6 | + /** | |
| 7 | + * 校验用户名 + 密码 + 公司编码并签发 access token。 | |
| 8 | + * REQ-USR-001。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException | |
| 11 | + * 40004 公司不存在 / 40101 凭据错误 / 40103 账号作废 / 42301 账号锁定 | |
| 12 | + */ | |
| 13 | + LoginVo login(String username, String password, String companyCode); | |
| 14 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 4 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 5 | + | |
| 6 | +public interface UserCreateService { | |
| 7 | + /** | |
| 8 | + * 新建用户 + 权限分类授权。 | |
| 9 | + * REQ-USR-002。 | |
| 10 | + * | |
| 11 | + * @param req 已通过 jakarta 校验的请求体 | |
| 12 | + * @param operatorUsername 当前登录用户(写入 sCreatedBy / sGrantedBy) | |
| 13 | + * @throws com.xly.erp.common.exception.BizException | |
| 14 | + * 40004 employee / permissionCategory 不存在 / 40901 用户名重复 / 40902 用户号重复 | |
| 15 | + */ | |
| 16 | + CreateUserVo create(CreateUserReq req, String operatorUsername); | |
| 17 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 4 | + | |
| 5 | +public interface UserDetailService { | |
| 6 | + /** | |
| 7 | + * REQ-USR-003 GET /api/v1/users/{userId} 详情。 | |
| 8 | + * 包含作废用户(不过滤 iIsDeleted)。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException 40401 用户不存在 | |
| 11 | + */ | |
| 12 | + UserDetailVo getById(Integer userId); | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.PageResult; | |
| 4 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 5 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 6 | + | |
| 7 | +public interface UserListService { | |
| 8 | + /** | |
| 9 | + * REQ-USR-004 GET /api/v1/users — 分页 + 多字段筛选 + 排序。 | |
| 10 | + * 白名单校验、越界矫正均由实现层完成。 | |
| 11 | + */ | |
| 12 | + PageResult<UserListItemVo> list(UserQueryReq req); | |
| 13 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 4 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 5 | + | |
| 6 | +public interface UserUpdateService { | |
| 7 | + /** | |
| 8 | + * REQ-USR-003 PUT /api/v1/users/{userId}:部分字段更新 + 权限分类增量差集。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException | |
| 11 | + * 40004 employee/permissionCategory 不存在 / 40302 自我停用 / 40401 用户不存在 / 40902 用户号冲突 | |
| 12 | + */ | |
| 13 | + UserDetailVo update(Integer userId, UpdateUserReq req, | |
| 14 | + Integer operatorUserId, String operatorUsername); | |
| 15 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.security.JwtUtil; | |
| 6 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 7 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysCompanyMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 12 | +import com.xly.erp.module.usr.service.LoginService; | |
| 13 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 14 | +import com.xly.erp.module.usr.vo.UserInfoVo; | |
| 15 | +import lombok.RequiredArgsConstructor; | |
| 16 | +import lombok.extern.slf4j.Slf4j; | |
| 17 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 18 | +import org.springframework.stereotype.Service; | |
| 19 | + | |
| 20 | +import java.time.LocalDateTime; | |
| 21 | +import java.time.format.DateTimeFormatter; | |
| 22 | +import java.util.HashMap; | |
| 23 | +import java.util.Map; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +@RequiredArgsConstructor | |
| 27 | +@Slf4j | |
| 28 | +public class LoginServiceImpl implements LoginService { | |
| 29 | + | |
| 30 | + static final int MAX_FAILED_LOGIN_COUNT = 5; | |
| 31 | + static final long LOCK_DURATION_MINUTES = 30L; | |
| 32 | + static final long TOKEN_TTL_SEC = 7200L; | |
| 33 | + | |
| 34 | + private final SysUserMapper userMapper; | |
| 35 | + private final SysCompanyMapper companyMapper; | |
| 36 | + private final SysEmployeeMapper employeeMapper; | |
| 37 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 38 | + private final JwtUtil jwtUtil; | |
| 39 | + | |
| 40 | + @Override | |
| 41 | + public LoginVo login(String username, String password, String companyCode) { | |
| 42 | + // 1. 公司校验(只读,不需事务) | |
| 43 | + SysCompany company = companyMapper.selectByCode(companyCode); | |
| 44 | + if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) { | |
| 45 | + log.warn("[login] companyCode={} 不存在或已删除", companyCode); | |
| 46 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 2. 用户查找 | |
| 50 | + SysUser user = userMapper.selectByUsername(username); | |
| 51 | + if (user == null) { | |
| 52 | + log.warn("[login] username={} 不存在(统一返 40101)", username); | |
| 53 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 3. 作废校验(不计入失败次数) | |
| 57 | + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { | |
| 58 | + log.warn("[login] username={} 已作废", username); | |
| 59 | + throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录"); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 4. 锁定校验(不计入失败次数;过期锁定视为已解锁) | |
| 63 | + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { | |
| 64 | + log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil()); | |
| 65 | + Map<String, Object> data = new HashMap<>(); | |
| 66 | + data.put("lockUntil", user.getTLockUntil() | |
| 67 | + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); | |
| 68 | + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试", data); | |
| 69 | + } | |
| 70 | + | |
| 71 | + // 5. 密码校验 | |
| 72 | + if (!passwordEncoder.matches(password, user.getSPasswordHash())) { | |
| 73 | + int rows = userMapper.incrementFailedLoginCountAtomic( | |
| 74 | + user.getIIncrement(), MAX_FAILED_LOGIN_COUNT, LOCK_DURATION_MINUTES); | |
| 75 | + log.warn("[login] username={} 密码错误,原子累加失败次数 rows={}", username, rows); | |
| 76 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 77 | + } | |
| 78 | + | |
| 79 | + // 6. 成功路径 | |
| 80 | + return loginSuccess(user, companyCode); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private LoginVo loginSuccess(SysUser user, String companyCode) { | |
| 84 | + userMapper.markLoginSuccess(user.getIIncrement()); | |
| 85 | + | |
| 86 | + String employeeName = null; | |
| 87 | + if (user.getIEmployeeId() != null) { | |
| 88 | + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); | |
| 89 | + if (emp != null) { | |
| 90 | + employeeName = emp.getSEmployeeName(); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + Map<String, Object> claims = new HashMap<>(); | |
| 95 | + claims.put("sub", user.getIIncrement()); | |
| 96 | + claims.put("username", user.getSUsername()); | |
| 97 | + claims.put("userType", user.getSUserType()); | |
| 98 | + claims.put("companyCode", companyCode); | |
| 99 | + claims.put("language", user.getSLanguage()); | |
| 100 | + | |
| 101 | + String token = jwtUtil.issue(claims, TOKEN_TTL_SEC); | |
| 102 | + | |
| 103 | + log.info("[login] username={} 登录成功", user.getSUsername()); | |
| 104 | + | |
| 105 | + return LoginVo.builder() | |
| 106 | + .accessToken(token) | |
| 107 | + .tokenType("Bearer") | |
| 108 | + .expiresInSec(TOKEN_TTL_SEC) | |
| 109 | + .userInfo(UserInfoVo.builder() | |
| 110 | + .userId(user.getIIncrement()) | |
| 111 | + .username(user.getSUsername()) | |
| 112 | + .userType(user.getSUserType()) | |
| 113 | + .language(user.getSLanguage()) | |
| 114 | + .companyCode(companyCode) | |
| 115 | + .employeeName(employeeName) | |
| 116 | + .build()) | |
| 117 | + .build(); | |
| 118 | + } | |
| 119 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.dto.CreateUserReq; | |
| 6 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 7 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 12 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 13 | +import com.xly.erp.module.usr.service.UserCreateService; | |
| 14 | +import com.xly.erp.module.usr.vo.CreateUserVo; | |
| 15 | +import lombok.RequiredArgsConstructor; | |
| 16 | +import lombok.extern.slf4j.Slf4j; | |
| 17 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 18 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 19 | +import org.springframework.stereotype.Service; | |
| 20 | +import org.springframework.transaction.annotation.Transactional; | |
| 21 | + | |
| 22 | +import java.util.List; | |
| 23 | + | |
| 24 | +@Service | |
| 25 | +@RequiredArgsConstructor | |
| 26 | +@Slf4j | |
| 27 | +public class UserCreateServiceImpl implements UserCreateService { | |
| 28 | + | |
| 29 | + static final String INITIAL_PASSWORD = "666666"; | |
| 30 | + | |
| 31 | + private final SysUserMapper userMapper; | |
| 32 | + private final SysEmployeeMapper employeeMapper; | |
| 33 | + private final SysPermissionCategoryMapper permissionCategoryMapper; | |
| 34 | + private final SysUserPermissionCategoryMapper userPermissionCategoryMapper; | |
| 35 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 36 | + | |
| 37 | + @Override | |
| 38 | + @Transactional | |
| 39 | + public CreateUserVo create(CreateUserReq req, String operatorUsername) { | |
| 40 | + // 1. 唯一性预检(返友好错误码;DB 唯一索引兜底并发场景) | |
| 41 | + if (userMapper.existsByUsername(req.getUsername())) { | |
| 42 | + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在"); | |
| 43 | + } | |
| 44 | + if (userMapper.existsByUserCode(req.getUserCode())) { | |
| 45 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 2. employee 外键校验 | |
| 49 | + if (req.getEmployeeId() != null) { | |
| 50 | + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId()); | |
| 51 | + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) { | |
| 52 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除"); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 3. permissionCategory 外键校验(批量) | |
| 57 | + List<Integer> pcIds = req.getPermissionCategoryIds(); | |
| 58 | + if (pcIds != null && !pcIds.isEmpty()) { | |
| 59 | + int active = permissionCategoryMapper.countActiveByIds(pcIds); | |
| 60 | + if (active != pcIds.size()) { | |
| 61 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, | |
| 62 | + "指定的权限分类含不存在或已删除项"); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + // 4. 写入 sys_user | |
| 67 | + SysUser user = new SysUser(); | |
| 68 | + user.setSUsername(req.getUsername()); | |
| 69 | + user.setSUserCode(req.getUserCode()); | |
| 70 | + user.setSPasswordHash(passwordEncoder.encode(INITIAL_PASSWORD)); | |
| 71 | + user.setIEmployeeId(req.getEmployeeId()); | |
| 72 | + user.setSUserType(req.getUserType()); | |
| 73 | + user.setSLanguage(req.getLanguage()); | |
| 74 | + user.setICanEditDocument(Boolean.TRUE.equals(req.getCanEditDocument()) ? 1 : 0); | |
| 75 | + user.setIIsDeleted(0); | |
| 76 | + user.setIFailedLoginCount(0); | |
| 77 | + user.setSCreatedBy(operatorUsername); | |
| 78 | + try { | |
| 79 | + userMapper.insert(user); | |
| 80 | + } catch (DataIntegrityViolationException e) { | |
| 81 | + String msg = e.getMessage() == null ? "" : e.getMessage(); | |
| 82 | + if (msg.contains("uk_sys_user_username")) { | |
| 83 | + throw new BizException(ErrorCode.CONFLICT_USERNAME, "用户名已存在"); | |
| 84 | + } | |
| 85 | + if (msg.contains("uk_sys_user_code")) { | |
| 86 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已存在"); | |
| 87 | + } | |
| 88 | + throw e; | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 5. 写入 sys_user_permission_category(如有) | |
| 92 | + if (pcIds != null && !pcIds.isEmpty()) { | |
| 93 | + for (Integer pcId : pcIds) { | |
| 94 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 95 | + link.setIUserId(user.getIIncrement()); | |
| 96 | + link.setIPermissionCategoryId(pcId); | |
| 97 | + link.setSGrantedBy(operatorUsername); | |
| 98 | + userPermissionCategoryMapper.insert(link); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + log.info("[user-create] username={} userCode={} byOperator={} permissionCount={}", | |
| 103 | + user.getSUsername(), user.getSUserCode(), operatorUsername, | |
| 104 | + pcIds == null ? 0 : pcIds.size()); | |
| 105 | + | |
| 106 | + return CreateUserVo.builder() | |
| 107 | + .userId(user.getIIncrement()) | |
| 108 | + .username(user.getSUsername()) | |
| 109 | + .userCode(user.getSUserCode()) | |
| 110 | + .build(); | |
| 111 | + } | |
| 112 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 8 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 10 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 11 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 12 | +import lombok.RequiredArgsConstructor; | |
| 13 | +import org.springframework.stereotype.Service; | |
| 14 | + | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +@Service | |
| 18 | +@RequiredArgsConstructor | |
| 19 | +public class UserDetailServiceImpl implements UserDetailService { | |
| 20 | + | |
| 21 | + private final SysUserMapper userMapper; | |
| 22 | + private final SysEmployeeMapper employeeMapper; | |
| 23 | + private final SysUserPermissionCategoryMapper upcMapper; | |
| 24 | + | |
| 25 | + @Override | |
| 26 | + public UserDetailVo getById(Integer userId) { | |
| 27 | + SysUser user = userMapper.selectById(userId); | |
| 28 | + if (user == null) { | |
| 29 | + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + String employeeName = null; | |
| 33 | + if (user.getIEmployeeId() != null) { | |
| 34 | + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); | |
| 35 | + if (emp != null) { | |
| 36 | + employeeName = emp.getSEmployeeName(); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + List<Integer> pcIds = upcMapper.selectPermissionCategoryIdsByUserId(userId); | |
| 41 | + | |
| 42 | + return UserDetailVo.builder() | |
| 43 | + .userId(user.getIIncrement()) | |
| 44 | + .username(user.getSUsername()) | |
| 45 | + .userCode(user.getSUserCode()) | |
| 46 | + .userType(user.getSUserType()) | |
| 47 | + .language(user.getSLanguage()) | |
| 48 | + .canEditDocument(Integer.valueOf(1).equals(user.getICanEditDocument())) | |
| 49 | + .isDeleted(Integer.valueOf(1).equals(user.getIIsDeleted())) | |
| 50 | + .employeeId(user.getIEmployeeId()) | |
| 51 | + .employeeName(employeeName) | |
| 52 | + .permissionCategoryIds(pcIds) | |
| 53 | + .updatedBy(user.getSUpdatedBy()) | |
| 54 | + .updatedDate(user.getTUpdatedDate()) | |
| 55 | + .build(); | |
| 56 | + } | |
| 57 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.response.PageResult; | |
| 6 | +import com.xly.erp.module.usr.dto.UserQueryReq; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 8 | +import com.xly.erp.module.usr.mapper.UserQueryParams; | |
| 9 | +import com.xly.erp.module.usr.service.UserListService; | |
| 10 | +import com.xly.erp.module.usr.vo.UserListItemVo; | |
| 11 | +import lombok.RequiredArgsConstructor; | |
| 12 | +import org.springframework.stereotype.Service; | |
| 13 | + | |
| 14 | +import java.time.LocalDateTime; | |
| 15 | +import java.time.format.DateTimeFormatter; | |
| 16 | +import java.time.format.DateTimeParseException; | |
| 17 | +import java.util.List; | |
| 18 | +import java.util.Map; | |
| 19 | +import java.util.Set; | |
| 20 | + | |
| 21 | +@Service | |
| 22 | +@RequiredArgsConstructor | |
| 23 | +public class UserListServiceImpl implements UserListService { | |
| 24 | + | |
| 25 | + static final int DEFAULT_PAGE = 1; | |
| 26 | + static final int DEFAULT_SIZE = 20; | |
| 27 | + static final String DEFAULT_SORT_FIELD = "tCreateDate"; | |
| 28 | + static final String DEFAULT_SORT_ORDER = "desc"; | |
| 29 | + static final String DEFAULT_MATCH_MODE = "contains"; | |
| 30 | + | |
| 31 | + static final Set<String> SORT_FIELDS = Set.of( | |
| 32 | + "tCreateDate", "tLastLoginDate", "sUsername", "sUserCode"); | |
| 33 | + | |
| 34 | + static final Set<String> SORT_ORDERS = Set.of("asc", "desc"); | |
| 35 | + | |
| 36 | + static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals"); | |
| 37 | + | |
| 38 | + /** spec § 业务规则 3:非字符串列(int/datetime)一律按 equals 处理。 */ | |
| 39 | + static final Set<String> EQUALS_ONLY_FIELDS = Set.of("isDeleted", "lastLoginDate"); | |
| 40 | + | |
| 41 | + static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN"); | |
| 42 | + | |
| 43 | + static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries( | |
| 44 | + Map.entry("username", "u.sUsername"), | |
| 45 | + Map.entry("employeeName", "e.sEmployeeName"), | |
| 46 | + Map.entry("userCode", "u.sUserCode"), | |
| 47 | + Map.entry("departmentName", "d.sDepartmentName"), | |
| 48 | + Map.entry("userType", "u.sUserType"), | |
| 49 | + Map.entry("isDeleted", "u.iIsDeleted"), | |
| 50 | + Map.entry("lastLoginDate", "u.tLastLoginDate"), | |
| 51 | + Map.entry("createdBy", "u.sCreatedBy")); | |
| 52 | + | |
| 53 | + private final SysUserMapper userMapper; | |
| 54 | + | |
| 55 | + @Override | |
| 56 | + public PageResult<UserListItemVo> list(UserQueryReq req) { | |
| 57 | + // 应用默认值 | |
| 58 | + int page = req.getPage() == null ? DEFAULT_PAGE : req.getPage(); | |
| 59 | + int size = req.getSize() == null ? DEFAULT_SIZE : req.getSize(); | |
| 60 | + String sortField = req.getSortField() == null ? DEFAULT_SORT_FIELD : req.getSortField(); | |
| 61 | + String sortOrder = req.getSortOrder() == null ? DEFAULT_SORT_ORDER : req.getSortOrder(); | |
| 62 | + String matchMode = req.getMatchMode() == null ? DEFAULT_MATCH_MODE : req.getMatchMode(); | |
| 63 | + | |
| 64 | + // 白名单校验 | |
| 65 | + if (!SORT_FIELDS.contains(sortField)) { | |
| 66 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "sortField 不在白名单"); | |
| 67 | + } | |
| 68 | + if (!SORT_ORDERS.contains(sortOrder)) { | |
| 69 | + throw new BizException(ErrorCode.BAD_REQUEST, "sortOrder 必须为 asc 或 desc"); | |
| 70 | + } | |
| 71 | + if (!MATCH_MODES.contains(matchMode)) { | |
| 72 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "matchMode 不在白名单"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + String sqlQueryColumn = null; | |
| 76 | + String normalizedQueryValue = null; | |
| 77 | + if (req.getQueryField() != null && !req.getQueryField().isBlank()) { | |
| 78 | + sqlQueryColumn = QUERY_FIELD_TO_SQL.get(req.getQueryField()); | |
| 79 | + if (sqlQueryColumn == null) { | |
| 80 | + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单"); | |
| 81 | + } | |
| 82 | + // 只有 queryField + queryValue 都提供且非空才应用条件 | |
| 83 | + if (req.getQueryValue() != null && !req.getQueryValue().isBlank()) { | |
| 84 | + normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue()); | |
| 85 | + // spec § 业务规则 3:非字符串列一律强制 equals | |
| 86 | + if (EQUALS_ONLY_FIELDS.contains(req.getQueryField())) { | |
| 87 | + matchMode = "equals"; | |
| 88 | + } | |
| 89 | + } else { | |
| 90 | + sqlQueryColumn = null; // 缺 queryValue 跳过条件 | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + if (req.getUserType() != null && !USER_TYPES.contains(req.getUserType())) { | |
| 95 | + throw new BizException(ErrorCode.BAD_REQUEST, "userType 必须为 NORMAL 或 SUPER_ADMIN"); | |
| 96 | + } | |
| 97 | + | |
| 98 | + UserQueryParams p = new UserQueryParams(); | |
| 99 | + p.sqlSortField = sortField; | |
| 100 | + p.sqlSortOrder = sortOrder; | |
| 101 | + p.sqlQueryColumn = sqlQueryColumn; | |
| 102 | + p.matchMode = matchMode; | |
| 103 | + p.queryValue = normalizedQueryValue; | |
| 104 | + p.userType = req.getUserType(); | |
| 105 | + p.isDeleted = req.getIsDeleted() == null ? null : (req.getIsDeleted() ? 1 : 0); | |
| 106 | + p.limit = size; | |
| 107 | + p.offset = (page - 1) * size; | |
| 108 | + | |
| 109 | + long total = userMapper.countByQuery(p); | |
| 110 | + List<UserListItemVo> records = userMapper.selectByQuery(p); | |
| 111 | + | |
| 112 | + // 越界矫正:当前页空但 total>0 → 重算最后一页 | |
| 113 | + int actualPage = page; | |
| 114 | + if (records.isEmpty() && total > 0) { | |
| 115 | + int lastPage = (int) ((total + size - 1) / size); | |
| 116 | + p.offset = (lastPage - 1) * size; | |
| 117 | + records = userMapper.selectByQuery(p); | |
| 118 | + actualPage = lastPage; | |
| 119 | + } | |
| 120 | + | |
| 121 | + return PageResult.<UserListItemVo>builder() | |
| 122 | + .records(records) | |
| 123 | + .total(total) | |
| 124 | + .page(actualPage) | |
| 125 | + .size(size) | |
| 126 | + .build(); | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 对非字符串列做规范化(spec § 业务规则 3): | |
| 131 | + * - isDeleted: true/1 → "1";false/0 → "0";其他抛 40001 | |
| 132 | + * - lastLoginDate: 解析 ISO LOCAL_DATE_TIME 或 ISO LOCAL_DATE,统一格式为 'yyyy-MM-dd HH:mm:ss';非法抛 40001 | |
| 133 | + * - 其他列返回原值 | |
| 134 | + */ | |
| 135 | + private String normalizeQueryValue(String queryField, String raw) { | |
| 136 | + if ("isDeleted".equals(queryField)) { | |
| 137 | + if ("true".equalsIgnoreCase(raw) || "1".equals(raw)) return "1"; | |
| 138 | + if ("false".equalsIgnoreCase(raw) || "0".equals(raw)) return "0"; | |
| 139 | + throw new BizException(ErrorCode.BAD_REQUEST, | |
| 140 | + "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1"); | |
| 141 | + } | |
| 142 | + if ("lastLoginDate".equals(queryField)) { | |
| 143 | + try { | |
| 144 | + LocalDateTime dt; | |
| 145 | + if (raw.contains("T") || raw.contains(" ")) { | |
| 146 | + dt = LocalDateTime.parse(raw.replace(' ', 'T')); | |
| 147 | + } else { | |
| 148 | + dt = java.time.LocalDate.parse(raw).atStartOfDay(); | |
| 149 | + } | |
| 150 | + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); | |
| 151 | + } catch (DateTimeParseException e) { | |
| 152 | + throw new BizException(ErrorCode.BAD_REQUEST, | |
| 153 | + "queryField=lastLoginDate 时 queryValue 必须为 ISO 日期或日期时间"); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return raw; | |
| 157 | + } | |
| 158 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; | |
| 4 | +import com.xly.erp.common.exception.BizException; | |
| 5 | +import com.xly.erp.common.response.ErrorCode; | |
| 6 | +import com.xly.erp.module.usr.dto.UpdateUserReq; | |
| 7 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 9 | +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; | |
| 12 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 13 | +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; | |
| 14 | +import com.xly.erp.module.usr.service.UserDetailService; | |
| 15 | +import com.xly.erp.module.usr.service.UserUpdateService; | |
| 16 | +import com.xly.erp.module.usr.vo.UserDetailVo; | |
| 17 | +import lombok.RequiredArgsConstructor; | |
| 18 | +import lombok.extern.slf4j.Slf4j; | |
| 19 | +import org.springframework.stereotype.Service; | |
| 20 | +import org.springframework.transaction.annotation.Transactional; | |
| 21 | + | |
| 22 | +import java.time.LocalDateTime; | |
| 23 | +import java.util.HashSet; | |
| 24 | +import java.util.LinkedHashSet; | |
| 25 | +import java.util.List; | |
| 26 | +import java.util.Set; | |
| 27 | + | |
| 28 | +@Service | |
| 29 | +@RequiredArgsConstructor | |
| 30 | +@Slf4j | |
| 31 | +public class UserUpdateServiceImpl implements UserUpdateService { | |
| 32 | + | |
| 33 | + private final SysUserMapper userMapper; | |
| 34 | + private final SysEmployeeMapper employeeMapper; | |
| 35 | + private final SysPermissionCategoryMapper permissionCategoryMapper; | |
| 36 | + private final SysUserPermissionCategoryMapper upcMapper; | |
| 37 | + private final UserDetailService userDetailService; | |
| 38 | + | |
| 39 | + @Override | |
| 40 | + @Transactional | |
| 41 | + public UserDetailVo update(Integer userId, UpdateUserReq req, | |
| 42 | + Integer operatorUserId, String operatorUsername) { | |
| 43 | + // 1. 存在性 | |
| 44 | + SysUser existing = userMapper.selectById(userId); | |
| 45 | + if (existing == null) { | |
| 46 | + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 2. 自我停用守卫 | |
| 50 | + if (Boolean.TRUE.equals(req.getIsDeleted()) && userId.equals(operatorUserId)) { | |
| 51 | + throw new BizException(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE, | |
| 52 | + "不允许停用当前登录用户自己"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + // 3. userCode 唯一(排除自身) | |
| 56 | + if (req.getUserCode() != null | |
| 57 | + && !req.getUserCode().equals(existing.getSUserCode()) | |
| 58 | + && userMapper.existsByUserCodeExcludingId(req.getUserCode(), userId)) { | |
| 59 | + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已被占用"); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 4. employeeId 外键(仅正整数才查) | |
| 63 | + if (req.getEmployeeId() != null && req.getEmployeeId() > 0) { | |
| 64 | + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId()); | |
| 65 | + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) { | |
| 66 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除"); | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 5. permissionCategoryIds 外键(dedup 后校验) | |
| 71 | + Set<Integer> targetPcSet = null; | |
| 72 | + if (req.getPermissionCategoryIds() != null) { | |
| 73 | + targetPcSet = new LinkedHashSet<>(req.getPermissionCategoryIds()); | |
| 74 | + if (!targetPcSet.isEmpty()) { | |
| 75 | + int active = permissionCategoryMapper.countActiveByIds( | |
| 76 | + new java.util.ArrayList<>(targetPcSet)); | |
| 77 | + if (active != targetPcSet.size()) { | |
| 78 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, | |
| 79 | + "指定的权限分类含不存在或已删除项"); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + // 6. 写 sys_user 字段 | |
| 85 | + UpdateWrapper<SysUser> uw = new UpdateWrapper<>(); | |
| 86 | + uw.eq("iIncrement", userId); | |
| 87 | + | |
| 88 | + if (req.getUserCode() != null) uw.set("sUserCode", req.getUserCode()); | |
| 89 | + if (req.getUserType() != null) uw.set("sUserType", req.getUserType()); | |
| 90 | + if (req.getLanguage() != null) uw.set("sLanguage", req.getLanguage()); | |
| 91 | + if (req.getCanEditDocument() != null) | |
| 92 | + uw.set("iCanEditDocument", req.getCanEditDocument() ? 1 : 0); | |
| 93 | + if (req.getIsDeleted() != null) | |
| 94 | + uw.set("iIsDeleted", req.getIsDeleted() ? 1 : 0); | |
| 95 | + if (req.getEmployeeId() != null) { | |
| 96 | + if (req.getEmployeeId() == 0) { | |
| 97 | + uw.set("iEmployeeId", null); // 解除关联 | |
| 98 | + } else { | |
| 99 | + uw.set("iEmployeeId", req.getEmployeeId()); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + uw.set("sUpdatedBy", operatorUsername); | |
| 103 | + uw.set("tUpdatedDate", LocalDateTime.now()); | |
| 104 | + | |
| 105 | + userMapper.update(null, uw); | |
| 106 | + | |
| 107 | + // 7. 权限分类增量差集 | |
| 108 | + if (targetPcSet != null) { | |
| 109 | + List<Integer> currentList = upcMapper.selectPermissionCategoryIdsByUserId(userId); | |
| 110 | + Set<Integer> currentSet = new HashSet<>(currentList); | |
| 111 | + | |
| 112 | + Set<Integer> toRemove = new HashSet<>(currentSet); | |
| 113 | + toRemove.removeAll(targetPcSet); | |
| 114 | + | |
| 115 | + Set<Integer> toAdd = new LinkedHashSet<>(targetPcSet); | |
| 116 | + toAdd.removeAll(currentSet); | |
| 117 | + | |
| 118 | + if (!toRemove.isEmpty()) { | |
| 119 | + upcMapper.deleteByUserAndCategoryIds(userId, new java.util.ArrayList<>(toRemove)); | |
| 120 | + } | |
| 121 | + for (Integer pcId : toAdd) { | |
| 122 | + SysUserPermissionCategory link = new SysUserPermissionCategory(); | |
| 123 | + link.setIUserId(userId); | |
| 124 | + link.setIPermissionCategoryId(pcId); | |
| 125 | + link.setSGrantedBy(operatorUsername); | |
| 126 | + upcMapper.insert(link); | |
| 127 | + } | |
| 128 | + log.info("[user-update] userId={} pc.toRemove={} pc.toAdd={}", | |
| 129 | + userId, toRemove.size(), toAdd.size()); | |
| 130 | + } | |
| 131 | + | |
| 132 | + log.info("[user-update] userId={} byOperator={} 完成", userId, operatorUsername); | |
| 133 | + return userDetailService.getById(userId); | |
| 134 | + } | |
| 135 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java
0 → 100644
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 | +} | ... | ... |