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 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。
输出 / 结果
成功 200:Result<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.iEmployeeIdJOINsys_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 剩余截止时刻 |
业务规则
-
账号查找:以
sUsername全等匹配(大小写敏感,与建表 collationutf8mb4_unicode_ci行为一致,登录时不做规范化)。 -
作废态优先:若用户记录
iIsDeleted = 1,直接返40103,不进入密码校验,不累加iFailedLoginCount。 -
锁定优先:若用户
tLockUntil不为空且大于当前时间,直接返42301,不进入密码校验,不累加。锁定到期(tLockUntil <= NOW())视为已解锁,正常进入密码校验。 -
密码校验:使用 Spring Security
BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——BCryptPasswordEncoder是 Spring Security 默认实现,无额外依赖;strength=10。 -
失败计数:密码不匹配时
sys_user.iFailedLoginCount += 1。- 阈值:累计达到 5 次(含第 5 次)时,同步写
sys_user.tLockUntil = NOW() + 30 分钟;下一次(第 6 次)请求会落到锁定分支返42301。 - 阈值前的失败仍返
40101,不主动告诉客户端剩余尝试次数(防探测)。
- 阈值:累计达到 5 次(含第 5 次)时,同步写
-
登录成功:原子事务(service 层
@Transactional)内:sys_user.iFailedLoginCount = 0sys_user.tLockUntil = NULLsys_user.tLastLoginDate = NOW()- 然后签发 JWT(事务内构造 claim,事务提交后返响应)。
-
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 不消费)
-
-
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 数据替代。
验收标准
后端集成测试(不依赖前端):
-
正确凭据 + 启用账号 + 存在公司 → 200,返回
accessToken(非空 JWT),userInfo字段完整;sys_user.tLastLoginDate更新、iFailedLoginCount = 0、tLockUntil IS NULL。 -
JWT 可验签:用
JWT_SECRETHS256 验签通过;sub=userId字符串;exp - iat == 7200。 -
错误密码 N 次(N<5) → 401 / 40101;
iFailedLoginCount = N;tLockUntil仍为 NULL。 -
第 5 次错误密码 → 401 / 40101;
iFailedLoginCount = 5;tLockUntil被写为 NOW() + 30 分钟。 -
锁定期间第 6 次(任意密码) → 423 / 42301;返回
data.lockUntil字段;iFailedLoginCount不再累加。 - 锁定到期后 → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。
-
作废账号(iIsDeleted=1)正确密码 → 401 / 40103;
iFailedLoginCount不变。 - 用户名不存在 → 401 / 40101(与密码错误同文案)。
- 公司不存在 / 已删除 → 400 / 40004,不累加失败计数(先于密码校验完成公司校验)。
- 请求体缺字段 / 越长 → 400 / 40001。
-
响应不回显堆栈:故意制造服务端异常时,响应
message为通用文案,无 stack trace。 - 日志记录:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。