Commit 967f69fc2eee03a0ea2b6af318598a936daadbf3

Authored by zichun
1 parent 243e66ff

docs(usr): spec + plan REQ-USR-004

docs/superpowers/plans/2026-04-30-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-04-30
  4 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-004.md
  5 +---
  6 +
  7 +# REQ-USR-004 用户登录 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** Phase A 实现 POST /api/usr/auth/login + 失败计数锁定 + JWT 双 token;Phase B 闭环 stub permitAll → authenticated。
  12 +
  13 +---
  14 +
  15 +## Schema 改动
  16 +
  17 +无。
  18 +
  19 +## 文件变更清单
  20 +
  21 +### 新增
  22 +
  23 +- backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java
  24 +- backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java
  25 +- backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java
  26 +- backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java
  27 +- backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java
  28 +- backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java
  29 +- backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java
  30 +- backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java
  31 +
  32 +### 修改
  33 +
  34 +- common/security/JwtUtil.java — 加 `signRefresh(userNo)` + 区分 access/refresh TTL
  35 +- common/security/SecurityConfig.java — 重写路径规则 + 注册 entryPoint
  36 +- module/usr/mapper/UserMapper.java — 加 `selectByUserName` + `updateLastLoginDate`
  37 +- module/usr/service/UserService.java + impl — 加 `login(LoginDTO)`
  38 +- module/mod/service/impl/ModuleServiceImpl.java — 移除 stub fallback
  39 +- module/usr/service/impl/UserServiceImpl.java — 移除 stub fallback(create + update)
  40 +- ModuleControllerIT (4 处) + UserControllerIT (3 处) — 改 stub 期望
  41 +
  42 +---
  43 +
  44 +## 任务步骤
  45 +
  46 +### Phase A: 登录接口本体
  47 +
  48 +#### Task 1: JwtUtil 加 signRefresh
  49 +
  50 +- 加 `Duration REFRESH_TTL = Duration.ofDays(30)` + `sign(userNo, ttl)` 通用方法 + `signRefresh(userNo)` 包装;现有 `sign(userNo)` 走 `Duration.ofHours(8)`
  51 +- 单测:`signRefresh_signsWithLongerTtl`
  52 +- Commit: `feat(usr): jwtutil signRefresh REQ-USR-004`
  53 +
  54 +#### Task 2: LoginAttemptStore + 单测
  55 +
  56 +- 新建 `@Component LoginAttemptStore`:`recordFailure(userNo) → newCount` / `isLocked(userNo) → Optional<Long secondsRemaining>` / `clearFailures(userNo)`;常量 `MAX_ATTEMPTS=5` / `LOCK_DURATION=Duration.ofMinutes(15)`
  57 +- 单测 5 用例覆盖隔离 / 累加 / 锁定 / 自动解锁 / 清空
  58 +- Commit: `feat(usr): login attempt store + lock logic REQ-USR-004`
  59 +
  60 +#### Task 3: UserMapper 加 selectByUserName + updateLastLoginDate
  61 +
  62 +- `@Select("SELECT ... FROM tUser WHERE sUserName = #{name}")` `User selectByUserName(String name)`
  63 +- `@Update("UPDATE tUser SET tLastLoginDate = #{ts} WHERE iIncrement = #{id}")` `int updateLastLoginDate(Integer id, LocalDateTime ts)`
  64 +- IT 2 用例
  65 +- Commit: `feat(usr): mapper selectByUserName + updateLastLoginDate REQ-USR-004`
  66 +
  67 +#### Task 4: LoginDTO + LoginVO + UserBriefVO + UserService.login + 单测
  68 +
  69 +- 6 个 service 单测(spec 列表)
  70 +- Commit: `feat(usr): login service + dto/vo REQ-USR-004`
  71 +
  72 +#### Task 5: AuthController + IT (5 用例)
  73 +
  74 +- 新建 `AuthController @RequestMapping("/api/usr/auth")`,`@PostMapping("/login")`
  75 +- IT 5 用例覆盖 spec
  76 +- Commit: `feat(usr): auth controller + login it REQ-USR-004`
  77 +
  78 +### Phase B: Stub 闭环
  79 +
  80 +#### Task 6: SecurityConfig 收紧 + AuthenticationEntryPoint
  81 +
  82 +- `JwtAuthenticationEntryPoint`:未认证写 JSON `Result.fail(20001, "未认证")` + status 200
  83 +- SecurityConfig:
  84 + - 移除 `/api/mod/**` + `/api/usr/**` permitAll
  85 + - 加 `/api/usr/auth/login` permitAll
  86 + - `anyRequest().authenticated()`
  87 + - `exceptionHandling(eh -> eh.authenticationEntryPoint(authEntryPoint))`
  88 +- 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 注释
  89 +- Commit: `refactor(usr): tighten security to authenticated REQ-USR-004`
  90 +
  91 +#### Task 7: ModuleServiceImpl + UserServiceImpl 移除 stub fallback
  92 +
  93 +- `ModuleServiceImpl#create`:`sCreatedBy = SecurityContextHelper.currentUserNo()`,去掉 `?: stub.getStubUserNo()`;同 `UserServiceImpl#create` + `update`
  94 +- 不破坏 `StubSecurityProperties` bean 本身(仍可保留以防其他用途;本 REQ 仅停止 fallback 引用)
  95 +- 单测调整:MOD-001 `createWithValidDto_persistsWithStandardCols` 期望 sCreatedBy 不再是 STUB_ADMIN(而要在测试里手动 SecurityContextHolder.set principal);其他类似
  96 +- Commit: `refactor(usr): remove stub fallback in services REQ-USR-004`
  97 +
  98 +#### Task 8: 修改现有 stub IT 期望
  99 +
  100 +- ModuleControllerIT 4 条:`postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` / `putWithoutJwt_*` / `deleteWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001`,DB 无新增行
  101 +- UserControllerIT 3 条:`postWithoutJwt_*` / `putWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001`
  102 +- 全量回归绿
  103 +- Commit: `test(usr): update stub regression to authenticated REQ-USR-004`
  104 +
  105 +## 提交计划
  106 +
  107 +8 commits:6 feat + 2 refactor + 1 test(或合并为更少 commit 视进度)。
docs/superpowers/specs/2026-04-30-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-04-30
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-004 — 用户登录
  8 +
  9 +## 目标
  10 +
  11 +实现 `POST /api/usr/auth/login` 登录接口(公开),含密码 BCrypt 校验、失败计数 + 临时锁定、JWT 双 token 签发、`tLastLoginDate` 更新。**同时**收尾整个项目的 stub permitAll,把 SecurityConfig 收紧为 "登录接口外全部 authenticated()",并完成 service 层 stub fallback 的移除。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-USR-004)
  16 +
  17 +- Method / Path: `POST /api/usr/auth/login`
  18 +- Auth: **无**(路径独立 permitAll)
  19 +
  20 +### 请求 DTO `LoginDTO`
  21 +
  22 +| 字段 | 类型 | 校验 |
  23 +|---|---|---|
  24 +| `sUserName` | `String` | `@NotBlank` |
  25 +| `password` | `String` | `@NotBlank` |
  26 +| `version` | `String` | `@NotBlank`(仅记录,不参与校验:合法值 `标准版` / `-`) |
  27 +
  28 +## 输出 / 结果
  29 +
  30 +### 成功响应
  31 +
  32 +```json
  33 +{
  34 + "code": 0, "msg": "ok",
  35 + "data": {
  36 + "accessToken": "<jwt>", "refreshToken": "<jwt>",
  37 + "expiresIn": 28800,
  38 + "user": { "iIncrement": 1, "sUserNo": "001", "sUserName": "u1", "sUserType": "普通用户", "sLanguage": "zh" }
  39 + }
  40 +}
  41 +```
  42 +
  43 +### VO `LoginVO`:`accessToken` / `refreshToken` / `expiresIn` / `user`(`UserBriefVO`)
  44 +
  45 +### 持久化效果
  46 +
  47 +成功登录:`UPDATE tUser SET tLastLoginDate = NOW() WHERE iIncrement = ?`。失败:仅更新内存中失败计数,不入 DB。
  48 +
  49 +## 业务规则
  50 +
  51 +1. **必填校验**:`sUserName` / `password` 空 → `BizException(40001, "<字段>: 不能为空")`(实际由 Bean Validation @NotBlank 触发,GlobalExceptionHandler 转 40001)。
  52 +2. **用户查询**:`SELECT * FROM tUser WHERE sUserName = ?`;不存在 → `BizException(40101, "用户名或密码错误")`(不区分用户名/密码错,防泄漏)。
  53 +3. **账号禁用**:`user.bDeleted=1` → `BizException(40102, "账号已禁用")`。
  54 +4. **锁定检查**:从 `LoginAttemptStore`(内存 `ConcurrentHashMap`)查该 sUserName 的失败记录;若在锁定期 → `BizException(42301, "账号临时锁定,剩余 <N> 秒")`,N = (lockExpireAt - now).seconds。
  55 +5. **密码校验**:`bCryptPasswordEncoder.matches(password, user.sPasswordHash)`;
  56 + - 失败:失败计数 +1;若计数 ≥ `MAX_ATTEMPTS=5` → 设 `lockExpireAt = now + LOCK_DURATION=15分钟` → 抛 `BizException(42301, ...)`;否则抛 `BizException(40101, "用户名或密码错误")`
  57 + - 成功:清空该 sUserName 的失败计数;UPDATE tLastLoginDate;签发 access + refresh token;返回 LoginVO
  58 +6. **Token 签发**:`accessToken = jwtUtil.sign(sUserNo)`(默认 8 小时);`refreshToken = jwtUtil.signRefresh(sUserNo)`(30 天);`expiresIn = 28800`(access TTL 秒数常量)。
  59 +7. **`LoginAttemptStore`** 内存实现:`Map<String, AttemptRecord{int count, Instant firstFailAt, Instant lockExpireAt}>`;线程安全;锁定到期自动放行(下次登录尝试时若 `now > lockExpireAt` 则视为未锁,重置计数)。
  60 +
  61 +## 边界与约束
  62 +
  63 +- **必填项空** → `40001`
  64 +- **用户名不存在 / 密码错(未达阈值)** → `40101`
  65 +- **账号 bDeleted** → `40102`
  66 +- **锁定中** → `42301` + 剩余秒数
  67 +- **`sPasswordHash` 不返回**(VO 字段集排除)
  68 +- **不入 DB 的失败计数**:本期内存实现;docs/04 § 零 技术栈列了 Redis,未来引入时替换
  69 +- **不引入 Redis**:本 REQ 暂不增加运行时依赖
  70 +
  71 +## 实现范围与边界抉择(含 Stub 闭环)
  72 +
  73 +### Phase A — 登录接口本体
  74 +
  75 +1. 新建 `LoginDTO` / `LoginVO` / `UserBriefVO` / `LoginAttemptStore`(@Component)
  76 +2. `JwtUtil` 加 `signRefresh(userNo)` 方法
  77 +3. `UserMapper` 加 `selectByUserName` + `updateLastLoginDate`
  78 +4. `UserService` 加 `login(LoginDTO)` + 实现
  79 +5. 新建 `AuthController` 处理 `/api/usr/auth/login`
  80 +
  81 +### Phase B — Stub 闭环(**关键里程碑**)
  82 +
  83 +收紧 SecurityConfig + 移除 service 层 stub fallback + 修复现有 IT 测试期望:
  84 +
  85 +1. `SecurityConfig`:
  86 + - 移除 `/api/mod/**` + `/api/usr/**` permitAll
  87 + - 改为 `/api/usr/auth/login` permitAll + `anyRequest().authenticated()`
  88 + - 注册 `AuthenticationEntryPoint` 把未认证 → JSON `Result(20001, "未认证")`(让 docs/05 § 全局约定 § 鉴权"缺失或失效返回 2xxxx" 落地)
  89 +2. `ModuleServiceImpl#create` 移除 `stub.getStubUserNo()` fallback:`sCreatedBy = SecurityContextHelper.currentUserNo()` 必须非 null(authenticated() 保证;若 null 抛兜底异常)
  90 +3. `UserServiceImpl#create` / `update` 同样移除 fallback
  91 +4. **现有 stub IT 测试更新**(约 8 处):
  92 + - `*WithoutJwt_permitAllStub_*` 系列(MOD-001/002/003 + USR-001/002):原期望 200 + sCreatedBy=STUB_ADMIN,改期望 `code=20001`,DB 无新行
  93 + - `getWithoutJwt_permitAllStub_returns200`(MOD-004 + USR-003):改期望 `code=20001`
  94 + - 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点(grep -r 全局批量删)
  95 +
  96 +> Phase B 是 USR-004 完成的前提,模块完成报告 § ⑩ 列出的"鉴权 stub 收尾"将一次性闭环。
  97 +
  98 +## 依赖的 schema 表 / 字段
  99 +
  100 +读:`tUser.*`(按 sUserName 查全行)
  101 +写:`tUser.tLastLoginDate`
  102 +
  103 +## 依赖的接口
  104 +
  105 +无。
  106 +
  107 +## 验收标准
  108 +
  109 +### 单元测试(`UserServiceImplTest` / `LoginAttemptStoreTest` 新建)
  110 +
  111 +- [x] `loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate`
  112 +- [x] `loginWithUserNotFound_throws40101`
  113 +- [x] `loginWithDeletedUser_throws40102`
  114 +- [x] `loginWithWrongPassword_incrementsCounter_throws40101`
  115 +- [x] `loginAfterMaxAttemptsReached_throws42301_withRemainingSeconds`
  116 +- [x] `loginWhileLocked_throws42301`
  117 +- [x] `loginAfterLockExpired_resetsCounter`
  118 +- [x] `loginAttemptStore_isolation` — 不同 sUserName 互不影响
  119 +- [x] `loginSuccess_clearsFailureCounter`
  120 +
  121 +### Mapper IT(追加到 `UserMapperIT`)
  122 +
  123 +- [x] `selectByUserName_returnsRowOrNull`
  124 +- [x] `updateLastLoginDate_setsValue`
  125 +
  126 +### 集成测试(`AuthControllerIT` 新建)
  127 +
  128 +- [x] `loginWithValidCredentials_returns200_withTokens`
  129 +- [x] `loginWithEmptyBody_returns40001`
  130 +- [x] `loginWithUserNotFound_returns40101`
  131 +- [x] `loginWithWrongPassword_returns40101`
  132 +- [x] `loginAfter5WrongPasswords_returns42301`
  133 +
  134 +### Stub 闭环 IT 更新(**Phase B**,~8 处)
  135 +
  136 +修改原"无 JWT 期望 200" 用例,新期望 `code=20001`,DB 无新增行。具体清单:
  137 +
  138 +- ModuleControllerIT:postWithoutJwt / putWithoutJwt / deleteWithoutJwt / getWithoutJwt(4 处)
  139 +- UserControllerIT:postWithoutJwt / putWithoutJwt / getWithoutJwt(3 处)
  140 +
  141 +### 工程验收
  142 +
  143 +- [x] `mvn -B test` 全绿(约 145+ 用例)
  144 +- [x] SecurityConfig 路径只有 `/api/usr/auth/login` permitAll;其他全 authenticated
  145 +- [x] 全局 grep `// REQ-MOD-001 stub: see USR-004 follow-up` 返回 0 结果
  146 +- [x] grep `stub.getStubUserNo()` 返回 0 结果(service 层)
  147 +- [x] `AuthenticationEntryPoint` 配置:未认证 → `code=20001`