2026-06-01-REQ-USR-004.md 21.2 KB

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 / PathPOST /api/usr/login
  • Auth:否(登录端点,Spring Security 放行;不携带 / 不校验 token)。
  • Content-Typeapplication/json
  • Body → LoginDTOcom.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
  • LoginVOcom.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 / PathGET /api/usr/companies
  • Auth:否(登录页加载时调用,需在登录前可访问,Spring Security 放行,见 § 8 D1)。
  • 请求:无参数(一次性返回全部可登录公司 / 版本;当前数据量小,不分页,见 § 8 D5)。
  • 响应Result<List<CompanyOptionVO>>code=0CompanyOptionVO
VO 字段 类型 来源 说明
id Integer usr_company.iIncrement 公司 / 版本主键,登录时回传为 companyId
sCompanyName String usr_company.sCompanyName 公司名称(下拉显示文本)
sVersion String usr_company.sVersion 版本 / 账套标识(可为 null)

3. 业务规则

  1. 认证主流程(顺序判定):
    1. 基础参数校验(@Valid):sUserName / password / companyId 任一缺失或为空 → 40001(不进入查库)。
    2. sUserName 精确查 usr_useruk_usr_user_username 命中)。未查到用户40101(认证失败,统一提示,不暴露「账号不存在」)。
    3. 命中用户后判 iIsVoidiIsVoid=1(已禁用 / 作废)→ 40302(账号已禁用)。详见规则 5 与 § 8 D3 的判定顺序。
    4. BCrypt 比对 password 明文与 usr_user.sPassword 哈希:不匹配40101(认证失败,统一提示,不暴露「密码错误」)。
    5. 全部通过 → 签发 JWT + 更新 tLastLoginDate + 返回 LoginVO
  2. 防账号枚举:用户不存在与密码错误返回完全相同的错误码 40101 与提示文案(如「用户名或密码错误」),调用方无法据响应区分二者(卡片边界要求)。
  3. 统一失败提示:所有 40101 走同一 message,不在日志 / 响应里输出明文密码或「该用户名不存在」类可枚举信息。
  4. 令牌签发(JWT,无状态)
    • 登录成功用 config-vars.yaml secrets.jwt_secret 签发 JWT(docs/04 § 1.7),必须含过期时间(exp claim)。
    • claim 至少携带用户标识(subject = sUserNameuser.id)与用户类型 sUserType(供后续受保护接口鉴权 / 角色判定,对齐 REQ-USR-001/002 的「仅管理员」约束)。
    • 令牌不落库(卡片 / docs/03 业务注记「JWT 为无状态,不落库」)。
  5. 禁用 / 删除用户禁止登录iIsVoid=1 的用户即使凭据正确也禁止登录,返回 40302。本库为软删除模型(无独立「已删除」列,禁用即 iIsVoid=1),「已禁用」与「已删除」在本 REQ 统一按 iIsVoid=1 处理(见 § 8 D4)。
  6. companyId 的语义companyId 为必填且必须能在 usr_company 找到对应记录(存在性校验),但不参与用户名-密码认证绑定——usr_companyusr_user 无外键关系(docs/03「当前仅供登录时选择,不与 usr_user 建强外键」)。companyId 不存在于 usr_company 时按参数非法 40001 处理(见 § 8 D2)。companyId 不写入 JWT 的认证主体,仅作登录上下文(可选放入 claim,非强制)。
  7. 更新最后登录时间:认证全部通过后,将该用户 tLastLoginDate 更新为当前服务器时间(LocalDateTime.now())。该写操作在 Service 内置于 @Transactional(rollbackFor = Exception.class),与令牌签发同一逻辑事务(更新失败则整体回滚、登录视为失败,见 § 8 D6)。仅更新 tLastLoginDate,不触碰其他列。
  8. 连续失败限流 / 锁定:卡片「连续登录失败需有锁定或限流策略」。本 REQ 后端阶段采用轻量登录限流作为 MVP 落地(见 § 8 D7):按 sUserName(或来源 IP+用户名)在内存 / 进程级计数器累计连续失败次数,达到阈值后在冷却窗内对该账号的登录请求直接返回 42901(请求过于频繁,请稍后重试)。阈值与窗口取默认值(连续失败 5 次、锁定 5 分钟,见 § 4 配置 / § 8 D7);成功登录清零计数。不依赖 Redis(技术栈虽列 Redis,但当前无连接配置,落进程内存即可,后续可平滑替换为 Redis 计数)。
  9. 密码与敏感字段不返回LoginVO 不含 sPassword;不得 SELECT * 透传实体到响应;密码明文不进任何日志 / 异常 message。
  10. 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}。新增入参 LoginDTOmodules/usr/dto;出参 LoginVO / CompanyOptionVOmodules/usr/vousr_company 实体 UsrCompanymodules/usr/entityUsrCompanyMappermodules/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/loginGET /api/usr/companies;DTO LoginDTO,VO LoginVO / CompanyOptionVO
  • 统一响应(docs/04 § 1.4):返回 Result<LoginVO> / Result<List<CompanyOptionVO>>code=0 成功;错误码集中在 ResultCode 枚举(含 40101 / 40302 / 40001 / 42901)。
  • 异常处理(docs/04 § 1.5):认证 / 业务错误抛 BusinessException(ResultCode,msg),由 GlobalExceptionHandlerResult@Valid 基础校验失败由全局处理器转 40001
  • 事务(docs/04 § 1.6):login 含写(更新 tLastLoginDate),Service 实现方法加 @Transactional(rollbackFor = Exception.class)listCompanies 只读。
  • 认证 / 安全(docs/04 § 1.7):
    • 密码用 BCryptPasswordEncoder 比对,禁止明文存取 / 比较。
    • JWT 密钥取自 config-vars.yaml secrets.jwt_secret(映射到 application.yml,禁止硬编码进源码 / 日志),令牌带过期时间。
    • Spring Security 放行 POST /api/usr/loginGET /api/usr/companies(登录前可访问);其余 USR 写 / 查端点(REQ-001/002/003)仍经 JwtAuthenticationFilter 校验。
  • 数据访问(docs/04 § 3.4):只走 Mapper(MyBatis-Plus LambdaQueryWrapper / BaseMapper);按 sUserName 查用户、按 companyId 查公司均参数化(#{}/MP 预编译)防注入。不 SELECT sPassword 到响应;查认证用户时仅取必要列(含 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);登录成功 UPDATE tLastLoginDate)。
  • usr_company(主键 iIncrementGET /api/usr/companiessCompanyName / sVersion 作下拉项;login 校验 companyId 存在性。命中唯一索引 uk_usr_company_name(按名)/主键(按 id))。usr_companyusr_user 无外键(独立支撑表)。
  • 实体:UsrUserusr_userUsrCompanyusr_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 返回 LoginVOGET /api/usr/companies 返回 List<CompanyOptionVO>
    • 40001 — 参数校验失败(缺 sUserName / password / companyId,或 companyIdusr_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)

  1. 正确凭据登录成功:以存在、iIsVoid=0 的用户的正确用户名 + 密码 + 合法 companyId 调用 POST /api/usr/logincode=0,返回非空 tokenuser{ id, sUserName, sUserType, sLanguage };响应不含 sPassword
  2. 令牌可被后续接口接受:步骤 1 返回的 token 作为 Authorization: Bearer <token> 调用任一受保护接口(如 GET /api/usr/users)→ 通过 JWT 过滤器(不返回 401)。
  3. 登录成功更新登录时间:登录成功后再查该用户,tLastLoginDate 已更新为本次登录的时间(≥ 登录前值 / 由 null 变为非 null)。
  4. 密码错误:正确用户名 + 错误密码 → code=40101,message 为统一失败提示,返回 token、泄露「密码错误」字样。
  5. 账号不存在:不存在的用户名 → code=40101,错误码与 message 与「密码错误」完全一致(无法据响应区分账号是否存在)。
  6. 禁用用户被拒iIsVoid=1 的用户即使凭据正确 → code=40302(账号已禁用),不签发 token、不更新登录时间。
  7. 参数缺失:缺 sUserName / 缺 password / 缺 companyId 任一 → code=40001,不进入认证。
  8. companyId 非法companyId 不存在于 usr_companycode=40001(参数非法)。
  9. 连续失败限流:对同一账号连续 5 次密码错误后,第 6 次(在锁定窗内)即使密码正确也返回 code=42901(请求过于频繁);冷却窗过后或一次成功登录后计数清零,恢复正常。
  10. 公司/版本列表:未登录调用 GET /api/usr/companiescode=0,返回 usr_company 全量项 { id, sCompanyName, sVersion },可供前端「版本」下拉渲染;该端点无需 token 即可访问。
  11. 密码绝不泄露:任意成功 / 失败响应体均不含 sPassword、不含明文密码;应用日志中不出现登录明文密码。
  12. 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=140302 若先返回 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 完整。