diff --git a/docs/superpowers/plans/2026-04-30-REQ-USR-004.md b/docs/superpowers/plans/2026-04-30-REQ-USR-004.md new file mode 100644 index 0000000..7a04d2c --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-REQ-USR-004.md @@ -0,0 +1,107 @@ +--- +req_id: REQ-USR-004 +date: 2026-04-30 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-004.md +--- + +# REQ-USR-004 用户登录 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** Phase A 实现 POST /api/usr/auth/login + 失败计数锁定 + JWT 双 token;Phase B 闭环 stub permitAll → authenticated。 + +--- + +## Schema 改动 + +无。 + +## 文件变更清单 + +### 新增 + +- backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java +- backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java +- backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java +- backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java +- backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java +- backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java +- backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java +- backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java + +### 修改 + +- common/security/JwtUtil.java — 加 `signRefresh(userNo)` + 区分 access/refresh TTL +- common/security/SecurityConfig.java — 重写路径规则 + 注册 entryPoint +- module/usr/mapper/UserMapper.java — 加 `selectByUserName` + `updateLastLoginDate` +- module/usr/service/UserService.java + impl — 加 `login(LoginDTO)` +- module/mod/service/impl/ModuleServiceImpl.java — 移除 stub fallback +- module/usr/service/impl/UserServiceImpl.java — 移除 stub fallback(create + update) +- ModuleControllerIT (4 处) + UserControllerIT (3 处) — 改 stub 期望 + +--- + +## 任务步骤 + +### Phase A: 登录接口本体 + +#### Task 1: JwtUtil 加 signRefresh + +- 加 `Duration REFRESH_TTL = Duration.ofDays(30)` + `sign(userNo, ttl)` 通用方法 + `signRefresh(userNo)` 包装;现有 `sign(userNo)` 走 `Duration.ofHours(8)` +- 单测:`signRefresh_signsWithLongerTtl` +- Commit: `feat(usr): jwtutil signRefresh REQ-USR-004` + +#### Task 2: LoginAttemptStore + 单测 + +- 新建 `@Component LoginAttemptStore`:`recordFailure(userNo) → newCount` / `isLocked(userNo) → Optional` / `clearFailures(userNo)`;常量 `MAX_ATTEMPTS=5` / `LOCK_DURATION=Duration.ofMinutes(15)` +- 单测 5 用例覆盖隔离 / 累加 / 锁定 / 自动解锁 / 清空 +- Commit: `feat(usr): login attempt store + lock logic REQ-USR-004` + +#### Task 3: UserMapper 加 selectByUserName + updateLastLoginDate + +- `@Select("SELECT ... FROM tUser WHERE sUserName = #{name}")` `User selectByUserName(String name)` +- `@Update("UPDATE tUser SET tLastLoginDate = #{ts} WHERE iIncrement = #{id}")` `int updateLastLoginDate(Integer id, LocalDateTime ts)` +- IT 2 用例 +- Commit: `feat(usr): mapper selectByUserName + updateLastLoginDate REQ-USR-004` + +#### Task 4: LoginDTO + LoginVO + UserBriefVO + UserService.login + 单测 + +- 6 个 service 单测(spec 列表) +- Commit: `feat(usr): login service + dto/vo REQ-USR-004` + +#### Task 5: AuthController + IT (5 用例) + +- 新建 `AuthController @RequestMapping("/api/usr/auth")`,`@PostMapping("/login")` +- IT 5 用例覆盖 spec +- Commit: `feat(usr): auth controller + login it REQ-USR-004` + +### Phase B: Stub 闭环 + +#### Task 6: SecurityConfig 收紧 + AuthenticationEntryPoint + +- `JwtAuthenticationEntryPoint`:未认证写 JSON `Result.fail(20001, "未认证")` + status 200 +- SecurityConfig: + - 移除 `/api/mod/**` + `/api/usr/**` permitAll + - 加 `/api/usr/auth/login` permitAll + - `anyRequest().authenticated()` + - `exceptionHandling(eh -> eh.authenticationEntryPoint(authEntryPoint))` +- 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 注释 +- Commit: `refactor(usr): tighten security to authenticated REQ-USR-004` + +#### Task 7: ModuleServiceImpl + UserServiceImpl 移除 stub fallback + +- `ModuleServiceImpl#create`:`sCreatedBy = SecurityContextHelper.currentUserNo()`,去掉 `?: stub.getStubUserNo()`;同 `UserServiceImpl#create` + `update` +- 不破坏 `StubSecurityProperties` bean 本身(仍可保留以防其他用途;本 REQ 仅停止 fallback 引用) +- 单测调整:MOD-001 `createWithValidDto_persistsWithStandardCols` 期望 sCreatedBy 不再是 STUB_ADMIN(而要在测试里手动 SecurityContextHolder.set principal);其他类似 +- Commit: `refactor(usr): remove stub fallback in services REQ-USR-004` + +#### Task 8: 修改现有 stub IT 期望 + +- ModuleControllerIT 4 条:`postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` / `putWithoutJwt_*` / `deleteWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001`,DB 无新增行 +- UserControllerIT 3 条:`postWithoutJwt_*` / `putWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001` +- 全量回归绿 +- Commit: `test(usr): update stub regression to authenticated REQ-USR-004` + +## 提交计划 + +8 commits:6 feat + 2 refactor + 1 test(或合并为更少 commit 视进度)。 diff --git a/docs/superpowers/specs/2026-04-30-REQ-USR-004.md b/docs/superpowers/specs/2026-04-30-REQ-USR-004.md new file mode 100644 index 0000000..a28a892 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-REQ-USR-004.md @@ -0,0 +1,147 @@ +--- +req_id: REQ-USR-004 +date: 2026-04-30 +module: module_usr +--- + +# Spec: REQ-USR-004 — 用户登录 + +## 目标 + +实现 `POST /api/usr/auth/login` 登录接口(公开),含密码 BCrypt 校验、失败计数 + 临时锁定、JWT 双 token 签发、`tLastLoginDate` 更新。**同时**收尾整个项目的 stub permitAll,把 SecurityConfig 收紧为 "登录接口外全部 authenticated()",并完成 service 层 stub fallback 的移除。 + +## 输入 / 触发 + +### HTTP 接口(docs/05 § REQ-USR-004) + +- Method / Path: `POST /api/usr/auth/login` +- Auth: **无**(路径独立 permitAll) + +### 请求 DTO `LoginDTO` + +| 字段 | 类型 | 校验 | +|---|---|---| +| `sUserName` | `String` | `@NotBlank` | +| `password` | `String` | `@NotBlank` | +| `version` | `String` | `@NotBlank`(仅记录,不参与校验:合法值 `标准版` / `-`) | + +## 输出 / 结果 + +### 成功响应 + +```json +{ + "code": 0, "msg": "ok", + "data": { + "accessToken": "", "refreshToken": "", + "expiresIn": 28800, + "user": { "iIncrement": 1, "sUserNo": "001", "sUserName": "u1", "sUserType": "普通用户", "sLanguage": "zh" } + } +} +``` + +### VO `LoginVO`:`accessToken` / `refreshToken` / `expiresIn` / `user`(`UserBriefVO`) + +### 持久化效果 + +成功登录:`UPDATE tUser SET tLastLoginDate = NOW() WHERE iIncrement = ?`。失败:仅更新内存中失败计数,不入 DB。 + +## 业务规则 + +1. **必填校验**:`sUserName` / `password` 空 → `BizException(40001, "<字段>: 不能为空")`(实际由 Bean Validation @NotBlank 触发,GlobalExceptionHandler 转 40001)。 +2. **用户查询**:`SELECT * FROM tUser WHERE sUserName = ?`;不存在 → `BizException(40101, "用户名或密码错误")`(不区分用户名/密码错,防泄漏)。 +3. **账号禁用**:`user.bDeleted=1` → `BizException(40102, "账号已禁用")`。 +4. **锁定检查**:从 `LoginAttemptStore`(内存 `ConcurrentHashMap`)查该 sUserName 的失败记录;若在锁定期 → `BizException(42301, "账号临时锁定,剩余 秒")`,N = (lockExpireAt - now).seconds。 +5. **密码校验**:`bCryptPasswordEncoder.matches(password, user.sPasswordHash)`; + - 失败:失败计数 +1;若计数 ≥ `MAX_ATTEMPTS=5` → 设 `lockExpireAt = now + LOCK_DURATION=15分钟` → 抛 `BizException(42301, ...)`;否则抛 `BizException(40101, "用户名或密码错误")` + - 成功:清空该 sUserName 的失败计数;UPDATE tLastLoginDate;签发 access + refresh token;返回 LoginVO +6. **Token 签发**:`accessToken = jwtUtil.sign(sUserNo)`(默认 8 小时);`refreshToken = jwtUtil.signRefresh(sUserNo)`(30 天);`expiresIn = 28800`(access TTL 秒数常量)。 +7. **`LoginAttemptStore`** 内存实现:`Map`;线程安全;锁定到期自动放行(下次登录尝试时若 `now > lockExpireAt` 则视为未锁,重置计数)。 + +## 边界与约束 + +- **必填项空** → `40001` +- **用户名不存在 / 密码错(未达阈值)** → `40101` +- **账号 bDeleted** → `40102` +- **锁定中** → `42301` + 剩余秒数 +- **`sPasswordHash` 不返回**(VO 字段集排除) +- **不入 DB 的失败计数**:本期内存实现;docs/04 § 零 技术栈列了 Redis,未来引入时替换 +- **不引入 Redis**:本 REQ 暂不增加运行时依赖 + +## 实现范围与边界抉择(含 Stub 闭环) + +### Phase A — 登录接口本体 + +1. 新建 `LoginDTO` / `LoginVO` / `UserBriefVO` / `LoginAttemptStore`(@Component) +2. `JwtUtil` 加 `signRefresh(userNo)` 方法 +3. `UserMapper` 加 `selectByUserName` + `updateLastLoginDate` +4. `UserService` 加 `login(LoginDTO)` + 实现 +5. 新建 `AuthController` 处理 `/api/usr/auth/login` + +### Phase B — Stub 闭环(**关键里程碑**) + +收紧 SecurityConfig + 移除 service 层 stub fallback + 修复现有 IT 测试期望: + +1. `SecurityConfig`: + - 移除 `/api/mod/**` + `/api/usr/**` permitAll + - 改为 `/api/usr/auth/login` permitAll + `anyRequest().authenticated()` + - 注册 `AuthenticationEntryPoint` 把未认证 → JSON `Result(20001, "未认证")`(让 docs/05 § 全局约定 § 鉴权"缺失或失效返回 2xxxx" 落地) +2. `ModuleServiceImpl#create` 移除 `stub.getStubUserNo()` fallback:`sCreatedBy = SecurityContextHelper.currentUserNo()` 必须非 null(authenticated() 保证;若 null 抛兜底异常) +3. `UserServiceImpl#create` / `update` 同样移除 fallback +4. **现有 stub IT 测试更新**(约 8 处): + - `*WithoutJwt_permitAllStub_*` 系列(MOD-001/002/003 + USR-001/002):原期望 200 + sCreatedBy=STUB_ADMIN,改期望 `code=20001`,DB 无新行 + - `getWithoutJwt_permitAllStub_returns200`(MOD-004 + USR-003):改期望 `code=20001` + - 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点(grep -r 全局批量删) + +> Phase B 是 USR-004 完成的前提,模块完成报告 § ⑩ 列出的"鉴权 stub 收尾"将一次性闭环。 + +## 依赖的 schema 表 / 字段 + +读:`tUser.*`(按 sUserName 查全行) +写:`tUser.tLastLoginDate` + +## 依赖的接口 + +无。 + +## 验收标准 + +### 单元测试(`UserServiceImplTest` / `LoginAttemptStoreTest` 新建) + +- [x] `loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate` +- [x] `loginWithUserNotFound_throws40101` +- [x] `loginWithDeletedUser_throws40102` +- [x] `loginWithWrongPassword_incrementsCounter_throws40101` +- [x] `loginAfterMaxAttemptsReached_throws42301_withRemainingSeconds` +- [x] `loginWhileLocked_throws42301` +- [x] `loginAfterLockExpired_resetsCounter` +- [x] `loginAttemptStore_isolation` — 不同 sUserName 互不影响 +- [x] `loginSuccess_clearsFailureCounter` + +### Mapper IT(追加到 `UserMapperIT`) + +- [x] `selectByUserName_returnsRowOrNull` +- [x] `updateLastLoginDate_setsValue` + +### 集成测试(`AuthControllerIT` 新建) + +- [x] `loginWithValidCredentials_returns200_withTokens` +- [x] `loginWithEmptyBody_returns40001` +- [x] `loginWithUserNotFound_returns40101` +- [x] `loginWithWrongPassword_returns40101` +- [x] `loginAfter5WrongPasswords_returns42301` + +### Stub 闭环 IT 更新(**Phase B**,~8 处) + +修改原"无 JWT 期望 200" 用例,新期望 `code=20001`,DB 无新增行。具体清单: + +- ModuleControllerIT:postWithoutJwt / putWithoutJwt / deleteWithoutJwt / getWithoutJwt(4 处) +- UserControllerIT:postWithoutJwt / putWithoutJwt / getWithoutJwt(3 处) + +### 工程验收 + +- [x] `mvn -B test` 全绿(约 145+ 用例) +- [x] SecurityConfig 路径只有 `/api/usr/auth/login` permitAll;其他全 authenticated +- [x] 全局 grep `// REQ-MOD-001 stub: see USR-004 follow-up` 返回 0 结果 +- [x] grep `stub.getStubUserNo()` 返回 0 结果(service 层) +- [x] `AuthenticationEntryPoint` 配置:未认证 → `code=20001`