Commit 967f69fc2eee03a0ea2b6af318598a936daadbf3
1 parent
243e66ff
docs(usr): spec + plan REQ-USR-004
Showing
2 changed files
with
254 additions
and
0 deletions
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` | ... | ... |