--- req_id: REQ-USR-001 date: 2026-05-15 spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-001.md --- # REQ-USR-001 用户登录 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 `POST /api/v1/auth/login`,按 spec 完成 4 类校验(公司 / 作废 / 锁定 / 密码)+ 失败计数 + 锁定写入 + JWT 签发,并落地后端项目骨架以支撑后续 REQ。 **Architecture:** - Spring Boot 3 + MyBatis-Plus + Flyway + BCrypt + JJWT (HS256);分层 controller → service(impl) → mapper(docs/04 § 1.1)。 - 业务逻辑全部在 `LoginServiceImpl`,事务边界为成功路径的"清零计数 + 更新登录时间 + 签发 JWT"原子提交。 - 失败逻辑(计数累加 + 锁定写入)在独立事务里执行;锁定时间通过 `tLockUntil` 字段比对 `NOW()` 判定(无需 Redis)。 - 错误码集中在 `ErrorCode` 常量类;`GlobalExceptionHandler` 把 `BizException` 转 `Result.fail`。 **Tech Stack:** Spring Boot 3.x(Java 17)/ MyBatis-Plus 最新稳定 / Flyway 10.x(core + mysql)/ Spring Security crypto(BCryptPasswordEncoder,不启用完整 Security filter chain)/ JJWT 0.12.x / Lombok / Jakarta Validation。 --- ## Schema 改动 无。V1 已建好 `sys_user` / `sys_company` / `sys_employee`,本 REQ 不动 schema。 --- ## 文件变更清单 **Bootstrap(首次 REQ 一次性投入,后续 REQ 复用):** - `backend/pom.xml` — Create(Maven POM,声明依赖与插件) - `backend/src/main/java/com/xly/erp/Application.java` — Create(Spring Boot 启动类) - `backend/src/main/resources/application.yml` — Create(主配置,从 `.env.local` / 环境变量读敏感项) - `backend/src/main/resources/application-test.yml` — Create(测试 profile,复用相同 schema 但禁日志彩色) - `backend/src/main/resources/logback-spring.xml` — Create(最小 logback 配置) **通用基础层(首次 REQ 一次性投入):** - `backend/src/main/java/com/xly/erp/common/response/Result.java` — Create - `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — Create - `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — Create - `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — Create - `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — Create - `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` — Create(`BCryptPasswordEncoder` Bean) **业务层(REQ-USR-001 专属):** - `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` — Create(只读 join 用,最小字段) - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` — Create(接口) - `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` — Create **测试:** - `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` — Create(unit/integration with Spring Test + 真实 MySQL,按 spec 验收 1-10 项) - `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` — Create(`MockMvc` 端到端覆盖错误码 40001 / 40004 / 40101 / 40103 / 42301) - `backend/src/test/resources/sql/req-usr-001-seed.sql` — Create(测试 fixture:1 个启用用户、1 个作废用户、2 个公司、1 个员工) --- ## 约束常量(跨任务,写死不允许漂移) **错误码**(`ErrorCode` 常量类): | 常量名 | 值 | HTTP | |---|---|---| | `OK` | `200` | 200 | | `BAD_REQUEST` | `40001` | 400 | | `COMPANY_NOT_FOUND` | `40004` | 400 | | `BAD_CREDENTIALS` | `40101` | 401 | | `ACCOUNT_DELETED` | `40103` | 401 | | `ACCOUNT_LOCKED` | `42301` | 423 | | `INTERNAL_ERROR` | `50000` | 500 | **JWT 常量**:算法 HS256;TTL 7200 秒;claims 名 `sub` / `username` / `userType` / `companyCode` / `language` / `iat` / `exp` / `jti`;签名密钥从 `application.yml` 注入 `${JWT_SECRET}`(已在 `.env.local` 配置)。 **锁定策略**:阈值 5 次(含第 5 次触发锁定);锁定时长 30 分钟。Magic number 集中在 `LoginServiceImpl` 私有常量 `MAX_FAILED_LOGIN_COUNT = 5` 与 `LOCK_DURATION_MINUTES = 30L`。 **API 形状**: ``` POST /api/v1/auth/login (公开接口,无需 Bearer) Request: LoginReq { username:String, password:String, companyCode:String } Response: Result LoginVo { accessToken:String, tokenType:"Bearer", expiresInSec:7200, userInfo:UserInfoVo } UserInfoVo { userId:int, username:String, userType:String, language:String, employeeName:String?, companyCode:String } ``` --- ## 任务步骤 ### Task 1: Bootstrap Spring Boot 项目骨架 **Files:** - Create: `backend/pom.xml` - Create: `backend/src/main/java/com/xly/erp/Application.java` - Create: `backend/src/main/resources/application.yml` - Create: `backend/src/main/resources/application-test.yml` - Create: `backend/src/main/resources/logback-spring.xml` - Create: `backend/src/test/java/com/xly/erp/ApplicationContextTest.java` **配置要点**(POM 内容由 TDD 实现,签名约束如下): - Parent: `spring-boot-starter-parent:3.3.x`,Java 17 - Dependencies: `spring-boot-starter-web`, `spring-boot-starter-validation`, `spring-boot-starter-test`, `mybatis-plus-spring-boot3-starter:3.5.x`, `mysql-connector-j` (runtime), `flyway-core` + `flyway-mysql`, `spring-security-crypto`(**不**引入 `spring-boot-starter-security`,避免开启 filter chain),`io.jsonwebtoken:jjwt-api/jjwt-impl/jjwt-jackson:0.12.5`, `lombok` - Flyway 自动启用:默认指向 classpath:db/migration,本项目改为 `classpath:db/migration` 同步指向仓库根 `sql/migrations/` —— **由 Spring Boot 配置加载实现**:在 `application.yml` 写 `spring.flyway.locations: filesystem:../sql/migrations`(相对 backend/ 目录) - `application.yml` 必须用 `${VAR_NAME}` 注入 DB / JWT 凭据(来源 `.env.local`,启动时由 maven-dotenv-plugin 或 `EnvironmentPostProcessor` 加载——TDD 选择最简单方案 `spring-boot-dotenv` 第三方 starter 或 spring-cloud-context 都不引入,改用 `application.yml` 占位 + 启动命令显式 `--spring.config.location` 或 OS 环境变量;**推荐**:要求开发者把 `.env.local` 中的变量 export 到 shell 后 `./mvnw spring-boot:run`;测试场景由 surefire `` 注入或测试自行 `@TestPropertySource` 覆盖) > 注:以上"如何加载 .env.local"是 Spring Boot 项目的通用工程难题,本任务**最小可行实现**:测试用 `@DynamicPropertySource` 从 `System.getenv()` 注入;启动用 OS 环境变量。生产部署后续 REQ 再优化。 **API shape**: - `Application.main(String[])` — 标准 `SpringApplication.run` - `ApplicationContextTest#contextLoads()` — Spring Test 验证 ApplicationContext 启动 - [ ] **Step 1: 写失败测试** - 测试名: `ApplicationContextTest#contextLoads` - 意图: 验证 Spring Boot context 能启动;Flyway 能连接到 .env.local 指定的 MySQL 并发现 V1 已 apply(含 `flyway_schema_history` 表) - 子会话确认 FAIL(pom.xml 不存在 / Application 类不存在) - [ ] **Step 2: 实现最小代码** - 写 pom.xml(依赖如上)、Application.java、application.yml、application-test.yml、logback-spring.xml - 在 `application-test.yml` 中通过 `@DynamicPropertySource` 或 `spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_SCHEMA}` 占位符让测试读 env - [ ] **Step 3: 子会话验证 PASS** - 子会话跑 `cd backend && ./mvnw -B test -Dtest=ApplicationContextTest` 应绿 - [ ] **Step 4: Commit** - `git add backend/pom.xml backend/src/main/java/com/xly/erp/Application.java backend/src/main/resources/ backend/src/test/java/com/xly/erp/ApplicationContextTest.java` - `git commit -m "feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001"` --- ### Task 2: 通用响应包装 + 异常处理 **Files:** - Create: `backend/src/main/java/com/xly/erp/common/response/Result.java` - Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java` - Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` - Create: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` **API shape:** - `Result { int code; String message; T data; long timestamp; static Result ok(T data); static Result fail(int code, String message); }` - `ErrorCode` — 常量类,含上方"约束常量"表中所有 code(int 字段) - `BizException extends RuntimeException { int code; String message; BizException(int code, String message); }` - `GlobalExceptionHandler` — `@RestControllerAdvice`,handles: - `BizException` → 按其 `code` 映射到 `ResponseEntity`,HTTP 状态按 ErrorCode 表 - `MethodArgumentNotValidException` / `ConstraintViolationException` → `Result.fail(40001, ...)`,HTTP 400 - `Exception`(兜底) → `Result.fail(50000, "服务器内部错误")`,HTTP 500,记 ERROR 日志,**不**回显堆栈到 message - [ ] **Step 1: 写失败测试** - 测试名: `GlobalExceptionHandlerTest#bizException_returnsCodeAndHttpStatus` 等 3 个测试 - 意图: 用 `MockMvc` 配合一个 `/_test/throw-biz` 测试 controller 触发 `BizException(42301, "...")`,断言 HTTP 423 + body `code=42301`;类似覆盖 `BizException(40101, ...)` → 401,以及兜底 `RuntimeException` → 500 且 message 不含 "java." 前缀 - 子会话确认 FAIL - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001"` --- ### Task 3: JWT 工具 + 密码编码器 Bean **Files:** - Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` - Create: `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` - Create: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` **API shape:** - `JwtUtil#issue(Map claims, long ttlSec) : String` — 用 `${JWT_SECRET}`(注入 `@Value`)签发 HS256 JWT,含 `iat` / `exp` / `jti(=UUID)` - `JwtUtil#parse(String token) : Map` — 验签 + 解析,签名错或过期抛 `BizException(40101, ...)` - `PasswordEncoderConfig#passwordEncoder() : BCryptPasswordEncoder` — Spring Bean,strength=10 - [ ] **Step 1: 写失败测试** - 测试名: `JwtUtilTest#issuedToken_canBeParsedBackToClaims` / `JwtUtilTest#tamperedToken_throwsBizException` / `JwtUtilTest#expiredToken_throwsBizException`(用 ttl=0 模拟) - 意图: 验证签发→解析往返一致;篡改任意一字节抛 40101;过期抛 40101 - [ ] **Step 2: 实现最小代码** - JWT_SECRET 来自 `application-test.yml` 的 `jwt.secret: ${JWT_SECRET:test-secret-256bit-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}` - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001"` --- ### Task 4: Entity + Mapper(sys_user / sys_company / sys_employee) **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` - Create: `backend/src/test/resources/sql/req-usr-001-seed.sql` **API shape:** - `SysUser` — 含 `iIncrement / sUsername / sUserCode / sPasswordHash / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / iFailedLoginCount / tLockUntil / tLastLoginDate / sCreatedBy / sUpdatedBy / tUpdatedDate` 字段(命名按 docs/03,TableField 映射);`@TableName("sys_user")`,`@TableId(value="iIncrement", type=IdType.AUTO)` - `SysCompany` — 含 `iIncrement / sCompanyCode / sCompanyName / iIsDeleted`(最小字段) - `SysEmployee` — 含 `iIncrement / sEmployeeName / iDepartmentId`(最小字段) - Mapper 三个均 `extends BaseMapper`(MyBatis-Plus);`SysUserMapper` 额外定义一个方法:`selectByUsername(String username) : SysUser`(@Select 注解),用于登录查找 **Seed SQL** 内容(写死): ```sql -- req-usr-001-seed.sql DELETE FROM sys_user_permission_category; DELETE FROM sys_user; DELETE FROM sys_employee; DELETE FROM sys_company; INSERT INTO sys_company (sCompanyName, sCompanyCode, iIsDeleted) VALUES ('总部', 'HQ', 0), ('已删公司', 'DEL_CO', 1); INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) SELECT '张三', 'E001', 1 FROM (SELECT 1) t WHERE EXISTS (SELECT 1 FROM sys_department LIMIT 1); -- 若 sys_department 为空,先插入一行 INSERT INTO sys_department (sDepartmentName, sDepartmentCode) VALUES ('技术部', 'TECH'); INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) SELECT '张三', 'E001', iIncrement FROM sys_department WHERE sDepartmentCode='TECH' LIMIT 1; -- password = 'Password1!' 的 BCrypt(strength=10) 哈希(TDD 阶段实际生成填入) INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, iEmployeeId, sUserType, sLanguage, iIsDeleted, iFailedLoginCount, sCreatedBy) SELECT 'alice', 'U001', '', iIncrement, 'NORMAL', 'zh-CN', 0, 0, 'system' FROM sys_employee WHERE sEmployeeCode='E001'; INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, sUserType, sLanguage, iIsDeleted, sCreatedBy) VALUES ('bob_deleted', 'U002', '', 'NORMAL', 'zh-CN', 1, 'system'); ``` > `` 在 Task 4 实现时通过一次性 java main 或 `BCryptPasswordEncoder` 调用生成后填入;不允许保留占位符进 commit。 - [ ] **Step 1: 写失败测试** - 测试名: `SysUserMapperTest#selectByUsername_returnsUserWithAllFields` / `SysUserMapperTest#selectByUsername_returnsNullWhenNotFound` - 意图: seed 后查 `alice` → 字段完整;查 `nobody` → null - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001"` --- ### Task 5: LoginReq DTO + LoginVo + UserInfoVo **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java` **API shape:** - `LoginReq { @NotBlank @Size(max=50) String username; @NotBlank @Size(max=128) String password; @NotBlank @Size(max=50) String companyCode; }` - `LoginVo { String accessToken; String tokenType; long expiresInSec; UserInfoVo userInfo; }` - `UserInfoVo { Integer userId; String username; String userType; String language; String employeeName; String companyCode; }` - [ ] **Step 1: 写失败测试** - 测试名: `LoginReqValidationTest#blankUsername_fails` / `LoginReqValidationTest#tooLongUsername_fails` / `LoginReqValidationTest#allFieldsPresent_passes` - 意图: 用 `Validator` 校验 jakarta 约束注解工作正常 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001"` --- ### Task 6: LoginService 接口骨架 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API shape:** - `LoginService#login(String username, String password, String companyCode) : LoginVo` — 业务方法签名 - `LoginServiceImpl implements LoginService` — `@Service`,注入 `SysUserMapper`、`SysCompanyMapper`、`SysEmployeeMapper`、`BCryptPasswordEncoder`、`JwtUtil` - `LoginServiceImpl` 私有常量:`MAX_FAILED_LOGIN_COUNT = 5`、`LOCK_DURATION_MINUTES = 30L`、`TOKEN_TTL_SEC = 7200L` 本 task 仅产出**接口 + 空实现 + 一个 baseline 测试**(直接抛 UnsupportedOperationException),用于建立后续 task 的脚手架。 - [ ] **Step 1: 写失败测试** - 测试名: `LoginServiceImplTest#contextLoads`(验证 LoginService Bean 注入成功;与 `@SpringBootTest` + seed.sql 一起工作) - 意图: Bean 装配可用 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): LoginService 接口骨架 REQ-USR-001"` --- ### Task 7: Login — 公司不存在或已删 → 40004 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API behavior**:当 `companyCode` 在 `sys_company` 查不到(不存在 OR `iIsDeleted=1`)→ 抛 `BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除")`;**不**触碰 sys_user。 - [ ] **Step 1: 写失败测试** - 测试名: `LoginServiceImplTest#login_unknownCompany_throws40004` / `login_softDeletedCompany_throws40004` - 意图: 用 seed 中的 `HQ`(正常)vs `NOPE`(不存在)vs `DEL_CO`(软删)触发不同分支;断言抛 `BizException` 且 `code == 40004`;同时断言 `alice` 的 `iFailedLoginCount` 仍为 0(未被错误计入失败) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): 登录校验公司存在性 REQ-USR-001"` --- ### Task 8: Login — 用户不存在 / 密码错 → 40101(同文案,含失败计数) **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API behavior**: - 用户名不存在 → `BizException(40101, "用户名或密码错误")`;不写 DB(无 user 行可写) - 用户存在 + 密码 hash 不匹配 → `sys_user.iFailedLoginCount += 1`,返 `BizException(40101, "用户名或密码错误")` - [ ] **Step 1: 写失败测试** - 测试名: - `login_unknownUser_throws40101_noDbWrite` - `login_badPassword_throws40101_andIncrementsFailCount` (断言一次错误后 `iFailedLoginCount == 1`) - 意图: 防用户名枚举 + 失败计数累加 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001"` --- ### Task 9: Login — 失败 5 次锁定 → 第 6 次返 42301 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API behavior**: - 累计第 5 次错误密码时,在同一事务里写 `tLockUntil = NOW() + 30 分钟`;本次响应仍返 `40101`(**不**在第 5 次直接返锁定,避免泄露阈值) - 后续请求遇到 `tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 直接抛 `BizException(ACCOUNT_LOCKED, "账号已锁定,请稍后再试")`,HTTP 423;**不**计入失败次数 - `tLockUntil <= NOW()`(锁定到期)→ 视为已解锁,正常进入密码校验流程(即仍允许累加失败、仍允许成功) - [ ] **Step 1: 写失败测试** - 测试名: - `login_5thBadPassword_setsLockUntil_andStillReturns40101`(断言 `iFailedLoginCount == 5` 且 `tLockUntil` 不为空 ≥ NOW()+29min) - `login_duringLockWindow_throws42301_noCountIncrement` - `login_afterLockExpired_allowsNewAttempt`(用 SQL 把 tLockUntil 改成过去时刻,再次登录正确密码 → 应成功并清零计数;属于先做小验证,可在 Task 10 完整覆盖成功路径) - 意图: 锁定语义闭环 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001"` --- ### Task 10: Login — 作废账号 → 40103;成功 → 签 JWT + 清零 + 更新登录时间 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API behavior**: - `sys_user.iIsDeleted == 1` → `BizException(ACCOUNT_DELETED, "账号已被作废,禁止登录")`,HTTP 401;**不**进入密码校验,**不**累加失败 - 成功路径(`@Transactional`): 1. `iFailedLoginCount = 0`,`tLockUntil = NULL`,`tLastLoginDate = NOW()`(一次 UPDATE) 2. 加载 `sys_employee.sEmployeeName`(若 `iEmployeeId` 非空) 3. 构造 JWT claims(`sub=userId`, `username`, `userType`, `companyCode`, `language`, `jti=UUID`),通过 `JwtUtil.issue(claims, TOKEN_TTL_SEC)` 4. 返回 `LoginVo` 判定顺序(先后明确):1) 公司校验 → 2) 用户查找 → 3) 作废校验 → 4) 锁定校验 → 5) 密码校验 → 6) 成功路径。 - [ ] **Step 1: 写失败测试** - 测试名: - `login_deletedUser_throws40103_noCountIncrement` - `login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin` - `login_success_jwtParsesBack_with_sub_username_companyCode` - 意图: 成功路径与作废路径都覆盖;JWT 验签往返 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001"` --- ### Task 11: AuthController + 端到端 MockMvc 测试 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` **API shape:** - `AuthController` — `@RestController @RequestMapping("/api/v1/auth")` - `POST /api/v1/auth/login` 方法 `login(@RequestBody @Valid LoginReq req) : Result`,委托给 `LoginService` - [ ] **Step 1: 写失败测试** - 测试名(`AuthControllerTest` 用 `@SpringBootTest + MockMvc`): - `post_login_success_returns200_andLoginVo` - `post_login_badCredentials_returns401_code40101` - `post_login_lockedAccount_returns423_code42301` - `post_login_deletedAccount_returns401_code40103` - `post_login_unknownCompany_returns400_code40004` - `post_login_blankUsername_returns400_code40001` - 意图: 6 个 HTTP 路径全部覆盖 spec § 验收 1-10 - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** - `git commit -m "feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001"` --- ## 提交计划 按 task 顺序产生 11 个 commit: | Task | Commit message | |---|---| | 1 | `feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001` | | 2 | `feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001` | | 3 | `feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001` | | 4 | `feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001` | | 5 | `feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001` | | 6 | `feat(usr): LoginService 接口骨架 REQ-USR-001` | | 7 | `feat(usr): 登录校验公司存在性 REQ-USR-001` | | 8 | `feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001` | | 9 | `feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001` | | 10 | `feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001` | | 11 | `feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001` |