diff --git a/docs/superpowers/specs/2026-06-01-REQ-USR-004.md b/docs/superpowers/specs/2026-06-01-REQ-USR-004.md new file mode 100644 index 0000000..5a56591 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-REQ-USR-004.md @@ -0,0 +1,172 @@ +# 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`,`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>`,`code=0`。`CompanyOptionVO`: + +| 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_user`(`uk_usr_user_username` 命中)。**未查到用户** → `40101`(认证失败,统一提示,不暴露「账号不存在」)。 + 3. 命中用户后判 `iIsVoid`:`iIsVoid=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 = `sUserName` 或 `user.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_company` 与 `usr_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}`。新增入参 `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`;DTO `LoginDTO`,VO `LoginVO` / `CompanyOptionVO`。 +- **统一响应**(docs/04 § 1.4):返回 `Result` / `Result>`,`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.yaml` `secrets.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 预编译)防注入。**不 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`(主键 `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`。 + - `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) + +1. **正确凭据登录成功**:以存在、`iIsVoid=0` 的用户的正确用户名 + 密码 + 合法 `companyId` 调用 `POST /api/usr/login` → `code=0`,返回非空 `token` 与 `user{ id, sUserName, sUserType, sLanguage }`;响应**不含** `sPassword`。 +2. **令牌可被后续接口接受**:步骤 1 返回的 `token` 作为 `Authorization: Bearer ` 调用任一受保护接口(如 `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_company` → `code=40001`(参数非法)。 +9. **连续失败限流**:对同一账号连续 5 次密码错误后,第 6 次(在锁定窗内)即使密码正确也返回 `code=42901`(请求过于频繁);冷却窗过后或一次成功登录后计数清零,恢复正常。 +10. **公司/版本列表**:未登录调用 `GET /api/usr/companies` → `code=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=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 完整。