2026-05-06-REQ-USR-004.md 13.8 KB

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
  • LoginResultVOString 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 <dependencies> 追加: 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<String, FailRecord> 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<String, Object> 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<LoginResultVO> 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)