Commit c8f6f04e7caa5c9be781fe242826dc2b90cffff1

Authored by zichun
1 parent 3d2c0ad3

chore(usr): REQ-USR-001 review approve + 归档 spec/plan/review

docs/08-模块任务管理.md
@@ -60,7 +60,7 @@ @@ -60,7 +60,7 @@
60 - 路径: backend/module/usr/ 60 - 路径: backend/module/usr/
61 - MR: — 61 - MR: —
62 - 功能: 62 - 功能:
63 - - [ ] REQ-USR-001 用户登录 63 + - [x] REQ-USR-001 用户登录
64 - [ ] REQ-USR-002 新增用户 64 - [ ] REQ-USR-002 新增用户
65 - [ ] REQ-USR-003 修改用户 65 - [ ] REQ-USR-003 修改用户
66 - [ ] REQ-USR-004 查询用户 66 - [ ] REQ-USR-004 查询用户
docs/superpowers/plans/2026-05-15-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-15
  4 +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-001.md
  5 +---
  6 +
  7 +# REQ-USR-001 用户登录 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `POST /api/v1/auth/login`,按 spec 完成 4 类校验(公司 / 作废 / 锁定 / 密码)+ 失败计数 + 锁定写入 + JWT 签发,并落地后端项目骨架以支撑后续 REQ。
  12 +
  13 +**Architecture:**
  14 +- Spring Boot 3 + MyBatis-Plus + Flyway + BCrypt + JJWT (HS256);分层 controller → service(impl) → mapper(docs/04 § 1.1)。
  15 +- 业务逻辑全部在 `LoginServiceImpl`,事务边界为成功路径的"清零计数 + 更新登录时间 + 签发 JWT"原子提交。
  16 +- 失败逻辑(计数累加 + 锁定写入)在独立事务里执行;锁定时间通过 `tLockUntil` 字段比对 `NOW()` 判定(无需 Redis)。
  17 +- 错误码集中在 `ErrorCode` 常量类;`GlobalExceptionHandler` 把 `BizException` 转 `Result.fail`。
  18 +
  19 +**Tech Stack:** Spring Boot 3.x(Java 17)/ MyBatis-Plus 最新稳定 / Flyway 10.x(core + mysql)/ Spring Security crypto(BCryptPasswordEncoder,不启用完整 Security filter chain)/ JJWT 0.12.x / Lombok / Jakarta Validation。
  20 +
  21 +---
  22 +
  23 +## Schema 改动
  24 +
  25 +无。V1 已建好 `sys_user` / `sys_company` / `sys_employee`,本 REQ 不动 schema。
  26 +
  27 +---
  28 +
  29 +## 文件变更清单
  30 +
  31 +**Bootstrap(首次 REQ 一次性投入,后续 REQ 复用):**
  32 +- `backend/pom.xml` — Create(Maven POM,声明依赖与插件)
  33 +- `backend/src/main/java/com/xly/erp/Application.java` — Create(Spring Boot 启动类)
  34 +- `backend/src/main/resources/application.yml` — Create(主配置,从 `.env.local` / 环境变量读敏感项)
  35 +- `backend/src/main/resources/application-test.yml` — Create(测试 profile,复用相同 schema 但禁日志彩色)
  36 +- `backend/src/main/resources/logback-spring.xml` — Create(最小 logback 配置)
  37 +
  38 +**通用基础层(首次 REQ 一次性投入):**
  39 +- `backend/src/main/java/com/xly/erp/common/response/Result.java` — Create
  40 +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — Create
  41 +- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — Create
  42 +- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — Create
  43 +- `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — Create
  44 +- `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` — Create(`BCryptPasswordEncoder` Bean)
  45 +
  46 +**业务层(REQ-USR-001 专属):**
  47 +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` — Create
  48 +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` — Create
  49 +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` — Create(只读 join 用,最小字段)
  50 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Create
  51 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` — Create
  52 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` — Create
  53 +- `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` — Create
  54 +- `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` — Create
  55 +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` — Create
  56 +- `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` — Create(接口)
  57 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` — Create
  58 +- `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` — Create
  59 +
  60 +**测试:**
  61 +- `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` — Create(unit/integration with Spring Test + 真实 MySQL,按 spec 验收 1-10 项)
  62 +- `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` — Create(`MockMvc` 端到端覆盖错误码 40001 / 40004 / 40101 / 40103 / 42301)
  63 +- `backend/src/test/resources/sql/req-usr-001-seed.sql` — Create(测试 fixture:1 个启用用户、1 个作废用户、2 个公司、1 个员工)
  64 +
  65 +---
  66 +
  67 +## 约束常量(跨任务,写死不允许漂移)
  68 +
  69 +**错误码**(`ErrorCode` 常量类):
  70 +
  71 +| 常量名 | 值 | HTTP |
  72 +|---|---|---|
  73 +| `OK` | `200` | 200 |
  74 +| `BAD_REQUEST` | `40001` | 400 |
  75 +| `COMPANY_NOT_FOUND` | `40004` | 400 |
  76 +| `BAD_CREDENTIALS` | `40101` | 401 |
  77 +| `ACCOUNT_DELETED` | `40103` | 401 |
  78 +| `ACCOUNT_LOCKED` | `42301` | 423 |
  79 +| `INTERNAL_ERROR` | `50000` | 500 |
  80 +
  81 +**JWT 常量**:算法 HS256;TTL 7200 秒;claims 名 `sub` / `username` / `userType` / `companyCode` / `language` / `iat` / `exp` / `jti`;签名密钥从 `application.yml` 注入 `${JWT_SECRET}`(已在 `.env.local` 配置)。
  82 +
  83 +**锁定策略**:阈值 5 次(含第 5 次触发锁定);锁定时长 30 分钟。Magic number 集中在 `LoginServiceImpl` 私有常量 `MAX_FAILED_LOGIN_COUNT = 5` 与 `LOCK_DURATION_MINUTES = 30L`。
  84 +
  85 +**API 形状**:
  86 +
  87 +```
  88 +POST /api/v1/auth/login (公开接口,无需 Bearer)
  89 +Request: LoginReq { username:String, password:String, companyCode:String }
  90 +Response: Result<LoginVo>
  91 + LoginVo { accessToken:String, tokenType:"Bearer", expiresInSec:7200, userInfo:UserInfoVo }
  92 + UserInfoVo { userId:int, username:String, userType:String, language:String,
  93 + employeeName:String?, companyCode:String }
  94 +```
  95 +
  96 +---
  97 +
  98 +## 任务步骤
  99 +
  100 +### Task 1: Bootstrap Spring Boot 项目骨架
  101 +
  102 +**Files:**
  103 +- Create: `backend/pom.xml`
  104 +- Create: `backend/src/main/java/com/xly/erp/Application.java`
  105 +- Create: `backend/src/main/resources/application.yml`
  106 +- Create: `backend/src/main/resources/application-test.yml`
  107 +- Create: `backend/src/main/resources/logback-spring.xml`
  108 +- Create: `backend/src/test/java/com/xly/erp/ApplicationContextTest.java`
  109 +
  110 +**配置要点**(POM 内容由 TDD 实现,签名约束如下):
  111 +- Parent: `spring-boot-starter-parent:3.3.x`,Java 17
  112 +- Dependencies: `spring-boot-starter-web`, `spring-boot-starter-validation`, `spring-boot-starter-test`, `mybatis-plus-spring-boot3-starter:3.5.x`, `mysql-connector-j` (runtime), `flyway-core` + `flyway-mysql`, `spring-security-crypto`(**不**引入 `spring-boot-starter-security`,避免开启 filter chain),`io.jsonwebtoken:jjwt-api/jjwt-impl/jjwt-jackson:0.12.5`, `lombok`
  113 +- Flyway 自动启用:默认指向 classpath:db/migration,本项目改为 `classpath:db/migration` 同步指向仓库根 `sql/migrations/` —— **由 Spring Boot 配置加载实现**:在 `application.yml` 写 `spring.flyway.locations: filesystem:../sql/migrations`(相对 backend/ 目录)
  114 +- `application.yml` 必须用 `${VAR_NAME}` 注入 DB / JWT 凭据(来源 `.env.local`,启动时由 maven-dotenv-plugin 或 `EnvironmentPostProcessor` 加载——TDD 选择最简单方案 `spring-boot-dotenv` 第三方 starter 或 spring-cloud-context 都不引入,改用 `application.yml` 占位 + 启动命令显式 `--spring.config.location` 或 OS 环境变量;**推荐**:要求开发者把 `.env.local` 中的变量 export 到 shell 后 `./mvnw spring-boot:run`;测试场景由 surefire `<environmentVariables>` 注入或测试自行 `@TestPropertySource` 覆盖)
  115 +
  116 +> 注:以上"如何加载 .env.local"是 Spring Boot 项目的通用工程难题,本任务**最小可行实现**:测试用 `@DynamicPropertySource` 从 `System.getenv()` 注入;启动用 OS 环境变量。生产部署后续 REQ 再优化。
  117 +
  118 +**API shape**:
  119 +- `Application.main(String[])` — 标准 `SpringApplication.run`
  120 +- `ApplicationContextTest#contextLoads()` — Spring Test 验证 ApplicationContext 启动
  121 +
  122 +- [ ] **Step 1: 写失败测试**
  123 + - 测试名: `ApplicationContextTest#contextLoads`
  124 + - 意图: 验证 Spring Boot context 能启动;Flyway 能连接到 .env.local 指定的 MySQL 并发现 V1 已 apply(含 `flyway_schema_history` 表)
  125 + - 子会话确认 FAIL(pom.xml 不存在 / Application 类不存在)
  126 +
  127 +- [ ] **Step 2: 实现最小代码**
  128 + - 写 pom.xml(依赖如上)、Application.java、application.yml、application-test.yml、logback-spring.xml
  129 + - 在 `application-test.yml` 中通过 `@DynamicPropertySource` 或 `spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_SCHEMA}` 占位符让测试读 env
  130 +
  131 +- [ ] **Step 3: 子会话验证 PASS**
  132 + - 子会话跑 `cd backend && ./mvnw -B test -Dtest=ApplicationContextTest` 应绿
  133 +
  134 +- [ ] **Step 4: Commit**
  135 + - `git add backend/pom.xml backend/src/main/java/com/xly/erp/Application.java backend/src/main/resources/ backend/src/test/java/com/xly/erp/ApplicationContextTest.java`
  136 + - `git commit -m "feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001"`
  137 +
  138 +---
  139 +
  140 +### Task 2: 通用响应包装 + 异常处理
  141 +
  142 +**Files:**
  143 +- Create: `backend/src/main/java/com/xly/erp/common/response/Result.java`
  144 +- Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  145 +- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java`
  146 +- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java`
  147 +- Create: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java`
  148 +
  149 +**API shape:**
  150 +- `Result<T> { int code; String message; T data; long timestamp; static <T> Result<T> ok(T data); static Result<?> fail(int code, String message); }`
  151 +- `ErrorCode` — 常量类,含上方"约束常量"表中所有 code(int 字段)
  152 +- `BizException extends RuntimeException { int code; String message; BizException(int code, String message); }`
  153 +- `GlobalExceptionHandler` — `@RestControllerAdvice`,handles:
  154 + - `BizException` → 按其 `code` 映射到 `ResponseEntity<Result>`,HTTP 状态按 ErrorCode 表
  155 + - `MethodArgumentNotValidException` / `ConstraintViolationException` → `Result.fail(40001, ...)`,HTTP 400
  156 + - `Exception`(兜底) → `Result.fail(50000, "服务器内部错误")`,HTTP 500,记 ERROR 日志,**不**回显堆栈到 message
  157 +
  158 +- [ ] **Step 1: 写失败测试**
  159 + - 测试名: `GlobalExceptionHandlerTest#bizException_returnsCodeAndHttpStatus` 等 3 个测试
  160 + - 意图: 用 `MockMvc` 配合一个 `/_test/throw-biz` 测试 controller 触发 `BizException(42301, "...")`,断言 HTTP 423 + body `code=42301`;类似覆盖 `BizException(40101, ...)` → 401,以及兜底 `RuntimeException` → 500 且 message 不含 "java." 前缀
  161 + - 子会话确认 FAIL
  162 +
  163 +- [ ] **Step 2: 实现最小代码**
  164 +
  165 +- [ ] **Step 3: 子会话验证 PASS**
  166 +
  167 +- [ ] **Step 4: Commit**
  168 + - `git commit -m "feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001"`
  169 +
  170 +---
  171 +
  172 +### Task 3: JWT 工具 + 密码编码器 Bean
  173 +
  174 +**Files:**
  175 +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java`
  176 +- Create: `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java`
  177 +- Create: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java`
  178 +
  179 +**API shape:**
  180 +- `JwtUtil#issue(Map<String,Object> claims, long ttlSec) : String` — 用 `${JWT_SECRET}`(注入 `@Value`)签发 HS256 JWT,含 `iat` / `exp` / `jti(=UUID)`
  181 +- `JwtUtil#parse(String token) : Map<String,Object>` — 验签 + 解析,签名错或过期抛 `BizException(40101, ...)`
  182 +- `PasswordEncoderConfig#passwordEncoder() : BCryptPasswordEncoder` — Spring Bean,strength=10
  183 +
  184 +- [ ] **Step 1: 写失败测试**
  185 + - 测试名: `JwtUtilTest#issuedToken_canBeParsedBackToClaims` / `JwtUtilTest#tamperedToken_throwsBizException` / `JwtUtilTest#expiredToken_throwsBizException`(用 ttl=0 模拟)
  186 + - 意图: 验证签发→解析往返一致;篡改任意一字节抛 40101;过期抛 40101
  187 +
  188 +- [ ] **Step 2: 实现最小代码**
  189 + - JWT_SECRET 来自 `application-test.yml` 的 `jwt.secret: ${JWT_SECRET:test-secret-256bit-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}`
  190 +
  191 +- [ ] **Step 3: 子会话验证 PASS**
  192 +
  193 +- [ ] **Step 4: Commit**
  194 + - `git commit -m "feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001"`
  195 +
  196 +---
  197 +
  198 +### Task 4: Entity + Mapper(sys_user / sys_company / sys_employee)
  199 +
  200 +**Files:**
  201 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java`
  202 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java`
  203 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java`
  204 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`
  205 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java`
  206 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java`
  207 +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java`
  208 +- Create: `backend/src/test/resources/sql/req-usr-001-seed.sql`
  209 +
  210 +**API shape:**
  211 +- `SysUser` — 含 `iIncrement / sUsername / sUserCode / sPasswordHash / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / iFailedLoginCount / tLockUntil / tLastLoginDate / sCreatedBy / sUpdatedBy / tUpdatedDate` 字段(命名按 docs/03,TableField 映射);`@TableName("sys_user")`,`@TableId(value="iIncrement", type=IdType.AUTO)`
  212 +- `SysCompany` — 含 `iIncrement / sCompanyCode / sCompanyName / iIsDeleted`(最小字段)
  213 +- `SysEmployee` — 含 `iIncrement / sEmployeeName / iDepartmentId`(最小字段)
  214 +- Mapper 三个均 `extends BaseMapper<T>`(MyBatis-Plus);`SysUserMapper` 额外定义一个方法:`selectByUsername(String username) : SysUser`(@Select 注解),用于登录查找
  215 +
  216 +**Seed SQL** 内容(写死):
  217 +```sql
  218 +-- req-usr-001-seed.sql
  219 +DELETE FROM sys_user_permission_category;
  220 +DELETE FROM sys_user;
  221 +DELETE FROM sys_employee;
  222 +DELETE FROM sys_company;
  223 +
  224 +INSERT INTO sys_company (sCompanyName, sCompanyCode, iIsDeleted) VALUES
  225 + ('总部', 'HQ', 0),
  226 + ('已删公司', 'DEL_CO', 1);
  227 +
  228 +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId)
  229 +SELECT '张三', 'E001', 1 FROM (SELECT 1) t
  230 +WHERE EXISTS (SELECT 1 FROM sys_department LIMIT 1);
  231 +-- 若 sys_department 为空,先插入一行
  232 +INSERT INTO sys_department (sDepartmentName, sDepartmentCode) VALUES ('技术部', 'TECH');
  233 +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId)
  234 + SELECT '张三', 'E001', iIncrement FROM sys_department WHERE sDepartmentCode='TECH' LIMIT 1;
  235 +
  236 +-- password = 'Password1!' 的 BCrypt(strength=10) 哈希(TDD 阶段实际生成填入)
  237 +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, iEmployeeId, sUserType, sLanguage, iIsDeleted, iFailedLoginCount, sCreatedBy)
  238 + SELECT 'alice', 'U001', '<BCRYPT_HASH_OF_Password1!>', iIncrement, 'NORMAL', 'zh-CN', 0, 0, 'system'
  239 + FROM sys_employee WHERE sEmployeeCode='E001';
  240 +
  241 +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, sUserType, sLanguage, iIsDeleted, sCreatedBy)
  242 + VALUES ('bob_deleted', 'U002', '<BCRYPT_HASH_OF_Password1!>', 'NORMAL', 'zh-CN', 1, 'system');
  243 +```
  244 +> `<BCRYPT_HASH_OF_Password1!>` 在 Task 4 实现时通过一次性 java main 或 `BCryptPasswordEncoder` 调用生成后填入;不允许保留占位符进 commit。
  245 +
  246 +- [ ] **Step 1: 写失败测试**
  247 + - 测试名: `SysUserMapperTest#selectByUsername_returnsUserWithAllFields` / `SysUserMapperTest#selectByUsername_returnsNullWhenNotFound`
  248 + - 意图: seed 后查 `alice` → 字段完整;查 `nobody` → null
  249 +
  250 +- [ ] **Step 2: 实现最小代码**
  251 +
  252 +- [ ] **Step 3: 子会话验证 PASS**
  253 +
  254 +- [ ] **Step 4: Commit**
  255 + - `git commit -m "feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001"`
  256 +
  257 +---
  258 +
  259 +### Task 5: LoginReq DTO + LoginVo + UserInfoVo
  260 +
  261 +**Files:**
  262 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java`
  263 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java`
  264 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java`
  265 +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java`
  266 +
  267 +**API shape:**
  268 +- `LoginReq { @NotBlank @Size(max=50) String username; @NotBlank @Size(max=128) String password; @NotBlank @Size(max=50) String companyCode; }`
  269 +- `LoginVo { String accessToken; String tokenType; long expiresInSec; UserInfoVo userInfo; }`
  270 +- `UserInfoVo { Integer userId; String username; String userType; String language; String employeeName; String companyCode; }`
  271 +
  272 +- [ ] **Step 1: 写失败测试**
  273 + - 测试名: `LoginReqValidationTest#blankUsername_fails` / `LoginReqValidationTest#tooLongUsername_fails` / `LoginReqValidationTest#allFieldsPresent_passes`
  274 + - 意图: 用 `Validator` 校验 jakarta 约束注解工作正常
  275 +
  276 +- [ ] **Step 2: 实现最小代码**
  277 +
  278 +- [ ] **Step 3: 子会话验证 PASS**
  279 +
  280 +- [ ] **Step 4: Commit**
  281 + - `git commit -m "feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001"`
  282 +
  283 +---
  284 +
  285 +### Task 6: LoginService 接口骨架
  286 +
  287 +**Files:**
  288 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java`
  289 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  290 +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  291 +
  292 +**API shape:**
  293 +- `LoginService#login(String username, String password, String companyCode) : LoginVo` — 业务方法签名
  294 +- `LoginServiceImpl implements LoginService` — `@Service`,注入 `SysUserMapper`、`SysCompanyMapper`、`SysEmployeeMapper`、`BCryptPasswordEncoder`、`JwtUtil`
  295 +- `LoginServiceImpl` 私有常量:`MAX_FAILED_LOGIN_COUNT = 5`、`LOCK_DURATION_MINUTES = 30L`、`TOKEN_TTL_SEC = 7200L`
  296 +
  297 +本 task 仅产出**接口 + 空实现 + 一个 baseline 测试**(直接抛 UnsupportedOperationException),用于建立后续 task 的脚手架。
  298 +
  299 +- [ ] **Step 1: 写失败测试**
  300 + - 测试名: `LoginServiceImplTest#contextLoads`(验证 LoginService Bean 注入成功;与 `@SpringBootTest` + seed.sql 一起工作)
  301 + - 意图: Bean 装配可用
  302 +
  303 +- [ ] **Step 2: 实现最小代码**
  304 +
  305 +- [ ] **Step 3: 子会话验证 PASS**
  306 +
  307 +- [ ] **Step 4: Commit**
  308 + - `git commit -m "feat(usr): LoginService 接口骨架 REQ-USR-001"`
  309 +
  310 +---
  311 +
  312 +### Task 7: Login — 公司不存在或已删 → 40004
  313 +
  314 +**Files:**
  315 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  316 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  317 +
  318 +**API behavior**:当 `companyCode` 在 `sys_company` 查不到(不存在 OR `iIsDeleted=1`)→ 抛 `BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除")`;**不**触碰 sys_user。
  319 +
  320 +- [ ] **Step 1: 写失败测试**
  321 + - 测试名: `LoginServiceImplTest#login_unknownCompany_throws40004` / `login_softDeletedCompany_throws40004`
  322 + - 意图: 用 seed 中的 `HQ`(正常)vs `NOPE`(不存在)vs `DEL_CO`(软删)触发不同分支;断言抛 `BizException` 且 `code == 40004`;同时断言 `alice` 的 `iFailedLoginCount` 仍为 0(未被错误计入失败)
  323 +
  324 +- [ ] **Step 2: 实现最小代码**
  325 +
  326 +- [ ] **Step 3: 子会话验证 PASS**
  327 +
  328 +- [ ] **Step 4: Commit**
  329 + - `git commit -m "feat(usr): 登录校验公司存在性 REQ-USR-001"`
  330 +
  331 +---
  332 +
  333 +### Task 8: Login — 用户不存在 / 密码错 → 40101(同文案,含失败计数)
  334 +
  335 +**Files:**
  336 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  337 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  338 +
  339 +**API behavior**:
  340 +- 用户名不存在 → `BizException(40101, "用户名或密码错误")`;不写 DB(无 user 行可写)
  341 +- 用户存在 + 密码 hash 不匹配 → `sys_user.iFailedLoginCount += 1`,返 `BizException(40101, "用户名或密码错误")`
  342 +
  343 +- [ ] **Step 1: 写失败测试**
  344 + - 测试名:
  345 + - `login_unknownUser_throws40101_noDbWrite`
  346 + - `login_badPassword_throws40101_andIncrementsFailCount` (断言一次错误后 `iFailedLoginCount == 1`)
  347 + - 意图: 防用户名枚举 + 失败计数累加
  348 +
  349 +- [ ] **Step 2: 实现最小代码**
  350 +
  351 +- [ ] **Step 3: 子会话验证 PASS**
  352 +
  353 +- [ ] **Step 4: Commit**
  354 + - `git commit -m "feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001"`
  355 +
  356 +---
  357 +
  358 +### Task 9: Login — 失败 5 次锁定 → 第 6 次返 42301
  359 +
  360 +**Files:**
  361 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  362 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  363 +
  364 +**API behavior**:
  365 +- 累计第 5 次错误密码时,在同一事务里写 `tLockUntil = NOW() + 30 分钟`;本次响应仍返 `40101`(**不**在第 5 次直接返锁定,避免泄露阈值)
  366 +- 后续请求遇到 `tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 直接抛 `BizException(ACCOUNT_LOCKED, "账号已锁定,请稍后再试")`,HTTP 423;**不**计入失败次数
  367 +- `tLockUntil <= NOW()`(锁定到期)→ 视为已解锁,正常进入密码校验流程(即仍允许累加失败、仍允许成功)
  368 +
  369 +- [ ] **Step 1: 写失败测试**
  370 + - 测试名:
  371 + - `login_5thBadPassword_setsLockUntil_andStillReturns40101`(断言 `iFailedLoginCount == 5` 且 `tLockUntil` 不为空 ≥ NOW()+29min)
  372 + - `login_duringLockWindow_throws42301_noCountIncrement`
  373 + - `login_afterLockExpired_allowsNewAttempt`(用 SQL 把 tLockUntil 改成过去时刻,再次登录正确密码 → 应成功并清零计数;属于先做小验证,可在 Task 10 完整覆盖成功路径)
  374 + - 意图: 锁定语义闭环
  375 +
  376 +- [ ] **Step 2: 实现最小代码**
  377 +
  378 +- [ ] **Step 3: 子会话验证 PASS**
  379 +
  380 +- [ ] **Step 4: Commit**
  381 + - `git commit -m "feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001"`
  382 +
  383 +---
  384 +
  385 +### Task 10: Login — 作废账号 → 40103;成功 → 签 JWT + 清零 + 更新登录时间
  386 +
  387 +**Files:**
  388 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java`
  389 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java`
  390 +
  391 +**API behavior**:
  392 +- `sys_user.iIsDeleted == 1` → `BizException(ACCOUNT_DELETED, "账号已被作废,禁止登录")`,HTTP 401;**不**进入密码校验,**不**累加失败
  393 +- 成功路径(`@Transactional`):
  394 + 1. `iFailedLoginCount = 0`,`tLockUntil = NULL`,`tLastLoginDate = NOW()`(一次 UPDATE)
  395 + 2. 加载 `sys_employee.sEmployeeName`(若 `iEmployeeId` 非空)
  396 + 3. 构造 JWT claims(`sub=userId`, `username`, `userType`, `companyCode`, `language`, `jti=UUID`),通过 `JwtUtil.issue(claims, TOKEN_TTL_SEC)`
  397 + 4. 返回 `LoginVo`
  398 +
  399 +判定顺序(先后明确):1) 公司校验 → 2) 用户查找 → 3) 作废校验 → 4) 锁定校验 → 5) 密码校验 → 6) 成功路径。
  400 +
  401 +- [ ] **Step 1: 写失败测试**
  402 + - 测试名:
  403 + - `login_deletedUser_throws40103_noCountIncrement`
  404 + - `login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin`
  405 + - `login_success_jwtParsesBack_with_sub_username_companyCode`
  406 + - 意图: 成功路径与作废路径都覆盖;JWT 验签往返
  407 +
  408 +- [ ] **Step 2: 实现最小代码**
  409 +
  410 +- [ ] **Step 3: 子会话验证 PASS**
  411 +
  412 +- [ ] **Step 4: Commit**
  413 + - `git commit -m "feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001"`
  414 +
  415 +---
  416 +
  417 +### Task 11: AuthController + 端到端 MockMvc 测试
  418 +
  419 +**Files:**
  420 +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java`
  421 +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java`
  422 +
  423 +**API shape:**
  424 +- `AuthController` — `@RestController @RequestMapping("/api/v1/auth")`
  425 +- `POST /api/v1/auth/login` 方法 `login(@RequestBody @Valid LoginReq req) : Result<LoginVo>`,委托给 `LoginService`
  426 +
  427 +- [ ] **Step 1: 写失败测试**
  428 + - 测试名(`AuthControllerTest` 用 `@SpringBootTest + MockMvc`):
  429 + - `post_login_success_returns200_andLoginVo`
  430 + - `post_login_badCredentials_returns401_code40101`
  431 + - `post_login_lockedAccount_returns423_code42301`
  432 + - `post_login_deletedAccount_returns401_code40103`
  433 + - `post_login_unknownCompany_returns400_code40004`
  434 + - `post_login_blankUsername_returns400_code40001`
  435 + - 意图: 6 个 HTTP 路径全部覆盖 spec § 验收 1-10
  436 +
  437 +- [ ] **Step 2: 实现最小代码**
  438 +
  439 +- [ ] **Step 3: 子会话验证 PASS**
  440 +
  441 +- [ ] **Step 4: Commit**
  442 + - `git commit -m "feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001"`
  443 +
  444 +---
  445 +
  446 +## 提交计划
  447 +
  448 +按 task 顺序产生 11 个 commit:
  449 +
  450 +| Task | Commit message |
  451 +|---|---|
  452 +| 1 | `feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001` |
  453 +| 2 | `feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001` |
  454 +| 3 | `feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001` |
  455 +| 4 | `feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001` |
  456 +| 5 | `feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001` |
  457 +| 6 | `feat(usr): LoginService 接口骨架 REQ-USR-001` |
  458 +| 7 | `feat(usr): 登录校验公司存在性 REQ-USR-001` |
  459 +| 8 | `feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001` |
  460 +| 9 | `feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001` |
  461 +| 10 | `feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001` |
  462 +| 11 | `feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001` |
docs/superpowers/reviews/2026-05-15-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-15
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-001 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +
  18 +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:28 — incrementFailedLoginCountAtomic 依赖 MySQL `SET` 子句左到右求值语义;建议加链接 dev.mysql.com/doc/refman/8.0/en/update.html 提醒切换数据库方言时复查
  19 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java:66 — lockUntil 在 service 层 ISO 字符串化;建议改为直接放 LocalDateTime 让 Jackson 全局规则统一处理
  20 +- backend/src/main/java/com/xly/erp/common/exception/BizException.java:25 — (int, String, Throwable) 构造的 data=null 是合理默认;未来若有"包装异常 + data"诉求再加 4 参构造
  21 +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java:171 — 并发测试用 2×2 低于阈值;可补 3×2 高于阈值场景断言 lock 触发 + count 准确
  22 +
  23 +## 反例 / 测试覆盖缺口
  24 +
  25 +Round 1 标记『推迟』的 4 项(docs/04 vs docs/05 跨文档不一致、HttpMessageNotReadableException 兜底、ErrorCode.toHttpStatus future-proof、application.yml flyway 路径)本轮未触及,符合 round 1 review 约定,不视为 gap。Reviewer 沙箱因无 MySQL 连接产生 28 个测试 error,已通过 surefire 报告确认为环境问题;主会话 34 测试全过。
  26 +
  27 +## 本轮变更归档
  28 +
  29 +Round 1 修复全部正确落地:
  30 +
  31 +| # | 项目 | 状态 |
  32 +|---|------|-----|
  33 +| H1 | 42301 data.lockUntil | ✓ BizException 扩 data 链路完整 |
  34 +| H2 | 失败计数原子 UPDATE | ✓ 单 SQL,去 noRollbackFor,并发回归覆盖 |
  35 +| H3 | SELECT * → 显式列 | ✓ 两个 mapper 都改 |
  36 +| M5 | JWT 短密钥静默补零 | ✓ < 32 字节 PostConstruct 硬抛 |
  37 +| M6 | loginSuccess 双路径 | ✓ 去 entity,走 markLoginSuccess SQL |
  38 +| NTH | exp-iat==7200 / lockUntil / 并发原子累加 测试 | ✓ 全部新增 |
  39 +
  40 +未引入新回归。Approve。
docs/superpowers/specs/2026-05-15-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-15
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 用户登录
  8 +
  9 +## 目标
  10 +
  11 +提供用户名 + 密码 + 公司编码的登录接口,校验通过后签发 JWT access token(HS256,TTL 2 小时)并返回当前用户基础信息,用于后续接口的 Bearer 鉴权。本 REQ 仅签发 access token,refresh token 推迟到后续 REQ(与 docs/05 LoginVo 一致;docs/04 § 1.6 的 refresh 描述视为未来增量,本 REQ 范围内不实现)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +HTTP 入口 `POST /api/v1/auth/login`,公开接口(无需 Bearer)。
  16 +
  17 +**请求体 LoginReq**(JSON):
  18 +
  19 +| 字段 | 类型 | 必填 | 校验规则(登录场景,宽松,不复用创建场景的强度规则) |
  20 +|---|---|---|---|
  21 +| `username` | string | 是 | 非空;长度 1-50(容纳潜在历史账号;越界返 `40001`) |
  22 +| `password` | string | 是 | 非空;长度 1-128(密码强度只在创建/重置流程校验,登录只校验匹配;越界返 `40001`) |
  23 +| `companyCode` | string | 是 | 非空;最大长度 50;命中 `sys_company.sCompanyCode AND iIsDeleted=0`,否则返 `40004` |
  24 +
  25 +> **登录与创建的校验差异**:REQ-USR-002 创建用户时密码必须 8-20 位含大小写字母和数字(强度规则);本 REQ 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。
  26 +
  27 +## 输出 / 结果
  28 +
  29 +**成功 200**:`Result<LoginVo>`,其中 `LoginVo`:
  30 +
  31 +```json
  32 +{
  33 + "accessToken": "<JWT HS256, TTL=7200s>",
  34 + "tokenType": "Bearer",
  35 + "expiresInSec": 7200,
  36 + "userInfo": {
  37 + "userId": 123,
  38 + "username": "alice",
  39 + "userType": "NORMAL",
  40 + "language": "zh-CN",
  41 + "employeeName": "张三",
  42 + "companyCode": "HQ"
  43 + }
  44 +}
  45 +```
  46 +
  47 +- `employeeName` 通过 `sys_user.iEmployeeId` JOIN `sys_employee.sEmployeeName` 取得;未绑定职员时字段省略。
  48 +- 副作用:写库 `sys_user.iFailedLoginCount = 0`、`sys_user.tLockUntil = NULL`、`sys_user.tLastLoginDate = NOW()`。
  49 +
  50 +**失败**:见错误码段,全部走全局异常处理器映射为 `Result.fail(code, message)`。错误码段位与 docs/05 一致:
  51 +
  52 +| HTTP | code | 含义 | 触发条件 |
  53 +|---|---|---|---|
  54 +| 400 | 40001 | 用户名或密码格式错误 | 必填字段缺失 / 类型错 / 长度越界 |
  55 +| 400 | 40004 | 公司不存在或已删除 | `companyCode` 在 `sys_company` 查不到(含 `iIsDeleted=1`) |
  56 +| 401 | 40101 | 用户名或密码错误 | 用户不存在 OR 密码哈希不匹配(统一文案,不区分两者,防用户名枚举) |
  57 +| 401 | 40103 | 账号已被作废,禁止登录 | `sys_user.iIsDeleted = 1` |
  58 +| 423 | 42301 | 账号已锁定,请稍后再试 | `sys_user.tLockUntil IS NOT NULL AND tLockUntil > NOW()`;`data.lockUntil` 返回 ISO 8601 剩余截止时刻 |
  59 +
  60 +## 业务规则
  61 +
  62 +1. **账号查找**:以 `sUsername` 全等匹配(大小写敏感,与建表 collation `utf8mb4_unicode_ci` 行为一致,登录时不做规范化)。
  63 +2. **作废态优先**:若用户记录 `iIsDeleted = 1`,直接返 `40103`,不进入密码校验,**不**累加 `iFailedLoginCount`。
  64 +3. **锁定优先**:若用户 `tLockUntil` 不为空且大于当前时间,直接返 `42301`,**不**进入密码校验,**不**累加。锁定到期(`tLockUntil <= NOW()`)视为已解锁,正常进入密码校验。
  65 +4. **密码校验**:使用 Spring Security `BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)`。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——`BCryptPasswordEncoder` 是 Spring Security 默认实现,无额外依赖;strength=10。
  66 +5. **失败计数**:密码不匹配时 `sys_user.iFailedLoginCount += 1`。
  67 + - 阈值:累计达到 **5 次**(含第 5 次)时,**同步**写 `sys_user.tLockUntil = NOW() + 30 分钟`;下一次(第 6 次)请求会落到锁定分支返 `42301`。
  68 + - 阈值前的失败仍返 `40101`,**不**主动告诉客户端剩余尝试次数(防探测)。
  69 +6. **登录成功**:原子事务(service 层 `@Transactional`)内:
  70 + - `sys_user.iFailedLoginCount = 0`
  71 + - `sys_user.tLockUntil = NULL`
  72 + - `sys_user.tLastLoginDate = NOW()`
  73 + - 然后签发 JWT(事务内构造 claim,事务提交后返响应)。
  74 +7. **JWT claims**:
  75 + - `sub` = `sys_user.iIncrement`(数字主键,字符串化)
  76 + - `username` = `sys_user.sUsername`
  77 + - `userType` = `sys_user.sUserType`
  78 + - `companyCode` = 请求传入的 `companyCode`(已校验存在)
  79 + - `language` = `sys_user.sLanguage`
  80 + - `iat` / `exp` = 标准时间 claims(TTL 7200s)
  81 + - `jti` = UUID(为未来 refresh / 黑名单预留,本 REQ 不消费)
  82 +8. **JWT 密钥**:从 `.env.local` 的 `JWT_SECRET` 读取;`application.yml` 用 `${JWT_SECRET}` 占位符注入,代码层不允许硬编码。
  83 +
  84 +## 边界与约束
  85 +
  86 +- **HTTPS 传输**:密码以明文走 HTTPS body,生产部署由 Nginx 终止 TLS(docs/07 § 二);本 REQ 不在应用层加密 / 解密密码。
  87 +- **统一响应**:全部走 `Result` / `Result.fail`(docs/04 § 1.3);错误响应禁回显堆栈。
  88 +- **审计日志**:登录成功 / 失败 / 锁定均通过 Logback 写 INFO / WARN 日志(含 traceId 与 username),日志位置遵循 docs/04 / docs/07;本 REQ 不写额外的 DB 审计表。
  89 +- **Redis**:docs/04 § 1.6 提及"签发后写 Redis",**本 REQ 不实现**——签发后不写 Redis,JWT 自包含可独立验证;登出黑名单功能推迟到后续 REQ。`pom.xml` 不强制要求 redis 依赖(如已加入也不使用)。
  90 +- **并发安全**:失败计数 / 锁定写入位于同一事务;MySQL 默认 RR 隔离 + 行锁可避免并发同账号失败计数竞争。
  91 +- **不实现**:管理员手动解锁、密码重置、refresh token、登出、多端互踢、Redis 黑名单——全部推迟到后续 REQ。
  92 +
  93 +## 依赖的 schema 表 / 字段
  94 +
  95 +读 + 写 `sys_user`(V1 已建):
  96 +- 读:`iIncrement`, `sUsername`, `sPasswordHash`, `sUserType`, `sLanguage`, `iEmployeeId`, `iIsDeleted`, `iFailedLoginCount`, `tLockUntil`
  97 +- 写:`iFailedLoginCount`, `tLockUntil`, `tLastLoginDate`
  98 +
  99 +只读 `sys_company`(V1 已建):
  100 +- 读:`sCompanyCode`, `iIsDeleted`
  101 +
  102 +只读 JOIN `sys_employee`(V1 已建):
  103 +- 读:`sEmployeeName`
  104 +
  105 +**本 REQ 不需要新增 migration**(schema 已就绪)。
  106 +
  107 +## 依赖的接口
  108 +
  109 +- 本 REQ 提供:`POST /api/v1/auth/login`(docs/05 § module_usr)。
  110 +- 外部依赖(前端会调,但本 REQ 范围不实现):`GET /api/v1/companies`(公司下拉),由后续运营模块或前端阶段使用 fixture 数据替代。
  111 +
  112 +## 验收标准
  113 +
  114 +后端集成测试(不依赖前端):
  115 +
  116 +1. **正确凭据 + 启用账号 + 存在公司** → 200,返回 `accessToken`(非空 JWT),`userInfo` 字段完整;`sys_user.tLastLoginDate` 更新、`iFailedLoginCount = 0`、`tLockUntil IS NULL`。
  117 +2. **JWT 可验签**:用 `JWT_SECRET` HS256 验签通过;`sub` = `userId` 字符串;`exp - iat == 7200`。
  118 +3. **错误密码 N 次(N<5)** → 401 / 40101;`iFailedLoginCount = N`;`tLockUntil` 仍为 NULL。
  119 +4. **第 5 次错误密码** → 401 / 40101;`iFailedLoginCount = 5`;`tLockUntil` 被写为 NOW() + 30 分钟。
  120 +5. **锁定期间第 6 次(任意密码)** → 423 / 42301;返回 `data.lockUntil` 字段;`iFailedLoginCount` 不再累加。
  121 +6. **锁定到期后** → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。
  122 +7. **作废账号(iIsDeleted=1)正确密码** → 401 / 40103;`iFailedLoginCount` 不变。
  123 +8. **用户名不存在** → 401 / 40101(与密码错误同文案)。
  124 +9. **公司不存在 / 已删除** → 400 / 40004,**不**累加失败计数(先于密码校验完成公司校验)。
  125 +10. **请求体缺字段 / 越长** → 400 / 40001。
  126 +11. **响应不回显堆栈**:故意制造服务端异常时,响应 `message` 为通用文案,无 stack trace。
  127 +12. **日志记录**:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。