req_id: REQ-USR-004 date: 2026-05-08
module: module_usr
Spec: REQ-USR-004 — 用户登录
目标
用户在登录页选择所属公司(brand)、输入用户名和密码,完成身份认证后获取 JWT Access Token 及 Refresh Token,用于后续所有接口鉴权。多租户隔离:每次登录的用户查询限定在所选 brand 的 sBrandsId 范围内。
输入 / 触发
POST /api/auth/login(公开接口,无需 Bearer Token)
{
"brandNo": "string", // brand.sNo,前端版本下拉选中项
"username": "string", // usr_user.sUsername
"password": "string" // 明文密码(HTTPS 传输),后端用 BCrypt 校验
}
辅助:版本下拉数据
前端需预先调用 GET /api/auth/brands 获取 brand 列表(sNo / sName),填充版本下拉框。默认选中 sName = "标准版" 对应的项(若无则选第一项)。此接口亦无需鉴权。
POST /api/auth/refresh(无需 Bearer Token,需 Refresh Token)
{ "refreshToken": "string" }
输出 / 结果
登录成功(HTTP 200)
{
"code": 200,
"message": "登录成功",
"data": {
"accessToken": "<jwt>",
"refreshToken": "<jwt>",
"expiresIn": 86400,
"userInfo": {
"userId": "string", // usr_user.sId
"username": "string", // usr_user.sUsername
"userType": "string", // 普通用户 | 超级管理员
"language": "string", // 中文 | 英文 | 繁体
"brandId": "string" // brand.sId,写入 token claims 用于后续多租户隔离
}
}
}
失败(业务错误,HTTP 200 + code ≠ 0)
| code | message | 场景 |
|---|---|---|
| 40100 | 用户名或密码错误 | 密码错误 / 用户不存在 / brand 不存在(统一文案,防枚举) |
| 40101 | 账号已被禁用,请联系管理员 | bIsDisabled = 1 |
| 40102 | 账号已被锁定,请 {N} 分钟后重试 | tLockUntil > NOW() |
| 40103 | Refresh Token 已失效,请重新登录 | refresh 接口 token 过期或篡改 |
业务规则
多租户查询:先
SELECT * FROM brand WHERE sNo = ?得brandId = brand.sId;再SELECT * FROM usr_user WHERE sUsername = ? AND sBrandsId = ?(brandId)。brand 不存在或用户不存在统一返回 40100(防枚举)。密码校验:
BCryptPasswordEncoder.matches(plainPassword, usr_user.sPasswordHash)。禁用检查:
bIsDisabled = 1→ 返回 40101,不更新失败计数。锁定检查:
tLockUntil IS NOT NULL AND tLockUntil > NOW()→ 返回 40102,剩余分钟数 =CEIL((tLockUntil - NOW()) / 60)。失败计数:密码错误时
iLoginFailCount += 1;达到 5 次时设tLockUntil = NOW() + 30 分钟,同时返回 40102。-
登录成功:
- 重置
iLoginFailCount = 0,tLockUntil = NULL - 更新
tLastLoginDate = NOW() - 签发 Access Token(24h)+ Refresh Token(7d),均为 JWT,签名密钥来自
JWT_SECRET(从.env.local注入,application.yml中${JWT_SECRET})
- 重置
-
JWT Claims:
- Access Token:
sub = usr_user.sId,username,userType,brandId = brand.sId,exp = now + 24h - Refresh Token:
sub = usr_user.sId,brandId,type = refresh,exp = now + 7d
- Access Token:
Token 刷新(POST /api/auth/refresh):校验 Refresh Token 签名与
type=refresh;从 claims 读sub(userId)和brandId重新查库确认用户仍有效;签发新 Access Token(24h);Refresh Token 不续期(滑动窗口留给后续迭代)。版本下拉(GET /api/auth/brands):
SELECT sNo, sName FROM brand ORDER BY sName;无需分页(品牌数量通常极少)。
边界与约束
- 所有接口均走 HTTPS(Nginx 层保障,后端不强制)
- 登录日志(IP + 时间戳)通过 Spring Security 的
AuthenticationSuccessHandler/AuthenticationFailureHandler写 Logback,不写业务表 - Refresh Token 无服务端存储(无状态 JWT);强制退出由后续迭代(引入 token 黑名单)实现;本期用 7 天过期控制
- 密码明文绝不落库,
sPasswordHash仅存 BCrypt 60 字符哈希 - Access Token 不含密码 hash,不含任何敏感字段
- 防暴力破解:5 次失败锁 30 分钟(数据库层,不依赖内存,重启后有效)
-
GET /api/auth/brands结果可加短缓存(30s),减少登录页加载压力
依赖的 schema 表 / 字段
usr_user(主要读写字段)
| 字段 | 操作 | 说明 |
|---|---|---|
sUsername |
READ | 登录名匹配 |
sBrandsId |
READ | 多租户范围限定 |
sPasswordHash |
READ | BCrypt 校验 |
bIsDisabled |
READ | 禁用检查 |
tLockUntil |
READ / WRITE | 锁定截止时间 |
iLoginFailCount |
READ / WRITE | 连续失败次数 |
tLastLoginDate |
WRITE | 成功后更新 |
sId |
READ | 写入 JWT sub |
sUserType |
READ | 写入 JWT claims |
sLanguage |
READ | 返回 userInfo |
brand
| 字段 | 操作 | 说明 |
|---|---|---|
sNo |
READ | 版本下拉匹配键 |
sId |
READ | 写入 JWT brandId claim |
sName |
READ | 版本下拉显示名 |
依赖的接口
- 自身即认证入口,无上游接口依赖
- 对外暴露:
POST /api/auth/login、POST /api/auth/refresh、GET /api/auth/brands(均无鉴权)
验收标准
- 正确 brandNo + username + password → HTTP 200,返回有效 accessToken,
POST /api/usr/users携带该 token 鉴权通过 - 错误密码 → code=40100,且不暴露是用户名还是密码错了
- 连续 5 次错误密码 → 第 5 次返回 code=40102(含剩余分钟数),后续在锁定期内再请求仍返回 40102
- 锁定 30 分钟后自动解锁,正确凭据可再次登录
-
bIsDisabled = 1的账号登录 → code=40101 - brand.sNo 不存在 → code=40100(与密码错误同一文案)
- 用同一账号、不同 brandId 登录 → 互不干扰(多租户隔离)
- 有效 refreshToken → POST /api/auth/refresh 返回新 accessToken
- 过期 / 伪造 refreshToken → code=40103
- GET /api/auth/brands 返回所有 brand 列表(sNo + sName)