REQ-USR-004 登录用户 — 实现规格(后端)
阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 SSoT 引用:需求卡片
docs/01-需求清单/USR-用户管理/REQ-USR-004.md;DB 设计docs/03-数据库设计文档.md;API 契约docs/05-API接口契约.md;技术规范docs/04-技术规范.md。 本规格只消费已锁定事实,忽略 UI 描述(登录卡片布局/输入框图标/「版本」下拉控件外观/密码框星号显示),但身份认证规则、凭据校验、令牌签发、最后登录时间更新等业务规则全部下沉到后端 DTO + Service。
1. Goal(目标)
用户通过「用户名 + 密码(+ 版本/公司)」完成身份认证:账号存在、未禁用且密码 BCrypt 比对通过时,签发一枚带过期时间的无状态 JWT 访问令牌并返回用户基础信息;登录成功同步更新该用户的 tLastLoginDate。认证失败时返回不区分「账号不存在 / 密码错误」的统一失败提示以防账号枚举。
对外提供端点:
- 主端点(本 REQ 核心):
POST /api/usr/login(放行,无需 token)。 - 配套只读端点(支撑登录页「版本」下拉,见 § 8 D1):
GET /api/usr/companies(放行,返回可选公司/版本列表)。
「版本」下拉数据来源为
usr_company,需求卡片标注「页面加载时」预加载;docs/05 当前只定义了POST /api/usr/login,未单列公司列表端点。为使登录链路自洽(前端拿不到 companyId 就无法提交登录),本 REQ 在后端补齐GET /api/usr/companies只读端点(见 § 6、§ 8 D1)。
2. 输入 / 输出
2.1 输入(请求)— POST /api/usr/login
-
Method / Path:
POST /api/usr/login - Auth:否(登录端点,Spring Security 放行;不携带 / 不校验 token)。
-
Content-Type:
application/json -
Body →
LoginDTO(com.xly.erp.modules.usr.dto.LoginDTO):
| DTO 字段 | 类型 | 必填 | 校验 | 说明 |
|---|---|---|---|---|
sUserName |
String | 是 |
@NotBlank,@Size(max=50),trim 后非空 |
登录用户名(账号)。缺失 / 空 → 40001
|
password |
String | 是 |
@NotBlank,@Size(max=100)
|
登录密码明文(经 HTTPS 传输),服务端用 BCrypt 与 usr_user.sPassword 比对,绝不落日志、不回显。缺失 / 空 → 40001
|
companyId |
Integer | 是 | @NotNull |
登录「版本」下拉选中的 usr_company.iIncrement。缺失 → 40001。仅做存在性校验,不参与账号-密码认证绑定(见 § 3 规则 6、§ 8 D2) |
不接受 / 忽略的入参:任何用户属性写字段(用户类型 / 语言 / 权限等)、租户列覆盖、token 等本端点均不接收。
2.2 输出(响应)— POST /api/usr/login
- 成功:
Result<LoginVO>,code=0。 -
LoginVO(com.xly.erp.modules.usr.vo.LoginVO,对齐 docs/05 REQ-USR-004 契约,不含sPassword及任何敏感字段):
| VO 字段 | 类型 | 来源 | 说明 |
|---|---|---|---|
token |
String | JWT 签发 |
Bearer 访问令牌,无状态,带过期时间(见 § 3 规则 4、§ 4 配置) |
user |
Object | usr_user |
登录用户基础信息(嵌套对象,见下) |
user.id |
Integer | usr_user.iIncrement |
用户主键 ID |
user.sUserName |
String | usr_user.sUserName |
登录账号 |
user.sUserType |
String | usr_user.sUserType |
用户类型(普通用户 / 超级管理员) |
user.sLanguage |
String | usr_user.sLanguage |
界面语言(中文 / 英文 / 繁体) |
- 失败:统一
Result(见 § 6 错误码),message给可读中文提示,不抛栈、不携带任何区分账号是否存在的细节。
2.3 输入 / 输出 — GET /api/usr/companies(配套只读端点)
-
Method / Path:
GET /api/usr/companies - Auth:否(登录页加载时调用,需在登录前可访问,Spring Security 放行,见 § 8 D1)。
- 请求:无参数(一次性返回全部可登录公司 / 版本;当前数据量小,不分页,见 § 8 D5)。
-
响应:
Result<List<CompanyOptionVO>>,code=0。CompanyOptionVO:
| VO 字段 | 类型 | 来源 | 说明 |
|---|---|---|---|
id |
Integer | usr_company.iIncrement |
公司 / 版本主键,登录时回传为 companyId
|
sCompanyName |
String | usr_company.sCompanyName |
公司名称(下拉显示文本) |
sVersion |
String | usr_company.sVersion |
版本 / 账套标识(可为 null) |
3. 业务规则
-
认证主流程(顺序判定):
- 基础参数校验(
@Valid):sUserName/password/companyId任一缺失或为空 →40001(不进入查库)。 - 按
sUserName精确查usr_user(uk_usr_user_username命中)。未查到用户 →40101(认证失败,统一提示,不暴露「账号不存在」)。 - 命中用户后判
iIsVoid:iIsVoid=1(已禁用 / 作废)→40302(账号已禁用)。详见规则 5 与 § 8 D3 的判定顺序。 - BCrypt 比对
password明文与usr_user.sPassword哈希:不匹配 →40101(认证失败,统一提示,不暴露「密码错误」)。 - 全部通过 → 签发 JWT + 更新
tLastLoginDate+ 返回LoginVO。
- 基础参数校验(
-
防账号枚举:用户不存在与密码错误返回完全相同的错误码
40101与提示文案(如「用户名或密码错误」),调用方无法据响应区分二者(卡片边界要求)。 -
统一失败提示:所有
40101走同一 message,不在日志 / 响应里输出明文密码或「该用户名不存在」类可枚举信息。 -
令牌签发(JWT,无状态):
- 登录成功用
config-vars.yamlsecrets.jwt_secret签发 JWT(docs/04 § 1.7),必须含过期时间(exp claim)。 - claim 至少携带用户标识(subject =
sUserName或user.id)与用户类型sUserType(供后续受保护接口鉴权 / 角色判定,对齐 REQ-USR-001/002 的「仅管理员」约束)。 - 令牌不落库(卡片 / docs/03 业务注记「JWT 为无状态,不落库」)。
- 登录成功用
-
禁用 / 删除用户禁止登录:
iIsVoid=1的用户即使凭据正确也禁止登录,返回40302。本库为软删除模型(无独立「已删除」列,禁用即iIsVoid=1),「已禁用」与「已删除」在本 REQ 统一按iIsVoid=1处理(见 § 8 D4)。 -
companyId 的语义:
companyId为必填且必须能在usr_company找到对应记录(存在性校验),但不参与用户名-密码认证绑定——usr_company与usr_user无外键关系(docs/03「当前仅供登录时选择,不与 usr_user 建强外键」)。companyId不存在于usr_company时按参数非法40001处理(见 § 8 D2)。companyId不写入 JWT 的认证主体,仅作登录上下文(可选放入 claim,非强制)。 -
更新最后登录时间:认证全部通过后,将该用户
tLastLoginDate更新为当前服务器时间(LocalDateTime.now())。该写操作在 Service 内置于@Transactional(rollbackFor = Exception.class),与令牌签发同一逻辑事务(更新失败则整体回滚、登录视为失败,见 § 8 D6)。仅更新tLastLoginDate,不触碰其他列。 -
连续失败限流 / 锁定:卡片「连续登录失败需有锁定或限流策略」。本 REQ 后端阶段采用轻量登录限流作为 MVP 落地(见 § 8 D7):按
sUserName(或来源 IP+用户名)在内存 / 进程级计数器累计连续失败次数,达到阈值后在冷却窗内对该账号的登录请求直接返回42901(请求过于频繁,请稍后重试)。阈值与窗口取默认值(连续失败 5 次、锁定 5 分钟,见 § 4 配置 / § 8 D7);成功登录清零计数。不依赖 Redis(技术栈虽列 Redis,但当前无连接配置,落进程内存即可,后续可平滑替换为 Redis 计数)。 -
密码与敏感字段不返回:
LoginVO不含sPassword;不得SELECT *透传实体到响应;密码明文不进任何日志 / 异常 message。 -
GET /api/usr/companies只读无副作用:仅SELECT usr_company,不写任何表,可标@Transactional(readOnly = true)(可选);返回全部公司(无禁用列,全部视为可选)。
4. 约束(技术 / 安全)
-
分层(docs/04 § 1.2):
UsrAuthController(或在既有UsrUserController上新增login/companies端点,见 § 8 D8,仅@Valid校验 + 委派)→UsrAuthService/UsrAuthServiceImpl(认证 + 令牌签发 + 更新登录时间)→UsrUserMapper/UsrCompanyMapper(MyBatis-Plus)。Controller 禁止直接操作 Mapper、禁止写业务逻辑。 -
包路径:
com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}。新增入参LoginDTO置modules/usr/dto;出参LoginVO/CompanyOptionVO置modules/usr/vo;usr_company实体UsrCompany置modules/usr/entity、UsrCompanyMapper置modules/usr/mapper(若 REQ-USR-001/003 未建则本 REQ 新建,见 § 8 D9)。本 REQ 仅触及modules/usr/**与common/security/**(JWT 工具,若尚未建),不跨业务模块。 -
命名(docs/04 § 1.3):Service 方法
login(LoginDTO dto)、listCompanies();REST 路径POST /api/usr/login、GET /api/usr/companies;DTOLoginDTO,VOLoginVO/CompanyOptionVO。 -
统一响应(docs/04 § 1.4):返回
Result<LoginVO>/Result<List<CompanyOptionVO>>,code=0成功;错误码集中在ResultCode枚举(含40101/40302/40001/42901)。 -
异常处理(docs/04 § 1.5):认证 / 业务错误抛
BusinessException(ResultCode,msg),由GlobalExceptionHandler转Result;@Valid基础校验失败由全局处理器转40001。 -
事务(docs/04 § 1.6):
login含写(更新tLastLoginDate),Service 实现方法加@Transactional(rollbackFor = Exception.class);listCompanies只读。 -
认证 / 安全(docs/04 § 1.7):
- 密码用
BCryptPasswordEncoder比对,禁止明文存取 / 比较。 - JWT 密钥取自
config-vars.yamlsecrets.jwt_secret(映射到application.yml,禁止硬编码进源码 / 日志),令牌带过期时间。 - Spring Security 放行
POST /api/usr/login与GET /api/usr/companies(登录前可访问);其余 USR 写 / 查端点(REQ-001/002/003)仍经JwtAuthenticationFilter校验。
- 密码用
-
数据访问(docs/04 § 3.4):只走 Mapper(MyBatis-Plus
LambdaQueryWrapper/ BaseMapper);按sUserName查用户、按companyId查公司均参数化(#{}/MP 预编译)防注入。不 SELECTsPassword到响应;查认证用户时仅取必要列(含sPassword供比对,但不外泄)。 -
配置(新增,写入
application.yml,值引自config-vars.yaml/ 留默认):-
jwt.secret(引secrets.jwt_secret)、jwt.expiration(令牌有效期,默认 7200 秒 / 2 小时,见 § 8 D10)。 - 登录限流:
auth.login.max-fail(默认 5)、auth.login.lock-seconds(默认 300)。
-
-
schema:本 REQ 仅读
usr_user(认证 + 取sPassword/iIsVoid/sUserType/sLanguage)与usr_company(版本下拉 + companyId 校验),写usr_user.tLastLoginDate(UPDATE,非 DDL)。二表已由sql/migrations/V1__initial_schema.sql建好,结构与 docs/03 一致,无需新增 migration(无字段 / 索引 / 约束变更,见 § 8 D11)。
5. Schema 引用(docs/03 SSoT)
-
读 + 写:
usr_user(主键iIncrement;认证取sUserName(命中uk_usr_user_username)、sPassword(BCrypt 比对,不外泄)、iIsVoid(禁用判定)、sUserType/sLanguage(回LoginVO);登录成功 UPDATEtLastLoginDate)。 -
读:
usr_company(主键iIncrement;GET /api/usr/companies取sCompanyName/sVersion作下拉项;login校验companyId存在性。命中唯一索引uk_usr_company_name(按名)/主键(按 id))。usr_company与usr_user无外键(独立支撑表)。 - 实体:
UsrUser↔usr_user,UsrCompany↔usr_company(匈牙利前缀列名,实体字段与列名映射一致);认证结果装配为LoginVO(非实体直出),公司列表装配为CompanyOptionVO。 - 不涉及
usr_employee/usr_permission/usr_user_permission(登录不读权限 / 职员,权限校验在受保护接口的过滤器侧按 token claim 判定,本 REQ 仅签发 token)。
6. API 引用 / 错误码(docs/05 SSoT)
- 主端点契约见
docs/05-API接口契约.md§ REQ-USR-004(POST /api/usr/login)。 - 配套只读端点
GET /api/usr/companies为本 REQ 自洽补齐(docs/05 未单列,建议反向同步补入契约清单,见 § 8 D1)。 - 错误码:
-
0— 成功。POST /api/usr/login返回LoginVO;GET /api/usr/companies返回List<CompanyOptionVO>。 -
40001— 参数校验失败(缺sUserName/password/companyId,或companyId在usr_company不存在)。 -
40101— 认证失败(用户名或密码错误;不区分以防账号枚举)。 -
40302— 账号已禁用(iIsVoid=1,禁止登录)。 -
42901— 登录过于频繁(连续失败超阈值,账号临时锁定 / 限流,见 § 3 规则 8 / § 8 D7)。 - 401 — 受保护接口无 / 失效 token(由安全过滤器返回,非本端点产生;本登录端点放行)。
-
错误码常量统一登记到
ResultCode枚举(docs/04 § 1.4);42901为本 REQ 引入的限流码(docs/05 REQ-USR-004 未列,限流为卡片「锁定 / 限流策略」要求的落地,建议反向同步补入契约错误码段,见 § 8 D7)。
7. 验收标准(Acceptance Criteria)
-
正确凭据登录成功:以存在、
iIsVoid=0的用户的正确用户名 + 密码 + 合法companyId调用POST /api/usr/login→code=0,返回非空token与user{ id, sUserName, sUserType, sLanguage };响应不含sPassword。 -
令牌可被后续接口接受:步骤 1 返回的
token作为Authorization: Bearer <token>调用任一受保护接口(如GET /api/usr/users)→ 通过 JWT 过滤器(不返回 401)。 -
登录成功更新登录时间:登录成功后再查该用户,
tLastLoginDate已更新为本次登录的时间(≥ 登录前值 / 由 null 变为非 null)。 -
密码错误:正确用户名 + 错误密码 →
code=40101,message 为统一失败提示,不返回 token、不泄露「密码错误」字样。 -
账号不存在:不存在的用户名 →
code=40101,错误码与 message 与「密码错误」完全一致(无法据响应区分账号是否存在)。 -
禁用用户被拒:
iIsVoid=1的用户即使凭据正确 →code=40302(账号已禁用),不签发 token、不更新登录时间。 -
参数缺失:缺
sUserName/ 缺password/ 缺companyId任一 →code=40001,不进入认证。 -
companyId 非法:
companyId不存在于usr_company→code=40001(参数非法)。 -
连续失败限流:对同一账号连续 5 次密码错误后,第 6 次(在锁定窗内)即使密码正确也返回
code=42901(请求过于频繁);冷却窗过后或一次成功登录后计数清零,恢复正常。 -
公司/版本列表:未登录调用
GET /api/usr/companies→code=0,返回usr_company全量项{ id, sCompanyName, sVersion },可供前端「版本」下拉渲染;该端点无需 token 即可访问。 -
密码绝不泄露:任意成功 / 失败响应体均不含
sPassword、不含明文密码;应用日志中不出现登录明文密码。 - JWT 含过期时间:签发的 token 解码后含 exp claim(有限有效期),过期后调用受保护接口被拒(401)。
8. 自主决策记录(decisions)
| # | 问题 | 选择 | 依据 | 置信度 |
|---|---|---|---|---|
| D1 | docs/05 只定义 POST /api/usr/login,但卡片「版本」下拉需在页面加载时从 usr_company 取数,无对应列表端点 |
本 REQ 补齐只读端点 GET /api/usr/companies(放行、无参、返回全量),并建议反向同步补入 docs/05 |
卡片输入表「版本…显示来源=公司表…预加载=页面加载时」+ 卡片「依赖接口=无(…登录版本下拉数据来自 usr_company 基础数据读取)」明确该数据须由后端提供;登录前必须能拿到 companyId 否则登录链路不自洽;端点纯读、放行不触安全约束 | high |
| D2 |
companyId 在认证中的作用 + 非法 companyId 的错误码 |
companyId 必填且做存在性校验,但不参与账号-密码认证绑定(usr_company 与 usr_user 无外键);不存在 → 40001(参数非法) |
docs/03「usr_company…仅供登录时选择,不与 usr_user 建强外键」;卡片「版本=必填」;docs/05 40001 文义含「缺…版本」,非法 companyId 同属参数维度归 40001(区别于认证维度 40101) |
high |
| D3 | 禁用判定与密码比对的先后顺序(先查到用户后,先判 iIsVoid 还是先比密码) |
先比密码再判禁用或先判禁用均可返回正确码,但为防「禁用码 40302 反向暴露账号存在」,本规格采用:查到用户后先 BCrypt 比对密码,密码错 → 40101;密码对再判 iIsVoid=1 → 40302
|
若先返回 40302 而不验证密码,攻击者可用任意密码探测「某账号存在且被禁用」;先验密码再返禁用码,使 40302 仅在凭据正确时出现,最小化枚举面;卡片「禁用用户登录被拒」与「防枚举」二者兼顾 |
medium |
| D4 | 卡片「已删除用户禁止登录」但 docs/03 无独立「已删除」列 | 「已禁用」与「已删除」在本 REQ 统一按 iIsVoid=1 判定(软删除模型),均返回 40302
|
docs/03 usr_user 仅有 iIsVoid(作废/禁用),业务注记「iIsVoid=1 表示禁用,禁止登录」;无 hard-delete 字段,作废即逻辑删除,二者同义 |
high |
| D5 |
GET /api/usr/companies 是否分页 |
不分页,一次返回全量 | 公司/版本为低基数基础数据(账套数量极少),下拉一次性加载更简单;卡片标「预加载」即一次取全 | high |
| D6 | 更新 tLastLoginDate 失败是否回滚登录 |
置于同一 @Transactional,更新失败则整体回滚、登录返回失败 |
docs/04 § 1.6 写操作加事务;登录成功的副作用(记录登录时间)应原子,避免「签发了 token 但登录时间未记」的不一致;实现简单可靠 | medium |
| D7 | 卡片「连续失败锁定/限流」的后端落地形态与阈值 | MVP 采用进程内(内存)按用户名计数的轻量限流:连续失败 5 次 → 锁定 300 秒,期间返回新错误码 42901;成功登录清零;阈值/窗口写入配置项 auth.login.max-fail / auth.login.lock-seconds
|
卡片明确要求「锁定或限流策略」,必须落地某机制;技术栈列了 Redis 但 config-vars.yaml 无 Redis 连接(redis_password 为注释占位),故先用进程内计数,接口/配置预留后续平滑替换 Redis;阈值取通用安全默认(5 次/5 分钟);新增 42901 限流码(docs/05 未列,建议反向补入) |
medium |
| D8 | 登录端点放在新 Controller 还是既有 UsrUserController | 倾向新建 UsrAuthController(认证语义独立于用户 CRUD),亦可挂既有 UsrUserController;二者均满足分层约束,实现期择一 |
docs/04 § 1.2 示例 Controller 为 UsrUserController(CRUD),登录属认证关注点,单列 UsrAuthController 更内聚;非硬约束,故标为实现期可调 |
medium |
| D9 |
UsrCompany 实体 / UsrCompanyMapper 是否已存在 |
若 REQ-USR-001/003 未建则本 REQ 新建(属 modules/usr 内,非跨模块) |
REQ-001/003 spec 仅涉及 usr_user / usr_employee,未建 usr_company 访问层;本 REQ 首次读 usr_company,需补实体 + Mapper,仍在 USR 模块内 |
high |
| D10 | JWT 过期时间默认值 | 默认 7200 秒(2 小时),写入 application.yml jwt.expiration,可配 |
docs/04 § 1.7 / docs/05 仅要求「有过期时间」未给具体值;2 小时为后台管理系统常见会话时长的稳妥默认;落配置便于调整 | medium |
| D11 | 是否新增 migration | 不新增,复用 V1__initial_schema.sql
|
本 REQ 仅读 usr_user / usr_company 并 UPDATE usr_user.tLastLoginDate(DML 非 DDL),无字段/索引/约束变更,二表已在 V1 建好且结构与 docs/03 一致 |
high |
注:docs/03 中
sLanguage/usr_company.sVersion/usr_permission粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本登录 REQ 作用域内消解;本规格沿用 USR 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),sVersion仅作下拉展示字段直透,不阻塞。 反向同步建议(非本 stage 强制):实现期可将GET /api/usr/companies端点与42901限流错误码补入docs/05-API接口契约.md§ REQ-USR-004,保持契约 SSoT 完整。