--- 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 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。 ## 输出 / 结果 **成功 200**:`Result`,其中 `LoginVo`: ```json { "accessToken": "", "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 = 0`、`sys_user.tLockUntil = NULL`、`sys_user.tLastLoginDate = NOW()`。 **失败**:见错误码段,全部走全局异常处理器映射为 `Result.fail(code, message)`。错误码段位与 docs/05 一致: | HTTP | code | 含义 | 触发条件 | |---|---|---|---| | 400 | 40001 | 用户名或密码格式错误 | 必填字段缺失 / 类型错 / 长度越界 | | 400 | 40004 | 公司不存在或已删除 | `companyCode` 在 `sys_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.local` 的 `JWT_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 = 0`、`tLockUntil IS NULL`。 2. **JWT 可验签**:用 `JWT_SECRET` HS256 验签通过;`sub` = `userId` 字符串;`exp - iat == 7200`。 3. **错误密码 N 次(N<5)** → 401 / 40101;`iFailedLoginCount = N`;`tLockUntil` 仍为 NULL。 4. **第 5 次错误密码** → 401 / 40101;`iFailedLoginCount = 5`;`tLockUntil` 被写为 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 抽样验证即可)。