--- 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`