2026-05-15-REQ-USR-001.md 7.84 KB

req_id: REQ-USR-001 date: 2026-05-15

module: module_usr

Spec: REQ-USR-001 — 用户登录

目标

提供用户名 + 密码 + 公司编码的登录接口,校验通过后签发 JWT access token(HS256,TTL 2 小时)并返回当前用户基础信息,用于后续接口的 Bearer 鉴权。本 REQ 仅签发 access token,refresh token 推迟到后续 REQ(与 docs/05 LoginVo 一致;docs/04 § 1.6 的 refresh 描述视为未来增量,本 REQ 范围内不实现)。

输入 / 触发

HTTP 入口 POST /api/v1/auth/login,公开接口(无需 Bearer)。

请求体 LoginReq(JSON):

字段 类型 必填 校验规则(登录场景,宽松,不复用创建场景的强度规则)
username string 非空;长度 1-50(容纳潜在历史账号;越界返 40001
password string 非空;长度 1-128(密码强度只在创建/重置流程校验,登录只校验匹配;越界返 40001
companyCode string 非空;最大长度 50;命中 sys_company.sCompanyCode AND iIsDeleted=0,否则返 40004

登录与创建的校验差异:REQ-USR-002 创建用户时密码必须 8-20 位含大小写字母和数字(强度规则);本 REQ 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。

输出 / 结果

成功 200Result<LoginVo>,其中 LoginVo

{
  "accessToken": "<JWT HS256, TTL=7200s>",
  "tokenType": "Bearer",
  "expiresInSec": 7200,
  "userInfo": {
    "userId": 123,
    "username": "alice",
    "userType": "NORMAL",
    "language": "zh-CN",
    "employeeName": "张三",
    "companyCode": "HQ"
  }
}
  • employeeName 通过 sys_user.iEmployeeId JOIN sys_employee.sEmployeeName 取得;未绑定职员时字段省略。
  • 副作用:写库 sys_user.iFailedLoginCount = 0sys_user.tLockUntil = NULLsys_user.tLastLoginDate = NOW()

失败:见错误码段,全部走全局异常处理器映射为 Result.fail(code, message)。错误码段位与 docs/05 一致:

HTTP code 含义 触发条件
400 40001 用户名或密码格式错误 必填字段缺失 / 类型错 / 长度越界
400 40004 公司不存在或已删除 companyCodesys_company 查不到(含 iIsDeleted=1
401 40101 用户名或密码错误 用户不存在 OR 密码哈希不匹配(统一文案,不区分两者,防用户名枚举)
401 40103 账号已被作废,禁止登录 sys_user.iIsDeleted = 1
423 42301 账号已锁定,请稍后再试 sys_user.tLockUntil IS NOT NULL AND tLockUntil > NOW()data.lockUntil 返回 ISO 8601 剩余截止时刻

业务规则

  1. 账号查找:以 sUsername 全等匹配(大小写敏感,与建表 collation utf8mb4_unicode_ci 行为一致,登录时不做规范化)。
  2. 作废态优先:若用户记录 iIsDeleted = 1,直接返 40103,不进入密码校验,累加 iFailedLoginCount
  3. 锁定优先:若用户 tLockUntil 不为空且大于当前时间,直接返 42301进入密码校验,累加。锁定到期(tLockUntil <= NOW())视为已解锁,正常进入密码校验。
  4. 密码校验:使用 Spring Security BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——BCryptPasswordEncoder 是 Spring Security 默认实现,无额外依赖;strength=10。
  5. 失败计数:密码不匹配时 sys_user.iFailedLoginCount += 1
    • 阈值:累计达到 5 次(含第 5 次)时,同步sys_user.tLockUntil = NOW() + 30 分钟;下一次(第 6 次)请求会落到锁定分支返 42301
    • 阈值前的失败仍返 40101主动告诉客户端剩余尝试次数(防探测)。
  6. 登录成功:原子事务(service 层 @Transactional)内:
    • sys_user.iFailedLoginCount = 0
    • sys_user.tLockUntil = NULL
    • sys_user.tLastLoginDate = NOW()
    • 然后签发 JWT(事务内构造 claim,事务提交后返响应)。
  7. JWT claims
    • sub = sys_user.iIncrement(数字主键,字符串化)
    • username = sys_user.sUsername
    • userType = sys_user.sUserType
    • companyCode = 请求传入的 companyCode(已校验存在)
    • language = sys_user.sLanguage
    • iat / exp = 标准时间 claims(TTL 7200s)
    • jti = UUID(为未来 refresh / 黑名单预留,本 REQ 不消费)
  8. JWT 密钥:从 .env.localJWT_SECRET 读取;application.yml${JWT_SECRET} 占位符注入,代码层不允许硬编码。

边界与约束

  • HTTPS 传输:密码以明文走 HTTPS body,生产部署由 Nginx 终止 TLS(docs/07 § 二);本 REQ 不在应用层加密 / 解密密码。
  • 统一响应:全部走 Result / Result.fail(docs/04 § 1.3);错误响应禁回显堆栈。
  • 审计日志:登录成功 / 失败 / 锁定均通过 Logback 写 INFO / WARN 日志(含 traceId 与 username),日志位置遵循 docs/04 / docs/07;本 REQ 不写额外的 DB 审计表。
  • Redis:docs/04 § 1.6 提及"签发后写 Redis",本 REQ 不实现——签发后不写 Redis,JWT 自包含可独立验证;登出黑名单功能推迟到后续 REQ。pom.xml 不强制要求 redis 依赖(如已加入也不使用)。
  • 并发安全:失败计数 / 锁定写入位于同一事务;MySQL 默认 RR 隔离 + 行锁可避免并发同账号失败计数竞争。
  • 不实现:管理员手动解锁、密码重置、refresh token、登出、多端互踢、Redis 黑名单——全部推迟到后续 REQ。

依赖的 schema 表 / 字段

读 + 写 sys_user(V1 已建):

  • 读:iIncrement, sUsername, sPasswordHash, sUserType, sLanguage, iEmployeeId, iIsDeleted, iFailedLoginCount, tLockUntil
  • 写:iFailedLoginCount, tLockUntil, tLastLoginDate

只读 sys_company(V1 已建):

  • 读:sCompanyCode, iIsDeleted

只读 JOIN sys_employee(V1 已建):

  • 读:sEmployeeName

本 REQ 不需要新增 migration(schema 已就绪)。

依赖的接口

  • 本 REQ 提供:POST /api/v1/auth/login(docs/05 § module_usr)。
  • 外部依赖(前端会调,但本 REQ 范围不实现):GET /api/v1/companies(公司下拉),由后续运营模块或前端阶段使用 fixture 数据替代。

验收标准

后端集成测试(不依赖前端):

  1. 正确凭据 + 启用账号 + 存在公司 → 200,返回 accessToken(非空 JWT),userInfo 字段完整;sys_user.tLastLoginDate 更新、iFailedLoginCount = 0tLockUntil IS NULL
  2. JWT 可验签:用 JWT_SECRET HS256 验签通过;sub = userId 字符串;exp - iat == 7200
  3. 错误密码 N 次(N<5) → 401 / 40101;iFailedLoginCount = NtLockUntil 仍为 NULL。
  4. 第 5 次错误密码 → 401 / 40101;iFailedLoginCount = 5tLockUntil 被写为 NOW() + 30 分钟。
  5. 锁定期间第 6 次(任意密码) → 423 / 42301;返回 data.lockUntil 字段;iFailedLoginCount 不再累加。
  6. 锁定到期后 → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。
  7. 作废账号(iIsDeleted=1)正确密码 → 401 / 40103;iFailedLoginCount 不变。
  8. 用户名不存在 → 401 / 40101(与密码错误同文案)。
  9. 公司不存在 / 已删除 → 400 / 40004,累加失败计数(先于密码校验完成公司校验)。
  10. 请求体缺字段 / 越长 → 400 / 40001。
  11. 响应不回显堆栈:故意制造服务端异常时,响应 message 为通用文案,无 stack trace。
  12. 日志记录:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。