--- req_id: REQ-USR-004 date: 2026-05-06 spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-004.md --- # REQ-USR-004 用户登录 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. **Goal:** 实现 `POST /api/auth/login`:BCrypt 校验密码 → 5 次失败锁定(内存)→ 签发 HS256 JWT → 回写 tLastLoginDate。 **Architecture:** 引入 jjwt 0.12.x 签发 JWT;`LoginAttemptStore` 接口 + `InMemoryLoginAttemptStore` 实现(spec 注:Redis 替换为后续 REQ);`JwtTokenProvider` 封装 sign/parse;`LoginService` 协调 4 步逻辑;`SecurityConfig` 白名单 `/api/auth/login`。本 REQ 不切换其他端点为 authenticated(技术债登记)。 **Tech Stack:** 沿用 + 新增 jjwt-api/impl/jackson 0.12.x。 --- ## Schema 改动 无。 ## 文件变更清单 - 修改: `backend/pom.xml` — 追加 jjwt 三件套 - 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 LOGIN_INVALID_CREDENTIALS(40101) / LOGIN_ACCOUNT_LOCKED(40301) - 修改: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 白名单 `/api/auth/login` - 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`(含嵌套 LoginUserInfo) - 创建: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` — sign/parse 封装 - 创建: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 - 创建: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 - 创建: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` - 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 2 个错误码断言 - 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` - 创建: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` - 创建: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` - 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` --- ## 任务步骤 ### Task 1: pom.xml + ErrorCode + DTO/VO + DTO Validation **Files:** - Modify: `backend/pom.xml`(追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6 三个 dependency) - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` **API shape:** - `LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误")` - `LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定")` - `LoginDTO`:`@NotBlank @Size(max=50) String sUserName` / `@NotBlank @Size(max=100) String sPassword` / `@NotBlank @Pattern(regexp="^standard$") String sVersion` - `LoginResultVO`:`String accessToken` / `long expiresIn` / `LoginUserInfo user`(内嵌静态类 5 字段:iIncrement / sUserNo / sUserName / sUserType / sLanguage) - [ ] **Step 1.1 写失败测试** - ApiResponseTest 追加:`assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);` + LOGIN_ACCOUNT_LOCKED 40301。 - LoginDTOValidationTest 4 个用例:allValid / blankRequired / invalidVersion / overSized。 - 子会话: FAIL(class 不存在) - [ ] **Step 1.2 实现** - 在 pom.xml `` 追加: ``` io.jsonwebtoken:jjwt-api:0.12.6 io.jsonwebtoken:jjwt-impl:0.12.6 (runtime) io.jsonwebtoken:jjwt-jackson:0.12.6 (runtime) ``` - ErrorCode + DTO + VO + Validation - 子会话: PASS - [ ] **Step 1.3 提交** - `git commit -m "feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004"` --- ### Task 2: JwtTokenProvider + 单元测试 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` **API shape:** - `JwtTokenProvider` Spring `@Component`,构造注入 `@Value("${erp.jwt.secret}") String secret` + `@Value("${erp.jwt.expires-in-seconds}") long expiresIn`。 - `String sign(int uid, String username, String userType)`:HS256,claims `sub=username` / `uid` / `type=userType` / `iat` / `exp = iat + expiresIn`。 - `Claims parse(String token)`:验证签名 + 过期,抛 `JwtException` / `ExpiredJwtException`。 > secret 不足 256 bit 的处理:jjwt 0.12 要求 SecretKey 至少 256 bit。`JWT_SECRET` (.env.local) 当前已是 256 bit hex。Provider 用 `Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))`。如不足直接抛 `WeakKeyException`(启动期暴露)。 - [ ] **Step 2.1 写失败测试** - `JwtTokenProviderTest#signAndParse_returnsClaims`:注入 fake secret + expiresIn=7200,sign(uid=1, username=alice, type=普通用户) → parse → 断言 sub=alice / uid=1 / type=普通用户 / exp-iat=7200。 - `JwtTokenProviderTest#parseExpiredToken_throwsExpiredJwtException`:mock Clock 或手工构造已过期 token(用 jjwt builder 设 exp=now-1)。 - 测试方式:直接 `new JwtTokenProvider(secret, expiresIn)` 实例化,避免 SpringBootTest 开销。 - 子会话: FAIL - [ ] **Step 2.2 实现 JwtTokenProvider** - 子会话: PASS - [ ] **Step 2.3 提交** - `git commit -m "feat(usr): JwtTokenProvider sign/parse REQ-USR-004"` --- ### Task 3: LoginAttemptStore 接口 + InMemory 实现 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 - Create: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 - Test: `backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java` **API shape:** ``` interface LoginAttemptStore { /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ long cooldownSeconds(String username); /** 记录一次失败:count++;count==5 触发 15min 锁定 */ void recordFailure(String username); /** 登录成功清空记录 */ void clear(String username); } @Component class InMemoryLoginAttemptStore implements LoginAttemptStore { private static final int LOCK_THRESHOLD = 5; private static final Duration LOCK_DURATION = Duration.ofMinutes(15); private final Map store = new ConcurrentHashMap<>(); // FailRecord{int count; Instant lockUntil}; 同步 access via map.compute } ``` - [ ] **Step 3.1 写失败测试(4 个)** - `cooldown_initial_returnsZero` - `recordFailure_under5_doesNotLock` - `recordFailure_at5_triggersLock_cooldownPositive` - `clear_resetsCount` - (不直接测"15min 后解锁"——time-based 测试用 Duration.ZERO mock 不实际可行;spec § 6 验收"锁定到期后可登录"由 LoginServiceImplTest 模拟 lockUntil 至过去验证) - 子会话: FAIL - [ ] **Step 3.2 实现接口 + InMemory** - 子会话: PASS - [ ] **Step 3.3 提交** - `git commit -m "feat(usr): in-memory login attempt store REQ-USR-004"` --- ### Task 4: LoginService + Mockito 单元测试 **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` - Test: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` **API shape:** - `LoginService.login(LoginDTO dto): LoginResultVO` - 实现步骤(plan 锁定): 1. 检查锁定:`long cd = attemptStore.cooldownSeconds(dto.sUserName)`。`cd > 0` → 抛 `BizException(LOGIN_ACCOUNT_LOCKED, message + cooldownSeconds)`;message 体内含 cooldownSeconds,由 controller 层把 BizException 转换时把 cd 放到 data.cooldownSeconds。 - **简化方案**:直接抛 `BizException(LOGIN_ACCOUNT_LOCKED)` 不带 cooldown;controller 层 `catch BizException` 包装成 `ApiResponse.fail` 时丢失 cooldown。**改进方案**:自定义 `AccountLockedException extends BizException`,含 `long cooldownSeconds`,GlobalExceptionHandler 加专门 handler 把 cooldownSeconds 放进 `ApiResponse.data`。spec § 验收 #7 要求 `data.cooldownSeconds`,故选改进方案。 2. 查用户:`userMapper.selectOne(eq(sUserName).eq(bDeleted, false))`。null → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 3. BCrypt 校验:`passwordEncoder.matches(dto.sPassword, user.sPasswordHash)`。false → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 4. 成功:`attemptStore.clear(sUserName)`;签发 token;`userMapper.update(null, LambdaUpdateWrapper.eq(iIncrement,user.iIncrement).set(tLastLoginDate, now))`;构造 LoginResultVO 返回。 - 标 `@Transactional(rollbackFor = Exception.class)`(成功路径有 update;失败也安全)。 **新建** `AccountLockedException`(在 module_usr/security 或 common/exception,本 plan 选 common/exception): ``` class AccountLockedException extends BizException { private final long cooldownSeconds; public AccountLockedException(long cd) { super(ErrorCode.LOGIN_ACCOUNT_LOCKED); this.cooldownSeconds = cd; } } ``` GlobalExceptionHandler 追加 `@ExceptionHandler(AccountLockedException.class)` 把 cooldownSeconds 包进 `ApiResponse.data`,为此需要 `Map` data 形式:`Map.of("cooldownSeconds", e.getCooldownSeconds())`。 - [ ] **Step 4.1 写失败测试(7 个)** - `login_validCredentials_returnsTokenAndClearsFailCount`:mock attemptStore.cooldownSeconds=0 / userMapper.selectOne 返回 user / passwordEncoder.matches=true / jwt.sign 返回固定 token / userMapper.update 返回 1。断言 VO.accessToken / verify attemptStore.clear / verify userMapper.update 被调(含 LambdaUpdateWrapper)。 - `login_userNotFound_returns40101_recordsFailure` - `login_userSoftDeleted_returns40101`:selectOne 已带 bDeleted=0 过滤,覆盖该路径等价于 userNotFound(返回 null);mock selectOne null 即可。 - `login_passwordMismatch_returns40101_recordsFailure` - `login_accountLocked_throwsAccountLockedException_withCooldown` - `login_5thFailureTriggersLock`(mock attemptStore 检验 recordFailure 调用次数) - `login_successUpdatesTLastLoginDate`:ArgumentCaptor 捕 LambdaUpdateWrapper(或 Wrapper),断言 sql set 含 tLastLoginDate。 - 子会话: FAIL - [ ] **Step 4.2 实现 LoginService + Impl + AccountLockedException + GlobalExceptionHandler 扩展** - 子会话: PASS - [ ] **Step 4.3 提交** - `git commit -m "feat(usr): login service + account locked handling REQ-USR-004"` --- ### Task 5: LoginController + SecurityConfig 白名单 + 端到端 IT **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` - Modify: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`(白名单 /api/auth/login) - Modify: `backend/src/main/resources/application.yml`(确认 `erp.jwt.secret` / `erp.jwt.expires-in-seconds` 已存在;REQ-USR-001 引入时已设置) - Test: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` **API shape:** - `@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor LoginController` - `@PostMapping("/login") ApiResponse login(@Valid @RequestBody LoginDTO dto)` - Javadoc: `REQ-USR-004 用户登录 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成` - SecurityConfig 修改:在 `authorizeHttpRequests` 配置上下文中显式 `requestMatchers("/api/auth/login").permitAll()`,其他保持 permitAll(REQ-USR-004 不强制收紧,留作技术债登记) - [ ] **Step 5.1 写失败测试(9 个)** - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback`,`@Autowired UserMapper / PasswordEncoder / InMemoryLoginAttemptStore`,`@BeforeEach` 调用 `attemptStore.clear(sUserName)`(避免跨测试串扰,因为 store 是单例 ConcurrentHashMap)。 - `login_validCredentials_returns200WithToken`:先 mapper.insert 一个 user(密码 BCrypt 哈希 666666)→ POST 登录 → 断言 200 / token 非空 / expiresIn=7200 / user 嵌套。 - `login_jwtClaimsAreCorrect`:上一条 token 用 JwtTokenProvider 解析(@Autowired),断言 sub / uid / type 与 DB 一致。 - `login_invalidUsername_returns40101` - `login_wrongPassword_returns40101` - `login_softDeletedUser_returns40101` - `login_missingPassword_returns40010` - `login_invalidVersion_returns40010`:sVersion="experimental" - `login_5thFailureLocks_returns40301`:连续 5 次 wrong password,第 5 次断言 code=40301 + `data.cooldownSeconds > 0` - `login_responseExcludesSPasswordHash`:jsonPath `$.data.user.sPasswordHash` doesNotExist - 子会话: FAIL - [ ] **Step 5.2 实现 LoginController + SecurityConfig 白名单** - 子会话: PASS - [ ] **Step 5.3 跑全量 backend 测试** - `cd backend && mvn -B test` - 期望 144 + 4(DTO valid) + 2(JwtTokenProvider) + 4(InMemoryStore) + 7(service unit) + 9(controller IT) = 170 测试,全绿 - [ ] **Step 5.4 提交** - `git commit -m "feat(usr): POST /api/auth/login controller REQ-USR-004"` --- ## 提交计划 - `feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004`(Task 1) - `feat(usr): JwtTokenProvider sign/parse REQ-USR-004`(Task 2) - `feat(usr): in-memory login attempt store REQ-USR-004`(Task 3) - `feat(usr): login service + account locked handling REQ-USR-004`(Task 4) - `feat(usr): POST /api/auth/login controller REQ-USR-004`(Task 5)