Commit c8f6f04e7caa5c9be781fe242826dc2b90cffff1
1 parent
3d2c0ad3
chore(usr): REQ-USR-001 review approve + 归档 spec/plan/review
Showing
4 changed files
with
630 additions
and
1 deletions
docs/08-模块任务管理.md
docs/superpowers/plans/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-001.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-001 用户登录 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `POST /api/v1/auth/login`,按 spec 完成 4 类校验(公司 / 作废 / 锁定 / 密码)+ 失败计数 + 锁定写入 + JWT 签发,并落地后端项目骨架以支撑后续 REQ。 | |
| 12 | + | |
| 13 | +**Architecture:** | |
| 14 | +- Spring Boot 3 + MyBatis-Plus + Flyway + BCrypt + JJWT (HS256);分层 controller → service(impl) → mapper(docs/04 § 1.1)。 | |
| 15 | +- 业务逻辑全部在 `LoginServiceImpl`,事务边界为成功路径的"清零计数 + 更新登录时间 + 签发 JWT"原子提交。 | |
| 16 | +- 失败逻辑(计数累加 + 锁定写入)在独立事务里执行;锁定时间通过 `tLockUntil` 字段比对 `NOW()` 判定(无需 Redis)。 | |
| 17 | +- 错误码集中在 `ErrorCode` 常量类;`GlobalExceptionHandler` 把 `BizException` 转 `Result.fail`。 | |
| 18 | + | |
| 19 | +**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。 | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## Schema 改动 | |
| 24 | + | |
| 25 | +无。V1 已建好 `sys_user` / `sys_company` / `sys_employee`,本 REQ 不动 schema。 | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 文件变更清单 | |
| 30 | + | |
| 31 | +**Bootstrap(首次 REQ 一次性投入,后续 REQ 复用):** | |
| 32 | +- `backend/pom.xml` — Create(Maven POM,声明依赖与插件) | |
| 33 | +- `backend/src/main/java/com/xly/erp/Application.java` — Create(Spring Boot 启动类) | |
| 34 | +- `backend/src/main/resources/application.yml` — Create(主配置,从 `.env.local` / 环境变量读敏感项) | |
| 35 | +- `backend/src/main/resources/application-test.yml` — Create(测试 profile,复用相同 schema 但禁日志彩色) | |
| 36 | +- `backend/src/main/resources/logback-spring.xml` — Create(最小 logback 配置) | |
| 37 | + | |
| 38 | +**通用基础层(首次 REQ 一次性投入):** | |
| 39 | +- `backend/src/main/java/com/xly/erp/common/response/Result.java` — Create | |
| 40 | +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — Create | |
| 41 | +- `backend/src/main/java/com/xly/erp/common/exception/BizException.java` — Create | |
| 42 | +- `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` — Create | |
| 43 | +- `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` — Create | |
| 44 | +- `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` — Create(`BCryptPasswordEncoder` Bean) | |
| 45 | + | |
| 46 | +**业务层(REQ-USR-001 专属):** | |
| 47 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` — Create | |
| 48 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` — Create | |
| 49 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` — Create(只读 join 用,最小字段) | |
| 50 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Create | |
| 51 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` — Create | |
| 52 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` — Create | |
| 53 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` — Create | |
| 54 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` — Create | |
| 55 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` — Create | |
| 56 | +- `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` — Create(接口) | |
| 57 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` — Create | |
| 58 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` — Create | |
| 59 | + | |
| 60 | +**测试:** | |
| 61 | +- `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` — Create(unit/integration with Spring Test + 真实 MySQL,按 spec 验收 1-10 项) | |
| 62 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` — Create(`MockMvc` 端到端覆盖错误码 40001 / 40004 / 40101 / 40103 / 42301) | |
| 63 | +- `backend/src/test/resources/sql/req-usr-001-seed.sql` — Create(测试 fixture:1 个启用用户、1 个作废用户、2 个公司、1 个员工) | |
| 64 | + | |
| 65 | +--- | |
| 66 | + | |
| 67 | +## 约束常量(跨任务,写死不允许漂移) | |
| 68 | + | |
| 69 | +**错误码**(`ErrorCode` 常量类): | |
| 70 | + | |
| 71 | +| 常量名 | 值 | HTTP | | |
| 72 | +|---|---|---| | |
| 73 | +| `OK` | `200` | 200 | | |
| 74 | +| `BAD_REQUEST` | `40001` | 400 | | |
| 75 | +| `COMPANY_NOT_FOUND` | `40004` | 400 | | |
| 76 | +| `BAD_CREDENTIALS` | `40101` | 401 | | |
| 77 | +| `ACCOUNT_DELETED` | `40103` | 401 | | |
| 78 | +| `ACCOUNT_LOCKED` | `42301` | 423 | | |
| 79 | +| `INTERNAL_ERROR` | `50000` | 500 | | |
| 80 | + | |
| 81 | +**JWT 常量**:算法 HS256;TTL 7200 秒;claims 名 `sub` / `username` / `userType` / `companyCode` / `language` / `iat` / `exp` / `jti`;签名密钥从 `application.yml` 注入 `${JWT_SECRET}`(已在 `.env.local` 配置)。 | |
| 82 | + | |
| 83 | +**锁定策略**:阈值 5 次(含第 5 次触发锁定);锁定时长 30 分钟。Magic number 集中在 `LoginServiceImpl` 私有常量 `MAX_FAILED_LOGIN_COUNT = 5` 与 `LOCK_DURATION_MINUTES = 30L`。 | |
| 84 | + | |
| 85 | +**API 形状**: | |
| 86 | + | |
| 87 | +``` | |
| 88 | +POST /api/v1/auth/login (公开接口,无需 Bearer) | |
| 89 | +Request: LoginReq { username:String, password:String, companyCode:String } | |
| 90 | +Response: Result<LoginVo> | |
| 91 | + LoginVo { accessToken:String, tokenType:"Bearer", expiresInSec:7200, userInfo:UserInfoVo } | |
| 92 | + UserInfoVo { userId:int, username:String, userType:String, language:String, | |
| 93 | + employeeName:String?, companyCode:String } | |
| 94 | +``` | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +## 任务步骤 | |
| 99 | + | |
| 100 | +### Task 1: Bootstrap Spring Boot 项目骨架 | |
| 101 | + | |
| 102 | +**Files:** | |
| 103 | +- Create: `backend/pom.xml` | |
| 104 | +- Create: `backend/src/main/java/com/xly/erp/Application.java` | |
| 105 | +- Create: `backend/src/main/resources/application.yml` | |
| 106 | +- Create: `backend/src/main/resources/application-test.yml` | |
| 107 | +- Create: `backend/src/main/resources/logback-spring.xml` | |
| 108 | +- Create: `backend/src/test/java/com/xly/erp/ApplicationContextTest.java` | |
| 109 | + | |
| 110 | +**配置要点**(POM 内容由 TDD 实现,签名约束如下): | |
| 111 | +- Parent: `spring-boot-starter-parent:3.3.x`,Java 17 | |
| 112 | +- 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` | |
| 113 | +- Flyway 自动启用:默认指向 classpath:db/migration,本项目改为 `classpath:db/migration` 同步指向仓库根 `sql/migrations/` —— **由 Spring Boot 配置加载实现**:在 `application.yml` 写 `spring.flyway.locations: filesystem:../sql/migrations`(相对 backend/ 目录) | |
| 114 | +- `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 `<environmentVariables>` 注入或测试自行 `@TestPropertySource` 覆盖) | |
| 115 | + | |
| 116 | +> 注:以上"如何加载 .env.local"是 Spring Boot 项目的通用工程难题,本任务**最小可行实现**:测试用 `@DynamicPropertySource` 从 `System.getenv()` 注入;启动用 OS 环境变量。生产部署后续 REQ 再优化。 | |
| 117 | + | |
| 118 | +**API shape**: | |
| 119 | +- `Application.main(String[])` — 标准 `SpringApplication.run` | |
| 120 | +- `ApplicationContextTest#contextLoads()` — Spring Test 验证 ApplicationContext 启动 | |
| 121 | + | |
| 122 | +- [ ] **Step 1: 写失败测试** | |
| 123 | + - 测试名: `ApplicationContextTest#contextLoads` | |
| 124 | + - 意图: 验证 Spring Boot context 能启动;Flyway 能连接到 .env.local 指定的 MySQL 并发现 V1 已 apply(含 `flyway_schema_history` 表) | |
| 125 | + - 子会话确认 FAIL(pom.xml 不存在 / Application 类不存在) | |
| 126 | + | |
| 127 | +- [ ] **Step 2: 实现最小代码** | |
| 128 | + - 写 pom.xml(依赖如上)、Application.java、application.yml、application-test.yml、logback-spring.xml | |
| 129 | + - 在 `application-test.yml` 中通过 `@DynamicPropertySource` 或 `spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_SCHEMA}` 占位符让测试读 env | |
| 130 | + | |
| 131 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 132 | + - 子会话跑 `cd backend && ./mvnw -B test -Dtest=ApplicationContextTest` 应绿 | |
| 133 | + | |
| 134 | +- [ ] **Step 4: Commit** | |
| 135 | + - `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` | |
| 136 | + - `git commit -m "feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001"` | |
| 137 | + | |
| 138 | +--- | |
| 139 | + | |
| 140 | +### Task 2: 通用响应包装 + 异常处理 | |
| 141 | + | |
| 142 | +**Files:** | |
| 143 | +- Create: `backend/src/main/java/com/xly/erp/common/response/Result.java` | |
| 144 | +- Create: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 145 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/BizException.java` | |
| 146 | +- Create: `backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java` | |
| 147 | +- Create: `backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java` | |
| 148 | + | |
| 149 | +**API shape:** | |
| 150 | +- `Result<T> { int code; String message; T data; long timestamp; static <T> Result<T> ok(T data); static Result<?> fail(int code, String message); }` | |
| 151 | +- `ErrorCode` — 常量类,含上方"约束常量"表中所有 code(int 字段) | |
| 152 | +- `BizException extends RuntimeException { int code; String message; BizException(int code, String message); }` | |
| 153 | +- `GlobalExceptionHandler` — `@RestControllerAdvice`,handles: | |
| 154 | + - `BizException` → 按其 `code` 映射到 `ResponseEntity<Result>`,HTTP 状态按 ErrorCode 表 | |
| 155 | + - `MethodArgumentNotValidException` / `ConstraintViolationException` → `Result.fail(40001, ...)`,HTTP 400 | |
| 156 | + - `Exception`(兜底) → `Result.fail(50000, "服务器内部错误")`,HTTP 500,记 ERROR 日志,**不**回显堆栈到 message | |
| 157 | + | |
| 158 | +- [ ] **Step 1: 写失败测试** | |
| 159 | + - 测试名: `GlobalExceptionHandlerTest#bizException_returnsCodeAndHttpStatus` 等 3 个测试 | |
| 160 | + - 意图: 用 `MockMvc` 配合一个 `/_test/throw-biz` 测试 controller 触发 `BizException(42301, "...")`,断言 HTTP 423 + body `code=42301`;类似覆盖 `BizException(40101, ...)` → 401,以及兜底 `RuntimeException` → 500 且 message 不含 "java." 前缀 | |
| 161 | + - 子会话确认 FAIL | |
| 162 | + | |
| 163 | +- [ ] **Step 2: 实现最小代码** | |
| 164 | + | |
| 165 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 166 | + | |
| 167 | +- [ ] **Step 4: Commit** | |
| 168 | + - `git commit -m "feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001"` | |
| 169 | + | |
| 170 | +--- | |
| 171 | + | |
| 172 | +### Task 3: JWT 工具 + 密码编码器 Bean | |
| 173 | + | |
| 174 | +**Files:** | |
| 175 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtUtil.java` | |
| 176 | +- Create: `backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java` | |
| 177 | +- Create: `backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java` | |
| 178 | + | |
| 179 | +**API shape:** | |
| 180 | +- `JwtUtil#issue(Map<String,Object> claims, long ttlSec) : String` — 用 `${JWT_SECRET}`(注入 `@Value`)签发 HS256 JWT,含 `iat` / `exp` / `jti(=UUID)` | |
| 181 | +- `JwtUtil#parse(String token) : Map<String,Object>` — 验签 + 解析,签名错或过期抛 `BizException(40101, ...)` | |
| 182 | +- `PasswordEncoderConfig#passwordEncoder() : BCryptPasswordEncoder` — Spring Bean,strength=10 | |
| 183 | + | |
| 184 | +- [ ] **Step 1: 写失败测试** | |
| 185 | + - 测试名: `JwtUtilTest#issuedToken_canBeParsedBackToClaims` / `JwtUtilTest#tamperedToken_throwsBizException` / `JwtUtilTest#expiredToken_throwsBizException`(用 ttl=0 模拟) | |
| 186 | + - 意图: 验证签发→解析往返一致;篡改任意一字节抛 40101;过期抛 40101 | |
| 187 | + | |
| 188 | +- [ ] **Step 2: 实现最小代码** | |
| 189 | + - JWT_SECRET 来自 `application-test.yml` 的 `jwt.secret: ${JWT_SECRET:test-secret-256bit-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}` | |
| 190 | + | |
| 191 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 192 | + | |
| 193 | +- [ ] **Step 4: Commit** | |
| 194 | + - `git commit -m "feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001"` | |
| 195 | + | |
| 196 | +--- | |
| 197 | + | |
| 198 | +### Task 4: Entity + Mapper(sys_user / sys_company / sys_employee) | |
| 199 | + | |
| 200 | +**Files:** | |
| 201 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUser.java` | |
| 202 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysCompany.java` | |
| 203 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysEmployee.java` | |
| 204 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` | |
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java` | |
| 206 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysEmployeeMapper.java` | |
| 207 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` | |
| 208 | +- Create: `backend/src/test/resources/sql/req-usr-001-seed.sql` | |
| 209 | + | |
| 210 | +**API shape:** | |
| 211 | +- `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)` | |
| 212 | +- `SysCompany` — 含 `iIncrement / sCompanyCode / sCompanyName / iIsDeleted`(最小字段) | |
| 213 | +- `SysEmployee` — 含 `iIncrement / sEmployeeName / iDepartmentId`(最小字段) | |
| 214 | +- Mapper 三个均 `extends BaseMapper<T>`(MyBatis-Plus);`SysUserMapper` 额外定义一个方法:`selectByUsername(String username) : SysUser`(@Select 注解),用于登录查找 | |
| 215 | + | |
| 216 | +**Seed SQL** 内容(写死): | |
| 217 | +```sql | |
| 218 | +-- req-usr-001-seed.sql | |
| 219 | +DELETE FROM sys_user_permission_category; | |
| 220 | +DELETE FROM sys_user; | |
| 221 | +DELETE FROM sys_employee; | |
| 222 | +DELETE FROM sys_company; | |
| 223 | + | |
| 224 | +INSERT INTO sys_company (sCompanyName, sCompanyCode, iIsDeleted) VALUES | |
| 225 | + ('总部', 'HQ', 0), | |
| 226 | + ('已删公司', 'DEL_CO', 1); | |
| 227 | + | |
| 228 | +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) | |
| 229 | +SELECT '张三', 'E001', 1 FROM (SELECT 1) t | |
| 230 | +WHERE EXISTS (SELECT 1 FROM sys_department LIMIT 1); | |
| 231 | +-- 若 sys_department 为空,先插入一行 | |
| 232 | +INSERT INTO sys_department (sDepartmentName, sDepartmentCode) VALUES ('技术部', 'TECH'); | |
| 233 | +INSERT INTO sys_employee (sEmployeeName, sEmployeeCode, iDepartmentId) | |
| 234 | + SELECT '张三', 'E001', iIncrement FROM sys_department WHERE sDepartmentCode='TECH' LIMIT 1; | |
| 235 | + | |
| 236 | +-- password = 'Password1!' 的 BCrypt(strength=10) 哈希(TDD 阶段实际生成填入) | |
| 237 | +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, iEmployeeId, sUserType, sLanguage, iIsDeleted, iFailedLoginCount, sCreatedBy) | |
| 238 | + SELECT 'alice', 'U001', '<BCRYPT_HASH_OF_Password1!>', iIncrement, 'NORMAL', 'zh-CN', 0, 0, 'system' | |
| 239 | + FROM sys_employee WHERE sEmployeeCode='E001'; | |
| 240 | + | |
| 241 | +INSERT INTO sys_user (sUsername, sUserCode, sPasswordHash, sUserType, sLanguage, iIsDeleted, sCreatedBy) | |
| 242 | + VALUES ('bob_deleted', 'U002', '<BCRYPT_HASH_OF_Password1!>', 'NORMAL', 'zh-CN', 1, 'system'); | |
| 243 | +``` | |
| 244 | +> `<BCRYPT_HASH_OF_Password1!>` 在 Task 4 实现时通过一次性 java main 或 `BCryptPasswordEncoder` 调用生成后填入;不允许保留占位符进 commit。 | |
| 245 | + | |
| 246 | +- [ ] **Step 1: 写失败测试** | |
| 247 | + - 测试名: `SysUserMapperTest#selectByUsername_returnsUserWithAllFields` / `SysUserMapperTest#selectByUsername_returnsNullWhenNotFound` | |
| 248 | + - 意图: seed 后查 `alice` → 字段完整;查 `nobody` → null | |
| 249 | + | |
| 250 | +- [ ] **Step 2: 实现最小代码** | |
| 251 | + | |
| 252 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 253 | + | |
| 254 | +- [ ] **Step 4: Commit** | |
| 255 | + - `git commit -m "feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001"` | |
| 256 | + | |
| 257 | +--- | |
| 258 | + | |
| 259 | +### Task 5: LoginReq DTO + LoginVo + UserInfoVo | |
| 260 | + | |
| 261 | +**Files:** | |
| 262 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginReq.java` | |
| 263 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginVo.java` | |
| 264 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserInfoVo.java` | |
| 265 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginReqValidationTest.java` | |
| 266 | + | |
| 267 | +**API shape:** | |
| 268 | +- `LoginReq { @NotBlank @Size(max=50) String username; @NotBlank @Size(max=128) String password; @NotBlank @Size(max=50) String companyCode; }` | |
| 269 | +- `LoginVo { String accessToken; String tokenType; long expiresInSec; UserInfoVo userInfo; }` | |
| 270 | +- `UserInfoVo { Integer userId; String username; String userType; String language; String employeeName; String companyCode; }` | |
| 271 | + | |
| 272 | +- [ ] **Step 1: 写失败测试** | |
| 273 | + - 测试名: `LoginReqValidationTest#blankUsername_fails` / `LoginReqValidationTest#tooLongUsername_fails` / `LoginReqValidationTest#allFieldsPresent_passes` | |
| 274 | + - 意图: 用 `Validator` 校验 jakarta 约束注解工作正常 | |
| 275 | + | |
| 276 | +- [ ] **Step 2: 实现最小代码** | |
| 277 | + | |
| 278 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 279 | + | |
| 280 | +- [ ] **Step 4: Commit** | |
| 281 | + - `git commit -m "feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001"` | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 285 | +### Task 6: LoginService 接口骨架 | |
| 286 | + | |
| 287 | +**Files:** | |
| 288 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | |
| 289 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 290 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 291 | + | |
| 292 | +**API shape:** | |
| 293 | +- `LoginService#login(String username, String password, String companyCode) : LoginVo` — 业务方法签名 | |
| 294 | +- `LoginServiceImpl implements LoginService` — `@Service`,注入 `SysUserMapper`、`SysCompanyMapper`、`SysEmployeeMapper`、`BCryptPasswordEncoder`、`JwtUtil` | |
| 295 | +- `LoginServiceImpl` 私有常量:`MAX_FAILED_LOGIN_COUNT = 5`、`LOCK_DURATION_MINUTES = 30L`、`TOKEN_TTL_SEC = 7200L` | |
| 296 | + | |
| 297 | +本 task 仅产出**接口 + 空实现 + 一个 baseline 测试**(直接抛 UnsupportedOperationException),用于建立后续 task 的脚手架。 | |
| 298 | + | |
| 299 | +- [ ] **Step 1: 写失败测试** | |
| 300 | + - 测试名: `LoginServiceImplTest#contextLoads`(验证 LoginService Bean 注入成功;与 `@SpringBootTest` + seed.sql 一起工作) | |
| 301 | + - 意图: Bean 装配可用 | |
| 302 | + | |
| 303 | +- [ ] **Step 2: 实现最小代码** | |
| 304 | + | |
| 305 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 306 | + | |
| 307 | +- [ ] **Step 4: Commit** | |
| 308 | + - `git commit -m "feat(usr): LoginService 接口骨架 REQ-USR-001"` | |
| 309 | + | |
| 310 | +--- | |
| 311 | + | |
| 312 | +### Task 7: Login — 公司不存在或已删 → 40004 | |
| 313 | + | |
| 314 | +**Files:** | |
| 315 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 316 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 317 | + | |
| 318 | +**API behavior**:当 `companyCode` 在 `sys_company` 查不到(不存在 OR `iIsDeleted=1`)→ 抛 `BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除")`;**不**触碰 sys_user。 | |
| 319 | + | |
| 320 | +- [ ] **Step 1: 写失败测试** | |
| 321 | + - 测试名: `LoginServiceImplTest#login_unknownCompany_throws40004` / `login_softDeletedCompany_throws40004` | |
| 322 | + - 意图: 用 seed 中的 `HQ`(正常)vs `NOPE`(不存在)vs `DEL_CO`(软删)触发不同分支;断言抛 `BizException` 且 `code == 40004`;同时断言 `alice` 的 `iFailedLoginCount` 仍为 0(未被错误计入失败) | |
| 323 | + | |
| 324 | +- [ ] **Step 2: 实现最小代码** | |
| 325 | + | |
| 326 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 327 | + | |
| 328 | +- [ ] **Step 4: Commit** | |
| 329 | + - `git commit -m "feat(usr): 登录校验公司存在性 REQ-USR-001"` | |
| 330 | + | |
| 331 | +--- | |
| 332 | + | |
| 333 | +### Task 8: Login — 用户不存在 / 密码错 → 40101(同文案,含失败计数) | |
| 334 | + | |
| 335 | +**Files:** | |
| 336 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 337 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 338 | + | |
| 339 | +**API behavior**: | |
| 340 | +- 用户名不存在 → `BizException(40101, "用户名或密码错误")`;不写 DB(无 user 行可写) | |
| 341 | +- 用户存在 + 密码 hash 不匹配 → `sys_user.iFailedLoginCount += 1`,返 `BizException(40101, "用户名或密码错误")` | |
| 342 | + | |
| 343 | +- [ ] **Step 1: 写失败测试** | |
| 344 | + - 测试名: | |
| 345 | + - `login_unknownUser_throws40101_noDbWrite` | |
| 346 | + - `login_badPassword_throws40101_andIncrementsFailCount` (断言一次错误后 `iFailedLoginCount == 1`) | |
| 347 | + - 意图: 防用户名枚举 + 失败计数累加 | |
| 348 | + | |
| 349 | +- [ ] **Step 2: 实现最小代码** | |
| 350 | + | |
| 351 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 352 | + | |
| 353 | +- [ ] **Step 4: Commit** | |
| 354 | + - `git commit -m "feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001"` | |
| 355 | + | |
| 356 | +--- | |
| 357 | + | |
| 358 | +### Task 9: Login — 失败 5 次锁定 → 第 6 次返 42301 | |
| 359 | + | |
| 360 | +**Files:** | |
| 361 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 362 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 363 | + | |
| 364 | +**API behavior**: | |
| 365 | +- 累计第 5 次错误密码时,在同一事务里写 `tLockUntil = NOW() + 30 分钟`;本次响应仍返 `40101`(**不**在第 5 次直接返锁定,避免泄露阈值) | |
| 366 | +- 后续请求遇到 `tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 直接抛 `BizException(ACCOUNT_LOCKED, "账号已锁定,请稍后再试")`,HTTP 423;**不**计入失败次数 | |
| 367 | +- `tLockUntil <= NOW()`(锁定到期)→ 视为已解锁,正常进入密码校验流程(即仍允许累加失败、仍允许成功) | |
| 368 | + | |
| 369 | +- [ ] **Step 1: 写失败测试** | |
| 370 | + - 测试名: | |
| 371 | + - `login_5thBadPassword_setsLockUntil_andStillReturns40101`(断言 `iFailedLoginCount == 5` 且 `tLockUntil` 不为空 ≥ NOW()+29min) | |
| 372 | + - `login_duringLockWindow_throws42301_noCountIncrement` | |
| 373 | + - `login_afterLockExpired_allowsNewAttempt`(用 SQL 把 tLockUntil 改成过去时刻,再次登录正确密码 → 应成功并清零计数;属于先做小验证,可在 Task 10 完整覆盖成功路径) | |
| 374 | + - 意图: 锁定语义闭环 | |
| 375 | + | |
| 376 | +- [ ] **Step 2: 实现最小代码** | |
| 377 | + | |
| 378 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 379 | + | |
| 380 | +- [ ] **Step 4: Commit** | |
| 381 | + - `git commit -m "feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001"` | |
| 382 | + | |
| 383 | +--- | |
| 384 | + | |
| 385 | +### Task 10: Login — 作废账号 → 40103;成功 → 签 JWT + 清零 + 更新登录时间 | |
| 386 | + | |
| 387 | +**Files:** | |
| 388 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 389 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 390 | + | |
| 391 | +**API behavior**: | |
| 392 | +- `sys_user.iIsDeleted == 1` → `BizException(ACCOUNT_DELETED, "账号已被作废,禁止登录")`,HTTP 401;**不**进入密码校验,**不**累加失败 | |
| 393 | +- 成功路径(`@Transactional`): | |
| 394 | + 1. `iFailedLoginCount = 0`,`tLockUntil = NULL`,`tLastLoginDate = NOW()`(一次 UPDATE) | |
| 395 | + 2. 加载 `sys_employee.sEmployeeName`(若 `iEmployeeId` 非空) | |
| 396 | + 3. 构造 JWT claims(`sub=userId`, `username`, `userType`, `companyCode`, `language`, `jti=UUID`),通过 `JwtUtil.issue(claims, TOKEN_TTL_SEC)` | |
| 397 | + 4. 返回 `LoginVo` | |
| 398 | + | |
| 399 | +判定顺序(先后明确):1) 公司校验 → 2) 用户查找 → 3) 作废校验 → 4) 锁定校验 → 5) 密码校验 → 6) 成功路径。 | |
| 400 | + | |
| 401 | +- [ ] **Step 1: 写失败测试** | |
| 402 | + - 测试名: | |
| 403 | + - `login_deletedUser_throws40103_noCountIncrement` | |
| 404 | + - `login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin` | |
| 405 | + - `login_success_jwtParsesBack_with_sub_username_companyCode` | |
| 406 | + - 意图: 成功路径与作废路径都覆盖;JWT 验签往返 | |
| 407 | + | |
| 408 | +- [ ] **Step 2: 实现最小代码** | |
| 409 | + | |
| 410 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 411 | + | |
| 412 | +- [ ] **Step 4: Commit** | |
| 413 | + - `git commit -m "feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001"` | |
| 414 | + | |
| 415 | +--- | |
| 416 | + | |
| 417 | +### Task 11: AuthController + 端到端 MockMvc 测试 | |
| 418 | + | |
| 419 | +**Files:** | |
| 420 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java` | |
| 421 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java` | |
| 422 | + | |
| 423 | +**API shape:** | |
| 424 | +- `AuthController` — `@RestController @RequestMapping("/api/v1/auth")` | |
| 425 | +- `POST /api/v1/auth/login` 方法 `login(@RequestBody @Valid LoginReq req) : Result<LoginVo>`,委托给 `LoginService` | |
| 426 | + | |
| 427 | +- [ ] **Step 1: 写失败测试** | |
| 428 | + - 测试名(`AuthControllerTest` 用 `@SpringBootTest + MockMvc`): | |
| 429 | + - `post_login_success_returns200_andLoginVo` | |
| 430 | + - `post_login_badCredentials_returns401_code40101` | |
| 431 | + - `post_login_lockedAccount_returns423_code42301` | |
| 432 | + - `post_login_deletedAccount_returns401_code40103` | |
| 433 | + - `post_login_unknownCompany_returns400_code40004` | |
| 434 | + - `post_login_blankUsername_returns400_code40001` | |
| 435 | + - 意图: 6 个 HTTP 路径全部覆盖 spec § 验收 1-10 | |
| 436 | + | |
| 437 | +- [ ] **Step 2: 实现最小代码** | |
| 438 | + | |
| 439 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 440 | + | |
| 441 | +- [ ] **Step 4: Commit** | |
| 442 | + - `git commit -m "feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001"` | |
| 443 | + | |
| 444 | +--- | |
| 445 | + | |
| 446 | +## 提交计划 | |
| 447 | + | |
| 448 | +按 task 顺序产生 11 个 commit: | |
| 449 | + | |
| 450 | +| Task | Commit message | | |
| 451 | +|---|---| | |
| 452 | +| 1 | `feat(usr): bootstrap spring boot 后端骨架 REQ-USR-001` | | |
| 453 | +| 2 | `feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001` | | |
| 454 | +| 3 | `feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001` | | |
| 455 | +| 4 | `feat(usr): sys_user/sys_company/sys_employee entity + mapper REQ-USR-001` | | |
| 456 | +| 5 | `feat(usr): LoginReq + LoginVo + UserInfoVo REQ-USR-001` | | |
| 457 | +| 6 | `feat(usr): LoginService 接口骨架 REQ-USR-001` | | |
| 458 | +| 7 | `feat(usr): 登录校验公司存在性 REQ-USR-001` | | |
| 459 | +| 8 | `feat(usr): 登录用户名/密码错误统一返回 40101 + 累加失败计数 REQ-USR-001` | | |
| 460 | +| 9 | `feat(usr): 登录失败 5 次锁定 30 分钟 REQ-USR-001` | | |
| 461 | +| 10 | `feat(usr): 登录成功签发 JWT + 作废账号 40103 REQ-USR-001` | | |
| 462 | +| 11 | `feat(usr): POST /api/v1/auth/login controller + 端到端测试 REQ-USR-001` | | ... | ... |
docs/superpowers/reviews/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 2 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-001 — round 2 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- 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 提醒切换数据库方言时复查 | |
| 19 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java:66 — lockUntil 在 service 层 ISO 字符串化;建议改为直接放 LocalDateTime 让 Jackson 全局规则统一处理 | |
| 20 | +- backend/src/main/java/com/xly/erp/common/exception/BizException.java:25 — (int, String, Throwable) 构造的 data=null 是合理默认;未来若有"包装异常 + data"诉求再加 4 参构造 | |
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java:171 — 并发测试用 2×2 低于阈值;可补 3×2 高于阈值场景断言 lock 触发 + count 准确 | |
| 22 | + | |
| 23 | +## 反例 / 测试覆盖缺口 | |
| 24 | + | |
| 25 | +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 测试全过。 | |
| 26 | + | |
| 27 | +## 本轮变更归档 | |
| 28 | + | |
| 29 | +Round 1 修复全部正确落地: | |
| 30 | + | |
| 31 | +| # | 项目 | 状态 | | |
| 32 | +|---|------|-----| | |
| 33 | +| H1 | 42301 data.lockUntil | ✓ BizException 扩 data 链路完整 | | |
| 34 | +| H2 | 失败计数原子 UPDATE | ✓ 单 SQL,去 noRollbackFor,并发回归覆盖 | | |
| 35 | +| H3 | SELECT * → 显式列 | ✓ 两个 mapper 都改 | | |
| 36 | +| M5 | JWT 短密钥静默补零 | ✓ < 32 字节 PostConstruct 硬抛 | | |
| 37 | +| M6 | loginSuccess 双路径 | ✓ 去 entity,走 markLoginSuccess SQL | | |
| 38 | +| NTH | exp-iat==7200 / lockUntil / 并发原子累加 测试 | ✓ 全部新增 | | |
| 39 | + | |
| 40 | +未引入新回归。Approve。 | ... | ... |
docs/superpowers/specs/2026-05-15-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-05-15 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-001 — 用户登录 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +提供用户名 + 密码 + 公司编码的登录接口,校验通过后签发 JWT access token(HS256,TTL 2 小时)并返回当前用户基础信息,用于后续接口的 Bearer 鉴权。本 REQ 仅签发 access token,refresh token 推迟到后续 REQ(与 docs/05 LoginVo 一致;docs/04 § 1.6 的 refresh 描述视为未来增量,本 REQ 范围内不实现)。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +HTTP 入口 `POST /api/v1/auth/login`,公开接口(无需 Bearer)。 | |
| 16 | + | |
| 17 | +**请求体 LoginReq**(JSON): | |
| 18 | + | |
| 19 | +| 字段 | 类型 | 必填 | 校验规则(登录场景,宽松,不复用创建场景的强度规则) | | |
| 20 | +|---|---|---|---| | |
| 21 | +| `username` | string | 是 | 非空;长度 1-50(容纳潜在历史账号;越界返 `40001`) | | |
| 22 | +| `password` | string | 是 | 非空;长度 1-128(密码强度只在创建/重置流程校验,登录只校验匹配;越界返 `40001`) | | |
| 23 | +| `companyCode` | string | 是 | 非空;最大长度 50;命中 `sys_company.sCompanyCode AND iIsDeleted=0`,否则返 `40004` | | |
| 24 | + | |
| 25 | +> **登录与创建的校验差异**:REQ-USR-002 创建用户时密码必须 8-20 位含大小写字母和数字(强度规则);本 REQ 登录只校验"非空且未超长",避免历史账号或未来规则放宽时无法登录。 | |
| 26 | + | |
| 27 | +## 输出 / 结果 | |
| 28 | + | |
| 29 | +**成功 200**:`Result<LoginVo>`,其中 `LoginVo`: | |
| 30 | + | |
| 31 | +```json | |
| 32 | +{ | |
| 33 | + "accessToken": "<JWT HS256, TTL=7200s>", | |
| 34 | + "tokenType": "Bearer", | |
| 35 | + "expiresInSec": 7200, | |
| 36 | + "userInfo": { | |
| 37 | + "userId": 123, | |
| 38 | + "username": "alice", | |
| 39 | + "userType": "NORMAL", | |
| 40 | + "language": "zh-CN", | |
| 41 | + "employeeName": "张三", | |
| 42 | + "companyCode": "HQ" | |
| 43 | + } | |
| 44 | +} | |
| 45 | +``` | |
| 46 | + | |
| 47 | +- `employeeName` 通过 `sys_user.iEmployeeId` JOIN `sys_employee.sEmployeeName` 取得;未绑定职员时字段省略。 | |
| 48 | +- 副作用:写库 `sys_user.iFailedLoginCount = 0`、`sys_user.tLockUntil = NULL`、`sys_user.tLastLoginDate = NOW()`。 | |
| 49 | + | |
| 50 | +**失败**:见错误码段,全部走全局异常处理器映射为 `Result.fail(code, message)`。错误码段位与 docs/05 一致: | |
| 51 | + | |
| 52 | +| HTTP | code | 含义 | 触发条件 | | |
| 53 | +|---|---|---|---| | |
| 54 | +| 400 | 40001 | 用户名或密码格式错误 | 必填字段缺失 / 类型错 / 长度越界 | | |
| 55 | +| 400 | 40004 | 公司不存在或已删除 | `companyCode` 在 `sys_company` 查不到(含 `iIsDeleted=1`) | | |
| 56 | +| 401 | 40101 | 用户名或密码错误 | 用户不存在 OR 密码哈希不匹配(统一文案,不区分两者,防用户名枚举) | | |
| 57 | +| 401 | 40103 | 账号已被作废,禁止登录 | `sys_user.iIsDeleted = 1` | | |
| 58 | +| 423 | 42301 | 账号已锁定,请稍后再试 | `sys_user.tLockUntil IS NOT NULL AND tLockUntil > NOW()`;`data.lockUntil` 返回 ISO 8601 剩余截止时刻 | | |
| 59 | + | |
| 60 | +## 业务规则 | |
| 61 | + | |
| 62 | +1. **账号查找**:以 `sUsername` 全等匹配(大小写敏感,与建表 collation `utf8mb4_unicode_ci` 行为一致,登录时不做规范化)。 | |
| 63 | +2. **作废态优先**:若用户记录 `iIsDeleted = 1`,直接返 `40103`,不进入密码校验,**不**累加 `iFailedLoginCount`。 | |
| 64 | +3. **锁定优先**:若用户 `tLockUntil` 不为空且大于当前时间,直接返 `42301`,**不**进入密码校验,**不**累加。锁定到期(`tLockUntil <= NOW()`)视为已解锁,正常进入密码校验。 | |
| 65 | +4. **密码校验**:使用 Spring Security `BCryptPasswordEncoder.matches(rawPassword, sPasswordHash)`。docs/03 业务注记里"BCrypt / Argon2" 二选一,本 REQ 锁定 BCrypt——`BCryptPasswordEncoder` 是 Spring Security 默认实现,无额外依赖;strength=10。 | |
| 66 | +5. **失败计数**:密码不匹配时 `sys_user.iFailedLoginCount += 1`。 | |
| 67 | + - 阈值:累计达到 **5 次**(含第 5 次)时,**同步**写 `sys_user.tLockUntil = NOW() + 30 分钟`;下一次(第 6 次)请求会落到锁定分支返 `42301`。 | |
| 68 | + - 阈值前的失败仍返 `40101`,**不**主动告诉客户端剩余尝试次数(防探测)。 | |
| 69 | +6. **登录成功**:原子事务(service 层 `@Transactional`)内: | |
| 70 | + - `sys_user.iFailedLoginCount = 0` | |
| 71 | + - `sys_user.tLockUntil = NULL` | |
| 72 | + - `sys_user.tLastLoginDate = NOW()` | |
| 73 | + - 然后签发 JWT(事务内构造 claim,事务提交后返响应)。 | |
| 74 | +7. **JWT claims**: | |
| 75 | + - `sub` = `sys_user.iIncrement`(数字主键,字符串化) | |
| 76 | + - `username` = `sys_user.sUsername` | |
| 77 | + - `userType` = `sys_user.sUserType` | |
| 78 | + - `companyCode` = 请求传入的 `companyCode`(已校验存在) | |
| 79 | + - `language` = `sys_user.sLanguage` | |
| 80 | + - `iat` / `exp` = 标准时间 claims(TTL 7200s) | |
| 81 | + - `jti` = UUID(为未来 refresh / 黑名单预留,本 REQ 不消费) | |
| 82 | +8. **JWT 密钥**:从 `.env.local` 的 `JWT_SECRET` 读取;`application.yml` 用 `${JWT_SECRET}` 占位符注入,代码层不允许硬编码。 | |
| 83 | + | |
| 84 | +## 边界与约束 | |
| 85 | + | |
| 86 | +- **HTTPS 传输**:密码以明文走 HTTPS body,生产部署由 Nginx 终止 TLS(docs/07 § 二);本 REQ 不在应用层加密 / 解密密码。 | |
| 87 | +- **统一响应**:全部走 `Result` / `Result.fail`(docs/04 § 1.3);错误响应禁回显堆栈。 | |
| 88 | +- **审计日志**:登录成功 / 失败 / 锁定均通过 Logback 写 INFO / WARN 日志(含 traceId 与 username),日志位置遵循 docs/04 / docs/07;本 REQ 不写额外的 DB 审计表。 | |
| 89 | +- **Redis**:docs/04 § 1.6 提及"签发后写 Redis",**本 REQ 不实现**——签发后不写 Redis,JWT 自包含可独立验证;登出黑名单功能推迟到后续 REQ。`pom.xml` 不强制要求 redis 依赖(如已加入也不使用)。 | |
| 90 | +- **并发安全**:失败计数 / 锁定写入位于同一事务;MySQL 默认 RR 隔离 + 行锁可避免并发同账号失败计数竞争。 | |
| 91 | +- **不实现**:管理员手动解锁、密码重置、refresh token、登出、多端互踢、Redis 黑名单——全部推迟到后续 REQ。 | |
| 92 | + | |
| 93 | +## 依赖的 schema 表 / 字段 | |
| 94 | + | |
| 95 | +读 + 写 `sys_user`(V1 已建): | |
| 96 | +- 读:`iIncrement`, `sUsername`, `sPasswordHash`, `sUserType`, `sLanguage`, `iEmployeeId`, `iIsDeleted`, `iFailedLoginCount`, `tLockUntil` | |
| 97 | +- 写:`iFailedLoginCount`, `tLockUntil`, `tLastLoginDate` | |
| 98 | + | |
| 99 | +只读 `sys_company`(V1 已建): | |
| 100 | +- 读:`sCompanyCode`, `iIsDeleted` | |
| 101 | + | |
| 102 | +只读 JOIN `sys_employee`(V1 已建): | |
| 103 | +- 读:`sEmployeeName` | |
| 104 | + | |
| 105 | +**本 REQ 不需要新增 migration**(schema 已就绪)。 | |
| 106 | + | |
| 107 | +## 依赖的接口 | |
| 108 | + | |
| 109 | +- 本 REQ 提供:`POST /api/v1/auth/login`(docs/05 § module_usr)。 | |
| 110 | +- 外部依赖(前端会调,但本 REQ 范围不实现):`GET /api/v1/companies`(公司下拉),由后续运营模块或前端阶段使用 fixture 数据替代。 | |
| 111 | + | |
| 112 | +## 验收标准 | |
| 113 | + | |
| 114 | +后端集成测试(不依赖前端): | |
| 115 | + | |
| 116 | +1. **正确凭据 + 启用账号 + 存在公司** → 200,返回 `accessToken`(非空 JWT),`userInfo` 字段完整;`sys_user.tLastLoginDate` 更新、`iFailedLoginCount = 0`、`tLockUntil IS NULL`。 | |
| 117 | +2. **JWT 可验签**:用 `JWT_SECRET` HS256 验签通过;`sub` = `userId` 字符串;`exp - iat == 7200`。 | |
| 118 | +3. **错误密码 N 次(N<5)** → 401 / 40101;`iFailedLoginCount = N`;`tLockUntil` 仍为 NULL。 | |
| 119 | +4. **第 5 次错误密码** → 401 / 40101;`iFailedLoginCount = 5`;`tLockUntil` 被写为 NOW() + 30 分钟。 | |
| 120 | +5. **锁定期间第 6 次(任意密码)** → 423 / 42301;返回 `data.lockUntil` 字段;`iFailedLoginCount` 不再累加。 | |
| 121 | +6. **锁定到期后** → 锁定字段自然过期,下一次正确密码登录 200 并清零计数。 | |
| 122 | +7. **作废账号(iIsDeleted=1)正确密码** → 401 / 40103;`iFailedLoginCount` 不变。 | |
| 123 | +8. **用户名不存在** → 401 / 40101(与密码错误同文案)。 | |
| 124 | +9. **公司不存在 / 已删除** → 400 / 40004,**不**累加失败计数(先于密码校验完成公司校验)。 | |
| 125 | +10. **请求体缺字段 / 越长** → 400 / 40001。 | |
| 126 | +11. **响应不回显堆栈**:故意制造服务端异常时,响应 `message` 为通用文案,无 stack trace。 | |
| 127 | +12. **日志记录**:每次登录成功 / 失败 / 锁定均有日志行(人工或 grep 抽样验证即可)。 | ... | ... |