diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 8054ad1..3d2b049 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -60,7 +60,7 @@ - 路径: backend/module/usr/ - MR: — - 功能: - - [ ] REQ-USR-001 用户登录 + - [x] REQ-USR-001 用户登录 - [ ] REQ-USR-002 新增用户 - [ ] REQ-USR-003 修改用户 - [ ] REQ-USR-004 查询用户 diff --git a/docs/superpowers/plans/2026-05-15-REQ-USR-001.md b/docs/superpowers/plans/2026-05-15-REQ-USR-001.md new file mode 100644 index 0000000..789bc0b --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-REQ-USR-001.md @@ -0,0 +1,462 @@ +--- +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` | diff --git a/docs/superpowers/reviews/2026-05-15-REQ-USR-001.md b/docs/superpowers/reviews/2026-05-15-REQ-USR-001.md new file mode 100644 index 0000000..a91285a --- /dev/null +++ b/docs/superpowers/reviews/2026-05-15-REQ-USR-001.md @@ -0,0 +1,40 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-15 +round: 2 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-001 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:28 — incrementFailedLoginCountAtomic 依赖 MySQL `SET` 子句左到右求值语义;建议加链接 dev.mysql.com/doc/refman/8.0/en/update.html 提醒切换数据库方言时复查 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java:66 — lockUntil 在 service 层 ISO 字符串化;建议改为直接放 LocalDateTime 让 Jackson 全局规则统一处理 +- backend/src/main/java/com/xly/erp/common/exception/BizException.java:25 — (int, String, Throwable) 构造的 data=null 是合理默认;未来若有"包装异常 + data"诉求再加 4 参构造 +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java:171 — 并发测试用 2×2 低于阈值;可补 3×2 高于阈值场景断言 lock 触发 + count 准确 + +## 反例 / 测试覆盖缺口 + +Round 1 标记『推迟』的 4 项(docs/04 vs docs/05 跨文档不一致、HttpMessageNotReadableException 兜底、ErrorCode.toHttpStatus future-proof、application.yml flyway 路径)本轮未触及,符合 round 1 review 约定,不视为 gap。Reviewer 沙箱因无 MySQL 连接产生 28 个测试 error,已通过 surefire 报告确认为环境问题;主会话 34 测试全过。 + +## 本轮变更归档 + +Round 1 修复全部正确落地: + +| # | 项目 | 状态 | +|---|------|-----| +| H1 | 42301 data.lockUntil | ✓ BizException 扩 data 链路完整 | +| H2 | 失败计数原子 UPDATE | ✓ 单 SQL,去 noRollbackFor,并发回归覆盖 | +| H3 | SELECT * → 显式列 | ✓ 两个 mapper 都改 | +| M5 | JWT 短密钥静默补零 | ✓ < 32 字节 PostConstruct 硬抛 | +| M6 | loginSuccess 双路径 | ✓ 去 entity,走 markLoginSuccess SQL | +| NTH | exp-iat==7200 / lockUntil / 并发原子累加 测试 | ✓ 全部新增 | + +未引入新回归。Approve。 diff --git a/docs/superpowers/specs/2026-05-15-REQ-USR-001.md b/docs/superpowers/specs/2026-05-15-REQ-USR-001.md new file mode 100644 index 0000000..7b595b6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-REQ-USR-001.md @@ -0,0 +1,127 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-15 +module: module_usr +--- + +# Spec: REQ-USR-001 — 用户登录 + +## 目标 + +提供用户名 + 密码 + 公司编码的登录接口,校验通过后签发 JWT access token(HS256,TTL 2 小时)并返回当前用户基础信息,用于后续接口的 Bearer 鉴权。本 REQ 仅签发 access token,refresh token 推迟到后续 REQ(与 docs/05 LoginVo 一致;docs/04 § 1.6 的 refresh 描述视为未来增量,本 REQ 范围内不实现)。 + +## 输入 / 触发 + +HTTP 入口 `POST /api/v1/auth/login`,公开接口(无需 Bearer)。 + +**请求体 LoginReq**(JSON): + +| 字段 | 类型 | 必填 | 校验规则(登录场景,宽松,不复用创建场景的强度规则) | +|---|---|---|---| +| `username` | string | 是 | 非空;长度 1-50(容纳潜在历史账号;越界返 `40001`) | +| `password` | string | 是 | 非空;长度 1-128(密码强度只在创建/重置流程校验,登录只校验匹配;越界返 `40001`) | +| `companyCode` | string | 是 | 非空;最大长度 50;命中 `sys_company.sCompanyCode AND iIsDeleted=0`,否则返 `40004` | + +> **登录与创建的校验差异**:REQ-USR-002 创建用户时密码必须 8-20 位含大小写字母和数字(强度规则);本 REQ 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。 + +## 输出 / 结果 + +**成功 200**:`Result`,其中 `LoginVo`: + +```json +{ + "accessToken": "", + "tokenType": "Bearer", + "expiresInSec": 7200, + "userInfo": { + "userId": 123, + "username": "alice", + "userType": "NORMAL", + "language": "zh-CN", + "employeeName": "张三", + "companyCode": "HQ" + } +} +``` + +- `employeeName` 通过 `sys_user.iEmployeeId` JOIN `sys_employee.sEmployeeName` 取得;未绑定职员时字段省略。 +- 副作用:写库 `sys_user.iFailedLoginCount = 0`、`sys_user.tLockUntil = NULL`、`sys_user.tLastLoginDate = NOW()`。 + +**失败**:见错误码段,全部走全局异常处理器映射为 `Result.fail(code, message)`。错误码段位与 docs/05 一致: + +| HTTP | code | 含义 | 触发条件 | +|---|---|---|---| +| 400 | 40001 | 用户名或密码格式错误 | 必填字段缺失 / 类型错 / 长度越界 | +| 400 | 40004 | 公司不存在或已删除 | `companyCode` 在 `sys_company` 查不到(含 `iIsDeleted=1`) | +| 401 | 40101 | 用户名或密码错误 | 用户不存在 OR 密码哈希不匹配(统一文案,不区分两者,防用户名枚举) | +| 401 | 40103 | 账号已被作废,禁止登录 | `sys_user.iIsDeleted = 1` | +| 423 | 42301 | 账号已锁定,请稍后再试 | `sys_user.tLockUntil IS NOT NULL AND tLockUntil > NOW()`;`data.lockUntil` 返回 ISO 8601 剩余截止时刻 | + +## 业务规则 + +1. **账号查找**:以 `sUsername` 全等匹配(大小写敏感,与建表 collation `utf8mb4_unicode_ci` 行为一致,登录时不做规范化)。 +2. **作废态优先**:若用户记录 `iIsDeleted = 1`,直接返 `40103`,不进入密码校验,**不**累加 `iFailedLoginCount`。 +3. **锁定优先**:若用户 `tLockUntil` 不为空且大于当前时间,直接返 `42301`,**不**进入密码校验,**不**累加。锁定到期(`tLockUntil <= NOW()`)视为已解锁,正常进入密码校验。 +4. **密码校验**:使用 Spring Security `BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)`。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——`BCryptPasswordEncoder` 是 Spring Security 默认实现,无额外依赖;strength=10。 +5. **失败计数**:密码不匹配时 `sys_user.iFailedLoginCount += 1`。 + - 阈值:累计达到 **5 次**(含第 5 次)时,**同步**写 `sys_user.tLockUntil = NOW() + 30 分钟`;下一次(第 6 次)请求会落到锁定分支返 `42301`。 + - 阈值前的失败仍返 `40101`,**不**主动告诉客户端剩余尝试次数(防探测)。 +6. **登录成功**:原子事务(service 层 `@Transactional`)内: + - `sys_user.iFailedLoginCount = 0` + - `sys_user.tLockUntil = NULL` + - `sys_user.tLastLoginDate = NOW()` + - 然后签发 JWT(事务内构造 claim,事务提交后返响应)。 +7. **JWT claims**: + - `sub` = `sys_user.iIncrement`(数字主键,字符串化) + - `username` = `sys_user.sUsername` + - `userType` = `sys_user.sUserType` + - `companyCode` = 请求传入的 `companyCode`(已校验存在) + - `language` = `sys_user.sLanguage` + - `iat` / `exp` = 标准时间 claims(TTL 7200s) + - `jti` = UUID(为未来 refresh / 黑名单预留,本 REQ 不消费) +8. **JWT 密钥**:从 `.env.local` 的 `JWT_SECRET` 读取;`application.yml` 用 `${JWT_SECRET}` 占位符注入,代码层不允许硬编码。 + +## 边界与约束 + +- **HTTPS 传输**:密码以明文走 HTTPS body,生产部署由 Nginx 终止 TLS(docs/07 § 二);本 REQ 不在应用层加密 / 解密密码。 +- **统一响应**:全部走 `Result` / `Result.fail`(docs/04 § 1.3);错误响应禁回显堆栈。 +- **审计日志**:登录成功 / 失败 / 锁定均通过 Logback 写 INFO / WARN 日志(含 traceId 与 username),日志位置遵循 docs/04 / docs/07;本 REQ 不写额外的 DB 审计表。 +- **Redis**:docs/04 § 1.6 提及"签发后写 Redis",**本 REQ 不实现**——签发后不写 Redis,JWT 自包含可独立验证;登出黑名单功能推迟到后续 REQ。`pom.xml` 不强制要求 redis 依赖(如已加入也不使用)。 +- **并发安全**:失败计数 / 锁定写入位于同一事务;MySQL 默认 RR 隔离 + 行锁可避免并发同账号失败计数竞争。 +- **不实现**:管理员手动解锁、密码重置、refresh token、登出、多端互踢、Redis 黑名单——全部推迟到后续 REQ。 + +## 依赖的 schema 表 / 字段 + +读 + 写 `sys_user`(V1 已建): +- 读:`iIncrement`, `sUsername`, `sPasswordHash`, `sUserType`, `sLanguage`, `iEmployeeId`, `iIsDeleted`, `iFailedLoginCount`, `tLockUntil` +- 写:`iFailedLoginCount`, `tLockUntil`, `tLastLoginDate` + +只读 `sys_company`(V1 已建): +- 读:`sCompanyCode`, `iIsDeleted` + +只读 JOIN `sys_employee`(V1 已建): +- 读:`sEmployeeName` + +**本 REQ 不需要新增 migration**(schema 已就绪)。 + +## 依赖的接口 + +- 本 REQ 提供:`POST /api/v1/auth/login`(docs/05 § module_usr)。 +- 外部依赖(前端会调,但本 REQ 范围不实现):`GET /api/v1/companies`(公司下拉),由后续运营模块或前端阶段使用 fixture 数据替代。 + +## 验收标准 + +后端集成测试(不依赖前端): + +1. **正确凭据 + 启用账号 + 存在公司** → 200,返回 `accessToken`(非空 JWT),`userInfo` 字段完整;`sys_user.tLastLoginDate` 更新、`iFailedLoginCount = 0`、`tLockUntil IS NULL`。 +2. **JWT 可验签**:用 `JWT_SECRET` HS256 验签通过;`sub` = `userId` 字符串;`exp - iat == 7200`。 +3. **错误密码 N 次(N<5)** → 401 / 40101;`iFailedLoginCount = N`;`tLockUntil` 仍为 NULL。 +4. **第 5 次错误密码** → 401 / 40101;`iFailedLoginCount = 5`;`tLockUntil` 被写为 NOW() + 30 分钟。 +5. **锁定期间第 6 次(任意密码)** → 423 / 42301;返回 `data.lockUntil` 字段;`iFailedLoginCount` 不再累加。 +6. **锁定到期后** → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。 +7. **作废账号(iIsDeleted=1)正确密码** → 401 / 40103;`iFailedLoginCount` 不变。 +8. **用户名不存在** → 401 / 40101(与密码错误同文案)。 +9. **公司不存在 / 已删除** → 400 / 40004,**不**累加失败计数(先于密码校验完成公司校验)。 +10. **请求体缺字段 / 越长** → 400 / 40001。 +11. **响应不回显堆栈**:故意制造服务端异常时,响应 `message` 为通用文案,无 stack trace。 +12. **日志记录**:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。