Merged
Merge Request #1
·
created by
feat(module_usr): 用户管理
完成报告
见 docs/superpowers/module-reports/2026-05-15-module_usr.md(本 MR 仓库内完整贴入下方)。
module_id: module_usr date: 2026-05-15
git_range: 76218f38 (bootstrap spring boot 后端骨架) ↔ 4d58768c (test-gate evidence)
模块完成报告 — module_usr 用户管理
① 模块信息
- 模块 ID: module_usr
- 模块名: 用户管理
- 开发区间: 2026-05-15 单日(REQ-USR-001 → 002 → 003 → 004)
- 分支: module-module_usr
② REQ 完成清单
- REQ-USR-001 — 用户登录
- spec: docs/superpowers/specs/2026-05-15-REQ-USR-001.md
- plan: docs/superpowers/plans/2026-05-15-REQ-USR-001.md
- review: docs/superpowers/reviews/2026-05-15-REQ-USR-001.md
- REQ-USR-002 — 新增用户
- spec: docs/superpowers/specs/2026-05-15-REQ-USR-002.md
- plan: docs/superpowers/plans/2026-05-15-REQ-USR-002.md
- review: docs/superpowers/reviews/2026-05-15-REQ-USR-002.md
- REQ-USR-003 — 修改用户(含 GET 详情)
- spec: docs/superpowers/specs/2026-05-15-REQ-USR-003.md
- plan: docs/superpowers/plans/2026-05-15-REQ-USR-003.md
- review: docs/superpowers/reviews/2026-05-15-REQ-USR-003.md
- REQ-USR-004 — 查询用户
- spec: docs/superpowers/specs/2026-05-15-REQ-USR-004.md
- plan: docs/superpowers/plans/2026-05-15-REQ-USR-004.md
- review: docs/superpowers/reviews/2026-05-15-REQ-USR-004.md
③ 文件变更表
| 文件 | 操作 | 说明 |
|---|---|---|
backend/pom.xml |
Create | Spring Boot 3.3 + MyBatis-Plus + Flyway + JJWT + BCrypt 依赖 |
backend/src/main/java/com/xly/erp/Application.java |
Create | 启动类 + @MapperScan |
backend/src/main/resources/application.yml / application-test.yml
|
Create | DB / JWT 配置 + Jackson 严格反序列化 + MyBatis mapper-locations |
backend/src/main/resources/logback-spring.xml |
Create | Logback 基础配置 |
backend/src/main/resources/mapper/usr/SysUserMapper.xml |
Create | REQ-004 动态 SQL JOIN + WHERE |
backend/src/main/java/com/xly/erp/common/response/{Result,ErrorCode,PageResult}.java |
Create | 统一响应包装 + 错误码(含 40001/40003/40004/40101/40103/40301/40302/40401/40901/40902/42301)+ 通用分页 VO |
backend/src/main/java/com/xly/erp/common/exception/{BizException,GlobalExceptionHandler}.java |
Create | 业务异常 + 全局 @RestControllerAdvice |
backend/src/main/java/com/xly/erp/common/security/{JwtUtil,LoginContext,RequireSuperAdmin,JwtHandlerInterceptor}.java |
Create | JWT 工具 + ThreadLocal + 角色守卫注解 + 鉴权拦截器 |
backend/src/main/java/com/xly/erp/common/config/{PasswordEncoderConfig,WebMvcConfig}.java |
Create | BCrypt Bean + interceptor 注册 |
backend/src/main/java/com/xly/erp/module/usr/entity/Sys*.java |
Create | SysUser / SysCompany / SysEmployee / SysPermissionCategory / SysUserPermissionCategory 5 张表实体 |
backend/src/main/java/com/xly/erp/module/usr/mapper/Sys*Mapper.java + UserQueryParams.java
|
Create | 5 个 mapper + REQ-004 查询参数 DTO |
backend/src/main/java/com/xly/erp/module/usr/dto/{LoginReq,CreateUserReq,UpdateUserReq,UserQueryReq}.java |
Create | 4 个请求 DTO |
backend/src/main/java/com/xly/erp/module/usr/vo/{LoginVo,UserInfoVo,CreateUserVo,UserDetailVo,UserListItemVo}.java |
Create | 5 个响应 VO |
backend/src/main/java/com/xly/erp/module/usr/service/*Service.java + impl/*ServiceImpl.java
|
Create | LoginService / UserCreateService / UserDetailService / UserUpdateService / UserListService |
backend/src/main/java/com/xly/erp/module/usr/controller/{AuthController,UserController}.java |
Create | 5 个 HTTP 端点(POST /auth/login,POST/GET/PUT /users,GET /users/{id}) |
backend/src/test/... |
Create | 14 个测试类,201 个测试方法 |
scripts/test.sh |
Modify | 把 ./mvnw 改为系统 mvn(与 docs/07 § 一 Maven 3.9.x 依赖对齐;backend 未带 wrapper) |
docs/05-API接口契约.md |
Modify | REQ-002 去 password 字段 / 40002;REQ-003 补 GET 详情段;REQ-004 错误码列表补 sortField + 40101 |
文件总数:59 个新建(44 个 main + 14 个 test),1 个修改(scripts/test.sh + docs/05);约 5118 行 backend/ 净增。
④ 数据库使用表
- 读:
sys_user,sys_company,sys_employee,sys_department,sys_permission_category,sys_user_permission_category - 写:
sys_user(新增 / 部分字段更新 / 登录追踪字段 / 失败计数原子 UPDATE),sys_user_permission_category(增删差集)
sys_company/sys_employee/sys_department本模块全程只读使用;新增 / 编辑这三张表的接口推迟到后续运营 / HR 模块。
⑤ 测试结果
-
scripts/test.sh最终:GREEN(详见docs/superpowers/module-reports/module_usr-test-gate.md) - 通过: 201 / 失败: 0 / 跳过: 0
- 覆盖率: 未配置覆盖率插件;REQ 级 spec 验收覆盖:
- REQ-USR-001:12/12(含 BizException 单测、JWT 单测、并发原子累加回归)
- REQ-USR-002:15/15(spec § 15 唯一索引兜底未单测,依赖文本匹配;记入 ⑩)
- REQ-USR-003:22/22(spec § 23 作废用户登录路径属 REQ-USR-001 既有,不重复)
- REQ-USR-004:26/26(含 spec § 业务规则 3 isDeleted/lastLoginDate 强制 equals 回归)
⑥ 本模块新增 Migration
—(本模块无 schema 改动;V1__initial_schema.sql 已在 A 阶段建好 6 张表,本模块全程复用)
⑦ 跨模块改动清单(软规则 S2)
—(本模块未触碰其他模块代码;scripts/test.sh 改动属于项目级基础设施修复,不构成跨模块改动。docs/05 修订属于本模块自己的 API 契约同步。)
⑧ 偏离 spec 清单
- REQ-USR-001: docs/04 § 1.6 描述了 access + refresh token 双 token 模型,本 REQ 只签发 access token;refresh 推迟到后续 REQ。spec 已显式声明此偏离。
- REQ-USR-001: docs/04 § 1.6 提及"签发后写 Redis",本 REQ 不实现 Redis 黑名单,JWT 自包含验证。spec 已声明。
- REQ-USR-002: REQ 卡片表 1 与 docs/05 在密码字段处理上原本冲突;spec 锁定为"系统生成初始密码 666666"(以 REQ 卡为准),docs/05 同步删除 password 字段与 40002 错误码。
- 跨 REQ 文档冲突: docs/04 § 1.3 错误码段位(10xxx/20xxx/.../60xxx)与 docs/05 + 代码实际使用的 HTTP-aligned 段位(40001/40101/40301/40901/...)不一致。本模块沿用 docs/05 + HTTP-aligned 方案;建议后续单独 PR 修订 docs/04 § 1.3 表述以拉齐 SSoT。
⑨ AI reviewer 报告汇总
- REQ-USR-001: round 1 — request-changes(4 high + 7 medium,已落地 5 项修复);round 2 — approve(4 nice-to-have)
- REQ-USR-002: round 1 — approve(13 nice-to-have,0 must-fix)
- REQ-USR-003: round 1 — approve(6 nice-to-have,0 must-fix;归档时顺手补 docs/05 PUT § 40101)
- REQ-USR-004: round 1 — request-changes(2 medium);round 2 — approve(4 nice-to-have)
⑩ 已知问题
-
spec § 15 唯一索引兜底(REQ-USR-002)未单测: UserCreateServiceImpl 在 DataIntegrityViolationException 时通过 message 文本匹配
uk_sys_user_username/uk_sys_user_code转 40901/40902;与 MySQL 驱动版本/locale 强耦合,缺 @SpyBean 模拟回归测试。建议后续用SQLState='23000' + 错误号 1062解析 + 单测覆盖。 -
权限分类批量插入未走 batchInsert: REQ-USR-002 / 003 用 for + 单条 insert(N 次 IO)。当前 N < 20 可接受,建议未来补
insertBatch。 -
permissionCategoryIds 差集无乐观锁: REQ-USR-003 并发 PUT 同一用户可能产生交叉写入。建议未来引入
sys_user.iVersion做乐观锁。 - ErrorCode.COMPANY_NOT_FOUND 复用语义错位: REQ-USR-002 / 003 用 40004 抛"权限分类不存在",常量名 COMPANY_NOT_FOUND 与 message 字面冲突。建议重命名为 RELATED_ENTITY_NOT_FOUND 或拆专用 code。
- entity Lombok 字段沿用 SQL 列名匈牙利前缀: 偏离 docs/04 § 1.2 Java 字段小驼峰约定。当前为了让 MyBatis-Plus 零配置映射;建议未来 refactor 用 @TableField 显式映射。
- 跨文档错误码段位冲突: docs/04 § 1.3 vs docs/05 + 代码(见 ⑧ 偏离)。
- mapper XML ORDER BY 硬编码 u.* 前缀: REQ-USR-004 sortField 白名单当前仅 sys_user 表列;未来扩展到 e.* / d.* 列需更新前缀逻辑。
- JwtHandlerInterceptor 每请求重查 DB: 无缓存;后续容量评估后可加 Caffeine 短期缓存。
- scripts/test.sh 之前调用不存在的 ./mvnw: 本模块期间发现并修复(改用系统 mvn);首次 test-gate 失败已记录在 test-gate evidence。
⑪ 下一模块预览
后端阶段仅本一个模块(module_usr),全部 REQ 已完成。
下一步:本 MR 合并到 master 后,重跑 /erp-workflow:coding-start,会自动检测 backend_done=true && frontend_done=false,派发 frontend-start 进入前端阶段。前端阶段会以 prototype/ 的 HTML mockup 为权威推导 FE 业务功能清单,按 FE 循环(fe-feature-brainstorm → plan → tdd → verify → review)完成。
前端预期工作量:基于已实现的 6 个端点(auth/login、users CRUD + 列表 + 详情),FE 至少需要:登录页、用户列表 + 筛选页、用户新增 / 编辑 Modal 表单。prototype/ 目前为空,进入前端阶段时 frontend-start 会通过 AskUserQuestion 引导补齐 mockup。
⑫ MR 链接
—(由 mr-create 在推送 + 创建 MR 后回写此处)
本地闸门证据
- 测试: green(subagent: a9aa7d814ffd7dd13)
审核入口
- 本 MR =
module_usr的唯一人工介入点(后端模块 / 前端阶段共用) - Approve + Merge 后,下次用户运行
/erp-workflow:coding-start时入口会自动扫描 GitLab APIstate=merged,探测默认分支后git pull --ff-only同步并推进下一模块(后端阶段)或宣告全部完成(前端阶段)
Too many changes to show.
To preserve performance only 72 of 103 files are displayed.
| @@ -132,7 +132,7 @@ B 阶段分两段,**全部固化到 skills**。入口:`/erp-workflow:coding- | @@ -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 | 2. **手动 Edit `docs/08 § 二/§ 三` 的 `MR:` / `整体 MR:` 字段**,必须要由 `mr-create` 自动回写 | 136 | 2. **手动 Edit `docs/08 § 二/§ 三` 的 `MR:` / `整体 MR:` 字段**,必须要由 `mr-create` 自动回写 |
| 137 | 137 | ||
| 138 | ### Schema 演化规约(Flyway migration) | 138 | ### Schema 演化规约(Flyway migration) |
| 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> |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 |
| 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 |
| 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> |
| 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> |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |
| 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 | +} |