2026-04-30-REQ-USR-004.md 6.69 KB

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(仅记录,不参与校验:合法值 标准版 / -

输出 / 结果

成功响应

{
  "code": 0, "msg": "ok",
  "data": {
    "accessToken": "<jwt>", "refreshToken": "<jwt>",
    "expiresIn": 28800,
    "user": { "iIncrement": 1, "sUserNo": "001", "sUserName": "u1", "sUserType": "普通用户", "sLanguage": "zh" }
  }
}

VO LoginVOaccessToken / refreshToken / expiresIn / userUserBriefVO

持久化效果

成功登录: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=1BizException(40102, "账号已禁用")
  4. 锁定检查:从 LoginAttemptStore(内存 ConcurrentHashMap)查该 sUserName 的失败记录;若在锁定期 → BizException(42301, "账号临时锁定,剩余 <N> 秒"),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<String, AttemptRecord{int count, Instant firstFailAt, Instant lockExpireAt}>;线程安全;锁定到期自动放行(下次登录尝试时若 now > lockExpireAt 则视为未锁,重置计数)。

边界与约束

  • 必填项空40001
  • 用户名不存在 / 密码错(未达阈值)40101
  • 账号 bDeleted40102
  • 锁定中42301 + 剩余秒数
  • sPasswordHash 不返回(VO 字段集排除)
  • 不入 DB 的失败计数:本期内存实现;docs/04 § 零 技术栈列了 Redis,未来引入时替换
  • 不引入 Redis:本 REQ 暂不增加运行时依赖

实现范围与边界抉择(含 Stub 闭环)

Phase A — 登录接口本体

  1. 新建 LoginDTO / LoginVO / UserBriefVO / LoginAttemptStore(@Component)
  2. JwtUtilsignRefresh(userNo) 方法
  3. UserMapperselectByUserName + updateLastLoginDate
  4. UserServicelogin(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 新建)

  • loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate
  • loginWithUserNotFound_throws40101
  • loginWithDeletedUser_throws40102
  • loginWithWrongPassword_incrementsCounter_throws40101
  • loginAfterMaxAttemptsReached_throws42301_withRemainingSeconds
  • loginWhileLocked_throws42301
  • loginAfterLockExpired_resetsCounter
  • loginAttemptStore_isolation — 不同 sUserName 互不影响
  • loginSuccess_clearsFailureCounter

Mapper IT(追加到 UserMapperIT

  • selectByUserName_returnsRowOrNull
  • updateLastLoginDate_setsValue

集成测试(AuthControllerIT 新建)

  • loginWithValidCredentials_returns200_withTokens
  • loginWithEmptyBody_returns40001
  • loginWithUserNotFound_returns40101
  • loginWithWrongPassword_returns40101
  • loginAfter5WrongPasswords_returns42301

Stub 闭环 IT 更新(Phase B,~8 处)

修改原"无 JWT 期望 200" 用例,新期望 code=20001,DB 无新增行。具体清单:

  • ModuleControllerIT:postWithoutJwt / putWithoutJwt / deleteWithoutJwt / getWithoutJwt(4 处)
  • UserControllerIT:postWithoutJwt / putWithoutJwt / getWithoutJwt(3 处)

工程验收

  • mvn -B test 全绿(约 145+ 用例)
  • SecurityConfig 路径只有 /api/usr/auth/login permitAll;其他全 authenticated
  • 全局 grep // REQ-MOD-001 stub: see USR-004 follow-up 返回 0 结果
  • grep stub.getStubUserNo() 返回 0 结果(service 层)
  • AuthenticationEntryPoint 配置:未认证 → code=20001