# REQ-USR-004 登录用户 — 任务级 TDD 计划(后端) > 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / DTO / VO / entity / config / 公共响应 / 安全 / REST 契约实现)。**禁止**写 `frontend/**`。 > 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-004.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-004.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 > 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 > 本 REQ 复用 REQ-USR-001/002/003 已建的 `modules/usr/**`(`UsrUser` 实体、`UsrUserMapper extends BaseMapper`、`UsrUserController`)与 `common/**`(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `SecurityUtil` / `JwtUtil` / `JwtAuthenticationFilter` / `SecurityConfig` / `PasswordEncoder` Bean);**只读** `usr_user` / `usr_company` + **UPDATE** `usr_user.tLastLoginDate`(DML 非 DDL),**不新增 migration**(spec § 4 / § 8 D11,二表已在 `V1__initial_schema.sql` 建好且结构与 docs/03 一致)。 --- ## Goal(目标) 实现登录认证主端点 `POST /api/usr/login`(放行,无需 token):接收 `LoginDTO`(`sUserName` / `password` / `companyId`,三者必填),按「基础参数校验 → 查用户 → BCrypt 比对密码 → 判禁用 → companyId 存在性校验 → 签发 JWT + 更新 `tLastLoginDate`」流程认证,成功返回 `Result`(`code=0`,含 `token` 与 `user{ id, sUserName, sUserType, sLanguage }`,**绝不含 `sPassword`**)。配套补齐只读端点 `GET /api/usr/companies`(放行,无参,返回 `Result>` 供登录页「版本」下拉)。 防账号枚举:用户不存在与密码错误返回**完全相同**的 `40101`(统一文案);禁用用户 → `40302`;参数缺失或 `companyId` 在 `usr_company` 不存在 → `40001`;同一账号连续失败超阈值在冷却窗内 → `42901`(登录过于频繁)。登录成功在同一事务内更新 `tLastLoginDate` 并清零失败计数;JWT 含过期时间(exp claim),令牌可被既有 `JwtAuthenticationFilter` 接受。 ## Architecture(架构 / 分层) 遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与 `common/**`(错误码枚举 + 安全放行配置),不跨业务模块: ``` backend/src/main/java/com/xly/erp/ ├── common/response/ResultCode.java # 既有枚举,新增 42901 限流码(40101/40302/40001 已存在) ├── common/config/SecurityConfig.java # 既有,放行清单追加 GET /api/usr/companies(POST /api/usr/login 已放行) ├── modules/usr/ │ ├── controller/UsrAuthController.java # 【本 REQ 新增】认证 Controller:POST /api/usr/login + GET /api/usr/companies;仅 @Valid + 委派(spec § 8 D8) │ ├── service/UsrAuthService.java # 【本 REQ 新增】认证服务接口:login / listCompanies │ ├── service/impl/UsrAuthServiceImpl.java # 【本 REQ 新增】认证实现(查用户→BCrypt→判禁用→companyId 校验→签发 JWT→更新登录时间;限流计数) │ ├── mapper/UsrCompanyMapper.java # 【本 REQ 新增】UsrCompany Mapper(继承 BaseMapper,spec § 8 D9) │ ├── mapper/UsrUserMapper.java # 既有,复用 BaseMapper 的 selectOne/updateById(无需改) │ ├── entity/UsrCompany.java # 【本 REQ 新增】usr_company 实体(spec § 8 D9) │ ├── entity/UsrUser.java # 既有,只读 + UPDATE tLastLoginDate(无需改) │ ├── dto/LoginDTO.java # 【本 REQ 新增】登录入参 │ └── vo/{LoginVO,CompanyOptionVO}.java # 【本 REQ 新增】登录输出 + 公司下拉项输出(不含 sPassword) ``` - **跨模块**:无。本 REQ 落在 `modules/usr/**` + 改 `common/response/ResultCode.java`(新增一枚错误码常量)+ 改 `common/config/SecurityConfig.java`(放行清单加一条)。后两者属公共区改动,需在《模块完成报告》留痕「新增 `42901` 限流错误码 + 放行 `GET /api/usr/companies`」(CLAUDE.md 跨模块改动留痕要求)。 - **数据访问**:只走 Mapper(MyBatis-Plus)。按 `sUserName` 精确查用户用 `UsrUserMapper.selectOne(LambdaQueryWrapper.eq(UsrUser::getSUserName, ...))`;更新登录时间用 `UsrUserMapper.updateById`(仅 set `iIncrement` + `tLastLoginDate`,依赖 MP「null 字段不参与 SET」语义,不触碰其他列);按 `companyId` 校验存在用 `UsrCompanyMapper.selectById`;公司列表用 `UsrCompanyMapper.selectList(null)`。全部参数化(`#{}` / MP 预编译)防注入。**不 SELECT `sPassword` 到响应**(实体查出含 `sPassword` 供比对,但 `UsrUser.sPassword` 已标 `@JsonIgnore` 且 `LoginVO` 不含该字段,绝不外泄)。简单 CRUD,无需自定义 XML。 - **事务**:`login` 含写(UPDATE `tLastLoginDate`),`UsrAuthServiceImpl#login` 标 `@Transactional(rollbackFor = Exception.class)`(spec § 4 / § 8 D6,更新失败整体回滚、登录视为失败);`listCompanies` 标 `@Transactional(readOnly = true)`。 - **安全**:`SecurityConfig` 放行 `POST /api/usr/login`(已放行)+ `GET /api/usr/companies`(本 REQ 追加),登录前可访问;其余受保护接口仍走既有 `JwtAuthenticationFilter`。密码用既有 `BCryptPasswordEncoder` Bean 比对,禁止明文。JWT 用既有 `JwtUtil.generateToken(userName, userType)` 签发(含 exp claim),密钥来自 `application.yml` `jwt.secret`(引 config-vars `secrets.jwt_secret`),不硬编码。 - **限流**:进程内(内存)按 `sUserName` 计数的轻量限流(spec § 3 规则 8 / § 8 D7),不依赖 Redis;阈值 / 窗口读 `application.yml` `auth.login.max-fail`(默认 5)/ `auth.login.lock-seconds`(默认 300)。计数器封装在 `UsrAuthServiceImpl` 内部(`ConcurrentHashMap` 持有 `用户名→{连续失败次数, 锁定到期时间戳}`),成功登录清零;线程安全用并发容器即可(单实例 MVP)。 ## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) - Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(BaseMapper + `LambdaQueryWrapper`,简单 CRUD 无需 XML);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / § 8 D11)。 - Spring Security + JWT(既有 `JwtUtil` / `JwtAuthenticationFilter` / `SecurityUtil` / `SecurityConfig` / `PasswordEncoder` Bean);`POST /api/usr/login` 与 `GET /api/usr/companies` 放行。 - 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥 / 限流阈值只读 `config-vars.yaml` / `application.yml`,不硬编码。 - 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 ## 合同级常量(跨 task 必须一致) - REST:`POST /api/usr/login`(JSON body 绑定 `LoginDTO`,`@RequestBody`);`GET /api/usr/companies`(无参)。二者均放行(无需 token)。 - 错误码(`ResultCode` 枚举,spec § 6 / docs/05;`SUCCESS=0` / `PARAM_INVALID=40001` / `UNAUTHORIZED=40101` / `ACCOUNT_DISABLED=40302` 既有已存在,**本 REQ 仅新增 `LOGIN_RATE_LIMITED=42901`**): - `SUCCESS=0` — 成功。`login` → `LoginVO`;`companies` → `List`。 - `PARAM_INVALID=40001` — 参数校验失败(缺 `sUserName` / `password` / `companyId`,或 `companyId` 在 `usr_company` 不存在)。 - `UNAUTHORIZED=40101` — 认证失败(用户名或密码错误;**不区分**以防账号枚举,统一文案)。 - `ACCOUNT_DISABLED=40302` — 账号已禁用(`iIsVoid=1`,禁止登录)。 - `LOGIN_RATE_LIMITED=42901` — 登录过于频繁(连续失败超阈值,账号临时锁定,spec § 3 规则 8 / § 8 D7)。**新增常量**,message 建议「请求过于频繁,请稍后重试」。 - 401 — 受保护接口无 / 失效 token(由安全过滤器返回,非本端点产生;本登录端点放行)。 - 统一失败文案(spec § 3 规则 2/3):`40101` 走单一 message「用户名或密码错误」——用户不存在与密码错误**用同一码同一文案**,不暴露「账号不存在」/「密码错误」字样。可直接用 `BusinessException(ResultCode.UNAUTHORIZED)`(默认 message 为枚举的「认证失败」),或显式传统一文案;**两条失败路径必须传同一 message**,由 T4 测试固化「两路径响应体逐字一致」。 - 认证判定顺序(spec § 3 规则 1 / § 8 D3,**固定**):①基础参数校验(`@Valid`,缺失 → `40001`)→ ②按 `sUserName` 查 `usr_user`,未查到 → `40101` → ③**先 BCrypt 比对密码**,不匹配 → `40101` → ④再判 `iIsVoid=1` → `40302`(先验密码再返禁用码,最小化枚举面,spec § 8 D3)→ ⑤`companyId` 存在性校验(不存在 → `40001`)→ ⑥签发 JWT + 更新 `tLastLoginDate` + 清零失败计数 + 返回 `LoginVO`。限流判定(`42901`)置于流程**最前**(查用户前即查锁定窗,命中锁定窗直接 `42901`,spec 验收 9:锁定窗内即使密码正确也 `42901`)。 - JWT:复用既有 `JwtUtil.generateToken(String userName, String userType)`,subject = `sUserName`、claim 含 `sUserName`(`JwtUtil.CLAIM_USER_NAME`)+ `sUserType`(`JwtUtil.CLAIM_USER_TYPE`)+ exp(既有实现已带 `expiration`);返回的 `token` **不**加 `Bearer ` 前缀(前端按契约自行拼 `Authorization: Bearer `,与 `UsrUserCreateIT` 中 `"Bearer " + jwtUtil.generateToken(...)` 的用法一致)。`companyId` 不写入 JWT 认证主体(spec § 3 规则 6)。 - 限流配置键(`application.yml`,spec § 4 / § 8 D7):`auth.login.max-fail`(默认 5,达到该连续失败次数后锁定)、`auth.login.lock-seconds`(默认 300,锁定窗秒数)。`UsrAuthServiceImpl` 用 `@Value("${auth.login.max-fail:5}")` / `@Value("${auth.login.lock-seconds:300}")` 注入。 - 软删除口径(spec § 3 规则 5 / § 8 D4):本库无独立「已删除」列,「已禁用」与「已删除」统一按 `iIsVoid=1` 处理,均 `40302`。 - 敏感字段(spec § 3 规则 9 / 验收 11):`LoginVO` / `CompanyOptionVO` 均不含 `sPassword`;不 `SELECT *` 透传实体到响应;密码明文不进任何日志 / 异常 message。 ## 关键签名(首次出现处给出,跨 task 保持一致) - `UsrAuthService#login(LoginDTO dto)` 返回 `LoginVO`。 - `UsrAuthService#listCompanies()` 返回 `List`。 - `UsrAuthController#login(@Valid @RequestBody LoginDTO dto)` 返回 `Result`。 - `UsrAuthController#listCompanies()` 返回 `Result>`。 - `UsrCompanyMapper extends BaseMapper`(无自定义方法,复用 `selectById` / `selectList`)。 - 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` / `BusinessException(ResultCode, String)`(暴露 `getResultCode()`);`JwtUtil.generateToken(userName, userType)`;`PasswordEncoder.matches(raw, encoded)`;`UsrUserMapper extends BaseMapper`(`selectOne` / `updateById`);实体 getter 匈牙利前缀(`getSUserName` / `getSPassword` / `getIIsVoid` / `getSUserType` / `getSLanguage` / `getIIncrement` / `setTLastLoginDate`)。 ### `LoginDTO` 形状(`modules/usr/dto`,JSON body) > 字段带匈牙利前缀者(`sUserName`)的 getter `getSUserName` 会被 Jackson/反序列化推断为属性 `SUserName`,与契约 JSON 键 `sUserName` 不符——**加 `@JsonProperty("sUserName")`**(与 `CreateUserDTO`/`UserVO` 同做法)锁定反序列化键名;`password` / `companyId` 为常规小驼峰,无需 `@JsonProperty`。 | 字段 | 类型 | 校验注解 | 语义 | |---|---|---|---| | `sUserName` | String | `@NotBlank`,`@Size(max=50)` | 登录用户名;缺失 / 空 → `40001`。建议 Service 内 trim 后用于查库 | | `password` | String | `@NotBlank`,`@Size(max=100)` | 登录密码明文;缺失 / 空 → `40001`;服务端 BCrypt 比对,**绝不落日志 / 回显** | | `companyId` | Integer | `@NotNull` | 「版本」下拉选中的 `usr_company.iIncrement`;缺失 → `40001`;仅存在性校验,不参与认证绑定 | > 提供 getter/setter;`@NotBlank`/`@NotNull`/`@Size` 失败由既有 `GlobalExceptionHandler` 统一转 `40001`。 ### `LoginVO` 形状(`modules/usr/vo`,spec § 2.2,**不含 `sPassword` / 租户列**) | VO 字段 | 类型 | 来源 | |---|---|---| | `token` | String | `JwtUtil.generateToken` 签发(不含 `Bearer ` 前缀) | | `user` | `LoginVO.UserInfo`(嵌套对象) | `usr_user` 基础信息 | `LoginVO.UserInfo`(嵌套静态类或独立 VO,二选一,保持 JSON 嵌套形状 `user{...}`): | 字段 | 类型 | 来源列 | |---|---|---| | `id` | Integer | `usr_user.iIncrement` | | `sUserName` | String | `usr_user.sUserName` | | `sUserType` | String | `usr_user.sUserType` | | `sLanguage` | String | `usr_user.sLanguage` | > `UserInfo` 中匈牙利前缀字段(`sUserName`/`sUserType`/`sLanguage`)的 getter(`getSUserName` 等)会被 Jackson 推断为 `SUserName` 等大驼峰,与契约键不符——加 `@JsonProperty("sUserName")` 等锁键(与 `UserVO` 同做法);`id` / `token` / `user` 为常规驼峰无需注解。`LoginVO` 提供全字段 getter/setter,`implements Serializable`(与 `Result` / `UserVO` 一致)。**严格不含 `sPassword` / `password` / 租户列**。 ### `CompanyOptionVO` 形状(`modules/usr/vo`,spec § 2.3) | VO 字段 | 类型 | 来源列 | |---|---|---| | `id` | Integer | `usr_company.iIncrement` | | `sCompanyName` | String | `usr_company.sCompanyName` | | `sVersion` | String | `usr_company.sVersion`(可 null) | > 匈牙利前缀字段(`sCompanyName`/`sVersion`)的 getter 加 `@JsonProperty` 锁键;`id` 常规驼峰。提供全字段 getter/setter,`implements Serializable`。 ### `UsrCompany` 实体形状(`modules/usr/entity`,映射 `usr_company`,spec § 8 D9) > 与 `UsrUser` 同风格:`@TableName("usr_company")`,可继承既有 `BaseEntity`(复用 `iIncrement` / `sId` / 租户列 / `tCreateDate` 标准列,与 `UsrUser` 一致)。业务列 `@TableField("sCompanyName") String sCompanyName`、`@TableField("sVersion") String sVersion`,匈牙利前缀 getter/setter(`getSCompanyName` / `getSVersion`)。**先确认 `BaseEntity` 是否已含 `iIncrement` getter(`getIIncrement`)**——`UsrUser` 经 `getIIncrement()` 取主键,`UsrCompany` 同样依赖之;若 `BaseEntity` 已提供则直接继承复用。 --- ## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) > 业务类 commit subject 必须带 `REQ-USR-004` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 ### T1 — `ResultCode` 新增 `42901` 限流码 - [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java`: - `::loginRateLimitedCodeIs42901` —— 断言 `ResultCode.LOGIN_RATE_LIMITED.getCode() == 42901` 且 `getMessage()` 非空(如「请求过于频繁,请稍后重试」)。 - `::existingLoginCodesPresent` —— 断言既有 `UNAUTHORIZED.getCode()==40101`、`ACCOUNT_DISABLED.getCode()==40302`、`PARAM_INVALID.getCode()==40001`(确认复用、不重复定义)。 - [ ] **实现**:`common/response/ResultCode.java` 新增枚举常量 `LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试")`;**不改动** `40101`/`40302`/`40001` 既有常量。 - [ ] **验证**:子会话跑 `ResultCodeLoginTest` PASS。 - [ ] **commit**:`feat(usr): 新增登录限流错误码 42901 REQ-USR-004` ### T2 — `LoginDTO` 入参校验 + `CompanyOptionVO` 输出 - [ ] **测试**: - `backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java`(用 `jakarta.validation.Validator`): - `::acceptsValidLogin` —— `sUserName=admin`、`password=666666`、`companyId=1` 无违反。 - `::rejectsBlankUserName` —— `sUserName` 空串 / null 违反 `@NotBlank`。 - `::rejectsBlankPassword` —— `password` 空串 / null 违反 `@NotBlank`。 - `::rejectsNullCompanyId` —— `companyId=null` 违反 `@NotNull`。 - `::rejectsTooLongUserName` —— `sUserName` 长度 51 违反 `@Size(max=50)`。 - `::deserializesSUserNameJsonKey` —— 用 `ObjectMapper` 反序列化 `{"sUserName":"admin","password":"x","companyId":1}` → DTO `getSUserName()=="admin"`(验证 `@JsonProperty("sUserName")` 生效)。 - `backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java`(`ObjectMapper` 序列化): - `::serializesContractKeys` —— 填满字段序列化后 JSON 含键 `id`/`sCompanyName`/`sVersion`,不含 `SCompanyName` 等大驼峰键。 - [ ] **实现**:`modules/usr/dto/LoginDTO.java`(按「LoginDTO 形状」字段 + `@NotBlank`/`@NotNull`/`@Size` + `@JsonProperty("sUserName")` + getter/setter);`modules/usr/vo/CompanyOptionVO.java`(按「CompanyOptionVO 形状」字段 + `@JsonProperty` 锁匈牙利前缀键 + getter/setter,`implements Serializable`)。 - [ ] **验证**:子会话跑两测试 PASS。 - [ ] **commit**:`feat(usr): 登录入参 LoginDTO 与公司下拉项 CompanyOptionVO REQ-USR-004` ### T3 — `LoginVO`(含嵌套 `user`)输出 + `UsrCompany` 实体 + `UsrCompanyMapper` - [ ] **测试**: - `backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java`(`ObjectMapper` 序列化): - `::serializesTokenAndNestedUserNoPassword` —— 构造 `LoginVO`(`token="t.t.t"`、`user{ id=1, sUserName="admin", sUserType="超级管理员", sLanguage="中文" }`),序列化后 JSON 含键 `token` 与嵌套对象 `user`,`user` 内含 `id`/`sUserName`/`sUserType`/`sLanguage`;**不含** `sPassword`/`password`/`SUserName`(验证嵌套结构与 `@JsonProperty` 锁键、无密码字段)。 - `backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(回滚避免污染库)连测试库;`@Autowired UsrCompanyMapper`;测试内插 1 条 `usr_company` fixture,名前缀如 `IT4_CO_`): - `::selectByIdReturnsCompany` —— 插入 fixture 后 `selectById(生成 id)` 取回,断言 `getSCompanyName()` / `getSVersion()` 等于 fixture 值、`getIIncrement()` 非 null。 - `::selectListReturnsAll` —— `selectList(null)` 含刚插入的 fixture(`total≥1`,结果含该公司)。 - [ ] **实现**: - `modules/usr/vo/LoginVO.java`(按「LoginVO 形状」:`token` + 嵌套 `user`;嵌套用静态内部类 `UserInfo` 或独立 VO,字段加 `@JsonProperty` 锁键,全字段 getter/setter,`implements Serializable`,**不含密码**)。 - `modules/usr/entity/UsrCompany.java`(`@TableName("usr_company")`,继承 `BaseEntity` 复用标准列,业务列 `sCompanyName` / `sVersion` 加 `@TableField` + 匈牙利前缀 getter/setter)。 - `modules/usr/mapper/UsrCompanyMapper.java`(`@Mapper public interface UsrCompanyMapper extends BaseMapper`,无自定义方法)。 - [ ] **验证**:子会话跑两测试 PASS(Mapper 测试连库确认实体 ↔ `usr_company` 列映射正确、`BaseMapper` 可用)。 - [ ] **commit**:`feat(usr): 登录输出 LoginVO 与 UsrCompany 实体 Mapper REQ-USR-004` ### T4 — `UsrAuthService` 认证核心(查用户 → BCrypt → 判禁用 → companyId 校验 → 签发 → 更新登录时间 → 限流) - [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java`(Mockito:`@Mock UsrUserMapper` / `UsrCompanyMapper` / `PasswordEncoder` / `JwtUtil`;用 `@InjectMocks` 或构造器注入;限流阈值通过构造器 / 反射注入 `maxFail=3`、`lockSeconds=300` 便于断言;`selectOne` 桩返回 `UsrUser` fixture): - `::successReturnsTokenAndUpdatesLoginTime` —— 用户存在、`iIsVoid=0`、`passwordEncoder.matches` 桩 true、`companyId` 桩存在、`jwtUtil.generateToken` 桩返回 `"jwt.token"` → 返回的 `LoginVO.token=="jwt.token"`、`user.id/sUserName/sUserType/sLanguage` 来自 fixture;验证 `usrUserMapper.updateById` 被调用一次且入参实体 `tLastLoginDate` 非 null(且仅 set 主键 + `tLastLoginDate`,未覆写其他列)。 - `::userNotFoundThrows40101` —— `selectOne` 桩返回 null → 抛 `BusinessException(UNAUTHORIZED)`,**不**调 `passwordEncoder.matches`、**不**调 `updateById`、**不**签发 token。 - `::wrongPasswordThrows40101` —— 用户存在但 `matches` 桩 false → 抛 `BusinessException(UNAUTHORIZED)`,**不**调 `updateById`、**不**签发 token。 - `::notFoundAndWrongPasswordSameCodeAndMessage` —— 断言「用户不存在」与「密码错误」两路径抛出的 `BusinessException` 的 `getResultCode()` 相同(均 `UNAUTHORIZED`)且 `getMessage()` 逐字相同(防枚举,spec § 3 规则 2/3)。 - `::disabledUserThrows40302AfterPasswordOk` —— 用户存在、`matches` true、但 `iIsVoid=1` → 抛 `BusinessException(ACCOUNT_DISABLED)`(验证先验密码再判禁用,spec § 8 D3);**不**签发 token、**不**更新登录时间。 - `::illegalCompanyIdThrows40001` —— 用户存在、`matches` true、`iIsVoid=0`,但 `usrCompanyMapper.selectById(companyId)` 桩返回 null → 抛 `BusinessException(PARAM_INVALID)`;**不**签发 token、**不**更新登录时间。 - `::rateLimitAfterMaxFailThrows42901` —— 连续触发 `maxFail` 次密码错误后,下一次 `login`(即使 `matches` 桩 true)**在锁定窗内**抛 `BusinessException(LOGIN_RATE_LIMITED)`,且该次**不**调 `selectOne` 之后的认证逻辑 / **不**签发 token(锁定判定在最前)。 - `::successResetsFailCounter` —— 先失败若干次(< maxFail)再一次成功登录后,失败计数清零:随后再连续失败到「成功前的次数 + 1」不应触发 `42901`(验证成功清零,spec 验收 9)。 - `::listCompaniesMapsAllRows` —— `usrCompanyMapper.selectList(...)` 桩返回 2 条 `UsrCompany` → `listCompanies()` 返回 2 条 `CompanyOptionVO`,字段 `id`/`sCompanyName`/`sVersion` 来自实体。 - [ ] **实现**: - `modules/usr/service/UsrAuthService.java`:接口声明 `LoginVO login(LoginDTO dto)` + `List listCompanies()`。 - `modules/usr/service/impl/UsrAuthServiceImpl.java`(`@Service`,构造器注入 `UsrUserMapper` / `UsrCompanyMapper` / `PasswordEncoder` / `JwtUtil`;`@Value` 注入 `auth.login.max-fail:5` / `auth.login.lock-seconds:300`;进程内 `ConcurrentHashMap` 计数器): - `login` 标 `@Transactional(rollbackFor = Exception.class)`,严格按「认证判定顺序」执行:①限流窗判定(命中 → `42901`)→ ②按 `sUserName`(trim)`selectOne` 查用户,null → 记一次失败 + 抛 `UNAUTHORIZED` → ③`passwordEncoder.matches(dto.password, user.sPassword)` false → 记一次失败 + 抛 `UNAUTHORIZED`(与②同码同 message)→ ④`iIsVoid==1` → 抛 `ACCOUNT_DISABLED`(禁用不计入失败计数,避免锁死禁用账号;可记 decisions)→ ⑤`usrCompanyMapper.selectById(companyId)` null → 抛 `PARAM_INVALID`(「版本不存在」类不含枚举信息的中性提示)→ ⑥`jwtUtil.generateToken(user.sUserName, user.sUserType)` 签发;构造仅含主键 + `tLastLoginDate=LocalDateTime.now()` 的 `UsrUser` 调 `updateById`;清零该用户失败计数;装配 `LoginVO`(token + `user{id,sUserName,sUserType,sLanguage}`)返回。密码明文绝不进日志 / 异常 message。 - `listCompanies` 标 `@Transactional(readOnly = true)`:`selectList(null)` → 映射为 `List`。 - [ ] **验证**:子会话跑上述用例 PASS。 - [ ] **commit**:`feat(usr): 登录认证 Service 与公司列表 REQ-USR-004` ### T5 — `UsrAuthController` 端点 + `SecurityConfig` 放行 `/api/usr/companies` - [ ] **测试**: - `backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java`(MockMvc `standaloneSetup` + 真实 `GlobalExceptionHandler` + `@Mock UsrAuthService`): - `::loginReturnsCodeZeroWithTokenAndUser` —— `usrAuthService.login` 桩返回填好的 `LoginVO`,`POST /api/usr/login` 合法 body → HTTP 200、`code==0`、`data.token` 存在、`data.user.id`/`data.user.sUserName`/`data.user.sUserType`/`data.user.sLanguage` 存在;响应体不含 `sPassword`/`password`。 - `::loginMissingFieldReturns40001` —— body 缺 `companyId`(`@NotNull` 失败)→ `code==40001`,`usrAuthService.login` 不被调用。 - `::loginAuthFailReturns40101` —— Service 桩抛 `BusinessException(UNAUTHORIZED)` → `code==40101`(Controller 不吞业务异常、全局处理器转码)。 - `::loginDisabledReturns40302` —— Service 桩抛 `BusinessException(ACCOUNT_DISABLED)` → `code==40302`。 - `::loginRateLimitedReturns42901` —— Service 桩抛 `BusinessException(LOGIN_RATE_LIMITED)` → `code==42901`。 - `::companiesReturnsCodeZeroList` —— `usrAuthService.listCompanies` 桩返回 1 条 `CompanyOptionVO`,`GET /api/usr/companies` → `code==0`、`data[0].id`/`data[0].sCompanyName` 存在。 - `backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java`(续既有类或新增用例,验证放行清单含 `/api/usr/companies`): - `::companiesEndpointPermitted` —— 校验 `GET /api/usr/companies` 被放行(与既有 `/api/usr/login` 放行断言同口径;既有 `SecurityConfigTest` 已有放行断言模式,按其风格补一条)。 - [ ] **实现**: - `modules/usr/controller/UsrAuthController.java`(`@RestController` `@RequestMapping("/api/usr")`,构造器注入 `UsrAuthService`):`@PostMapping("/login") login(@Valid @RequestBody LoginDTO dto)` 返回 `Result.success(usrAuthService.login(dto))`;`@GetMapping("/companies") listCompanies()` 返回 `Result.success(usrAuthService.listCompanies())`。**仅校验 + 委派**,无业务逻辑、不直接调 Mapper、无管理员前置(登录 / 公司列表为放行端点)。 - `common/config/SecurityConfig.java`:在 `requestMatchers(...permitAll())` 放行清单追加 `"/api/usr/companies"`(`"/api/usr/login"` 已存在,**不删除既有放行项**)。 - [ ] **验证**:子会话跑 `UsrAuthControllerTest` + `SecurityConfigTest` PASS。 - [ ] **commit**:`feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004` ### T6 — 限流配置项写入 `application.yml`(+ test profile) - [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java`(`@SpringBootTest` + `@ActiveProfiles("test")`,注入配置值断言): - `::loginConfigDefaultsBound` —— 用 `@Value("${auth.login.max-fail}")` / `@Value("${auth.login.lock-seconds}")`(或 `Environment`)断言能解析出整数(默认 `5` / `300`,验证 `application.yml` 已声明键、Service `@Value` 注入不会因缺键启动失败)。 - [ ] **实现**:`backend/src/main/resources/application.yml` 新增配置段(值引 `config-vars.yaml`;config-vars 无登录限流键,采用 spec § 8 D7 默认值,并按既有 `jwt.*` 写法用 env 占位允许覆盖): ```yaml auth: login: max-fail: ${AUTH_LOGIN_MAX_FAIL:5} lock-seconds: ${AUTH_LOGIN_LOCK_SECONDS:300} ``` test profile(`application-test.yml`)可不重复声明(继承主 profile 默认即可);若 `AuthLoginConfigIT` 在 test profile 下取不到键,则在 `application-test.yml` 补同名段(记 decisions)。 - [ ] **验证**:子会话跑 `AuthLoginConfigIT` PASS(确认应用在 test profile 能启动且配置键可解析)。 - [ ] **commit**:`chore(usr): 登录限流阈值配置项 REQ-USR-004` ### T7 — 端到端验收回归(按 spec § 7 验收标准 1-12 收口) - [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;真实 `JwtUtil` / `BCryptPasswordEncoder` / 安全链;`@AfterEach` 按前缀清理 fixture,用户名前缀如 `it_login_`、公司名前缀如 `IT_LOGIN_CO_`;登录用户的 `sPassword` 用真实 `passwordEncoder.encode("666666")` 写入)覆盖 spec § 7: - `::ac1LoginSuccess` —— 预置 `iIsVoid=0` 用户(密码哈希 `666666`)+ 1 条公司 fixture,`POST /api/usr/login`(正确用户名 + 密码 + 该 `companyId`)→ `code=0`、`data.token` 非空、`data.user.id`/`sUserName`/`sUserType`/`sLanguage` 正确;响应体**不含** `sPassword`/`password`/明文 `666666`。 - `::ac2TokenAcceptedByProtectedApi` —— 用 ac1 返回的 `token` 作 `Authorization: Bearer ` 调 `GET /api/usr/users?pageNum=1&pageSize=10`(REQ-USR-003 受保护接口)→ **非 401**(通过 JWT 过滤器,`code=0` 或至少不被安全链拒)。 - `::ac3LoginUpdatesLastLoginDate` —— 记录登录前该用户 `tLastLoginDate`(null 或旧值),登录成功后 `selectById` 查该用户,断言 `tLastLoginDate` 已更新为非 null 且 ≥ 登录前(由 null 变非 null)。 - `::ac4WrongPassword40101` —— 正确用户名 + 错误密码 → `code=40101`,响应体不含 `token`、不含「密码错误」字样。 - `::ac5UserNotFound40101SameAsWrongPassword` —— 不存在用户名 → `code=40101`,且其 `message` 与 ac4「密码错误」响应的 `message` **逐字相同**(无法据响应区分账号是否存在)。 - `::ac6DisabledUser40302` —— 预置 `iIsVoid=1` 用户(密码哈希正确),正确凭据登录 → `code=40302`、不返回 `token`;登录后查该用户 `tLastLoginDate` 未更新。 - `::ac7MissingParam40001` —— 分别缺 `sUserName` / 缺 `password` / 缺 `companyId` → 各 `code=40001`,不进入认证(不返回 token)。 - `::ac8IllegalCompanyId40001` —— 正确凭据但 `companyId` 取一个 `usr_company` 不存在的值(如 `Integer.MAX_VALUE`)→ `code=40001`。 - `::ac9RateLimitAfter5Fails42901` —— 对同一账号连续 5 次密码错误(每次 `code=40101`),第 6 次**即使密码正确**也 `code=42901`;为隔离限流计数器状态,本用例用独立专属用户名(前缀 `it_login_rl_`),并验证一次成功登录(或等待窗口)后计数清零、恢复正常 `code=0`(窗口等待不可行时,断言「成功登录后计数清零」分支:先失败 < 5 次→成功→再失败仍不触发 42901)。 - `::ac10CompaniesListNoToken` —— **不带 token** 调 `GET /api/usr/companies` → HTTP 200、`code=0`、`data` 含公司 fixture 项 `{id, sCompanyName, sVersion}`(该端点放行)。 - `::ac11PasswordNeverLeaks` —— 上述任意成功 / 失败响应体均不含 `sPassword`/`password` 字段、不含明文密码 `666666`。 - `::ac12JwtHasExpiry` —— 解码 ac1 返回的 `token`(用 `jwtUtil` 或直接解析 claims)断言含 exp(有效期有限);可选:用一个已过期 token(如用极短 expire 或手工构造)调受保护接口 → 401(若构造过期 token 成本高,至少断言签发 token 解析出的过期时间晚于签发时间,记 decisions)。 - [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如限流计数器跨用例污染需按用户名隔离、`updateById` 仅更新 `tLastLoginDate` 的语义、统一文案逐字一致),不引入新公共契约、不新增 migration。 - [ ] **验证**:子会话跑 `UsrLoginIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。 - [ ] **commit**:`test(usr): 登录端到端验收回归 REQ-USR-004` --- ## 自审 ### 占位符扫描 - 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记(`sLanguage` / `sVersion` / `usr_permission` 粒度)不在本登录 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}、`sVersion` 直透展示)继续,不阻塞。 ### Spec coverage(spec 每节 → task 映射) - § 1 Goal(`POST /api/usr/login` 主端点 + `GET /api/usr/companies` 配套只读端点)→ T5(两端点)+ T4(认证 + 公司列表逻辑)+ 全部 task。 - § 2.1 输入 `LoginDTO`(字段 / 校验 / Auth 放行)→ T2(DTO 校验 + JSON 键)+ T5(端点放行 + `@Valid`)。 - § 2.2 输出 `Result`(含嵌套 `user`,不含 `sPassword`)→ T3(`LoginVO` 嵌套 + 无密码)+ T4(装配)+ T5(Controller 组装)+ T7 ac1/ac11。 - § 2.3 `GET /api/usr/companies` 输入 / 输出 `CompanyOptionVO` → T2(`CompanyOptionVO`)+ T3(`UsrCompany` 实体 / Mapper)+ T4(`listCompanies`)+ T5(端点)+ T7 ac10。 - § 3 规则 1 认证主流程(顺序判定)→ T4(认证顺序 + 各错误码)+ 合同级常量「认证判定顺序」+ T7 ac1/ac4/ac6/ac7/ac8。 - § 3 规则 2/3 防枚举 + 统一失败提示(用户不存在 = 密码错误,同码同文案)→ T4 `::notFoundAndWrongPasswordSameCodeAndMessage` + T7 ac5(逐字相同)。 - § 3 规则 4 JWT 签发(无状态 + exp + claim 含用户标识 / 类型)→ T4(`jwtUtil.generateToken`)+ T7 ac2/ac12 + 合同级常量「JWT」。 - § 3 规则 5 / § 8 D4 禁用 / 删除统一 `iIsVoid=1` → `40302` → T4 `::disabledUserThrows40302AfterPasswordOk` + T7 ac6。 - § 3 规则 6 / § 8 D2 `companyId` 存在性校验、不参与认证绑定、非法 → `40001` → T4 `::illegalCompanyIdThrows40001` + T7 ac8。 - § 3 规则 7 / § 8 D6 更新 `tLastLoginDate`(同事务,失败回滚)→ T4(`@Transactional` + `updateById` 仅改该列)+ T7 ac3。 - § 3 规则 8 / § 8 D7 连续失败限流 `42901`(进程内计数 / 阈值配置)→ T1(错误码)+ T4(计数器 + 锁定判定 + 成功清零)+ T6(配置项)+ T7 ac9。 - § 3 规则 9 / 验收 11 密码与敏感字段不返回 → T2/T3(VO 无密码)+ T4(不外泄)+ T7 ac11。 - § 3 规则 10 `GET /api/usr/companies` 只读无副作用 → T4(`@Transactional(readOnly=true)` + 仅 `selectList`)+ T7 ac10。 - § 4 约束(分层 `UsrAuthController`→`UsrAuthService`→Mapper / 包路径 / 命名 `login`/`listCompanies` / 统一响应 / 异常 / 事务 / 认证安全 / 数据访问 / 配置 / schema 不改)→ T1-T6 分层落位;安全放行 T5;配置 T6;schema 复用 V1(Tech Stack)。 - § 5 Schema 引用(读 + 写 `usr_user`、读 `usr_company`,不 SELECT 密码外泄)→ T3(`UsrCompany` 实体 / Mapper)+ T4(`selectOne`/`updateById`/`selectById`/`selectList`)。 - § 6 错误码(`0`/`40001`/`40101`/`40302`/`42901`/401)→ T1(`42901` 新增)+ 复用既有 `ResultCode`(`40101`/`40302`/`40001`)+ T4(抛码)+ T5(全局处理器转码)+ T7(401 安全链)。 - § 7 验收标准 1-12 → T7 ac1-ac12 逐条覆盖。 - § 8 decisions(D1-D11)→ D1(补 `GET /api/usr/companies`)T2/T3/T4/T5;D2(companyId 存在性 / `40001`)T4;D3(先验密码再判禁用)T4/合同级常量「认证判定顺序」;D4(已删除≡`iIsVoid=1`)T4;D5(公司列表不分页)T4(`selectList(null)`);D6(更新登录时间同事务回滚)T4;D7(进程内限流 + 阈值配置 + `42901`)T1/T4/T6;D8(新建 `UsrAuthController`)T5;D9(新建 `UsrCompany`/`UsrCompanyMapper`)T3;D10(JWT 过期时间默认)见下「关于 JWT 过期配置」决策;D11(不新增 migration)Tech Stack 已体现。 ### 类型一致性 - `UsrAuthService#login(LoginDTO):LoginVO` / `#listCompanies():List` 在 T4 定义,T5(Controller 调用)/ T7(IT)一致引用。 - `UsrAuthController#login(@Valid @RequestBody LoginDTO):Result` / `#listCompanies():Result>`,与 docs/05 契约 + spec § 2 一致。 - `UsrCompanyMapper extends BaseMapper` 在 T3 定义,T4 调用(`selectById`/`selectList`)一致。 - `LoginDTO`(`sUserName`/`password`/`companyId` + `@NotBlank`/`@NotNull`/`@Size` + `@JsonProperty("sUserName")`)在 T2 锁定,T4/T5/T7 一致。 - `LoginVO`(`token` + 嵌套 `user{id,sUserName,sUserType,sLanguage}`)在 T3 锁定,T4/T5/T7 一致;严格不含 `sPassword`/租户列。 - `CompanyOptionVO`(`id`/`sCompanyName`/`sVersion`)在 T2 锁定,T3/T4/T5/T7 一致。 - `UsrCompany` 实体在 T3 锁定(`@TableName("usr_company")` + `sCompanyName`/`sVersion` + 继承 `BaseEntity` 标准列),与 V1 `usr_company` 列一致。 - 错误码字面量 `0`/`40001`/`40101`/`40302`/`42901` 与 docs/05、spec § 6 一致:`SUCCESS`/`PARAM_INVALID`/`UNAUTHORIZED`/`ACCOUNT_DISABLED` 复用既有,**仅新增** `LOGIN_RATE_LIMITED=42901`(T1)。 - REST 路径 `POST /api/usr/login`、`GET /api/usr/companies` 与 docs/05、spec 一致;二者均放行(T5 `SecurityConfig`)。 - 复用既有 `JwtUtil.generateToken(userName, userType)`(claim 含 `sUserName`/`sUserType` + exp)、`PasswordEncoder`、`UsrUserMapper`、`BusinessException`、`Result`、`GlobalExceptionHandler`,签名与既有代码一致;实体 / VO getter 沿用匈牙利前缀 + `@JsonProperty` 锁键风格(与 `UserVO`/`CreateUserDTO` 同)。 ### 关于 JWT 过期配置(spec § 8 D10 的实现期落地决策) - spec § 8 D10 建议新增 `jwt.expiration`(默认 7200 秒)。**但既有代码已存在** `JwtUtil` + `application.yml` `jwt.expire-millis`(默认 43200000ms = 12 小时,REQ-USR-001 T3 锁定)。为遵循 DRY 与「复用既有签发组件」,本计划**复用既有 `jwt.expire-millis`**,**不**新增 `jwt.expiration` 配置键、**不**改 `JwtUtil`——既满足 spec「JWT 必须含过期时间」的硬要求(既有实现已 `.expiration(...)`),又避免双过期配置漂移。该决策记入返回 `decisions[]`(覆盖 spec § 8 D10 的具体键名 / 默认值)。验收 ac12 只断言「含 exp 且有限有效期」,不绑定具体秒数,故不受影响。