2026-05-08-REQ-USR-004.md 33.4 KB

req_id: REQ-USR-004 date: 2026-05-08

spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-004.md

REQ-USR-004 用户登录 Implementation Plan

Execution: Parent skill feature-tdd executes this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 从零搭建后端 Spring Boot 3 项目与前端 Vite+React 项目,实现三条公开认证接口(POST /api/auth/login、POST /api/auth/refresh、GET /api/auth/brands),完成多租户隔离的 JWT 登录认证及账号锁定防暴力破解。

Architecture: 后端分四层推进:公共层(Result/BizException/JwtUtil)→ 数据访问层(BrandMapper/UsrUserMapper)→ 业务层(AuthServiceImpl,含 brand 多租户查找、BCrypt 校验、禁用/锁定检查、失败计数、JWT 签发 6 条业务规则)→ 接口层(SecurityConfig + AuthController)。前端分三层推进:项目骨架 → Axios 封装 + Redux authSlice → LoginPage.tsx 含 brand 下拉 + 用户名密码表单。

Tech Stack: Spring Boot 3.x · Spring Security · JJWT 0.12.x · MyBatis-Plus 3.5.x · Flyway 10.x · MySQL 8.x · Vite · React 18 · Ant Design 5.x · Redux Toolkit · Axios · Vitest + @testing-library/react


Schema 改动

无(sql/migrations/V1__initial_schema.sql 已包含 usr_user + brand 表,已 apply 至测试库 xlyweberp_vibe_erp_test

文件变更清单

后端(全部新建)

  • backend/pom.xml — 创建(Spring Boot 3 Maven 项目根 POM)
  • backend/src/main/java/com/example/erp/Application.java — 创建(启动类)
  • backend/src/main/resources/application.yml — 创建(主配置,DB/JWT 用 ${ENV_VAR} 占位)
  • backend/src/main/resources/application-dev.yml — 创建(dev profile,Flyway baseline 防重复迁移)
  • backend/src/main/resources/db/migration/V1__initial_schema.sql — 创建(内容与 sql/migrations/V1__initial_schema.sql 完全一致,供 Flyway classpath 找到)
  • backend/src/main/java/com/example/erp/common/response/Result.java — 创建(统一响应体,含 timestamp)
  • backend/src/main/java/com/example/erp/common/exception/BizException.java — 创建(业务异常基类)
  • backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java — 创建(认证错误码常量)
  • backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java — 创建(@RestControllerAdvice)
  • backend/src/main/java/com/example/erp/common/util/JwtUtil.java — 创建(JWT 生成 + 解析)
  • backend/src/main/java/com/example/erp/config/JwtProperties.java — 创建(@ConfigurationProperties("jwt"))
  • backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java — 创建(brand 表映射)
  • backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java — 创建(usr_user 表映射)
  • backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java — 创建(extends BaseMapper)
  • backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java — 创建(extends BaseMapper)
  • backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java — 创建(@MapperScan + 分页插件)
  • backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java — 创建(登录入参)
  • backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java — 创建(刷新入参)
  • backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java — 创建(登录出参,含内部静态类 UserInfoVO)
  • backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java — 创建(brand 下拉出参)
  • backend/src/main/java/com/example/erp/module/usr/service/AuthService.java — 创建(接口)
  • backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java — 创建(业务实现)
  • backend/src/main/java/com/example/erp/config/BeanConfig.java — 创建(@Bean BCryptPasswordEncoder;与 SecurityConfig 分离,避免循环依赖)
  • backend/src/main/java/com/example/erp/config/SecurityConfig.java — 创建(放行 /api/auth/**,注册 JwtFilter)
  • backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java — 创建(OncePerRequestFilter,验证 Bearer Token)
  • backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java — 创建(3 个端点)
  • backend/src/test/java/com/example/erp/ApplicationContextTest.java — 创建
  • backend/src/test/java/com/example/erp/common/JwtUtilTest.java — 创建
  • backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java — 创建
  • backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java — 创建
  • backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java — 创建

前端(全部新建)

  • frontend/package.json — 创建
  • frontend/vite.config.ts — 创建(代理 /api → http://localhost:8080)
  • frontend/tsconfig.json — 创建
  • frontend/index.html — 创建
  • frontend/src/main.tsx — 创建(Provider + BrowserRouter + App)
  • frontend/src/App.tsx — 创建(路由表,/ 重定向 /login,受保护路由守卫 PrivateRoute)
  • frontend/src/styles/tokens.css — 创建(内容与根目录 src/styles/tokens.css 一致)
  • frontend/src/api/request.ts — 创建(Axios 实例 + 请求/响应拦截器 + 401 refresh 流程)
  • frontend/src/api/auth.ts — 创建(login / refresh / getBrands 接口函数)
  • frontend/src/store/index.ts — 创建(configureStore)
  • frontend/src/store/slices/authSlice.ts — 创建(setCredentials / clearCredentials)
  • frontend/src/pages/usr/LoginPage.tsx — 创建(AntD Form:brand Select + 用户名 + 密码 + 提交)
  • frontend/src/test/setup.ts — 创建(@testing-library/jest-dom setup)
  • frontend/src/test/authSlice.test.ts — 创建
  • frontend/src/test/LoginPage.test.tsx — 创建

任务步骤

Task 1: 后端项目骨架

Files:

  • 创建: backend/pom.xml
  • 创建: backend/src/main/java/com/example/erp/Application.java
  • 创建: backend/src/main/resources/application.yml
  • 创建: backend/src/main/resources/application-dev.yml
  • 创建: backend/src/main/resources/db/migration/V1__initial_schema.sql
  • 测试: backend/src/test/java/com/example/erp/ApplicationContextTest.java

pom.xml 必须包含的依赖:

  • spring-boot-starter-parent 3.x(parent)
  • spring-boot-starter-web, spring-boot-starter-security, spring-boot-starter-validation, spring-boot-starter-test
  • mybatis-plus-spring-boot3-starter 3.5.x
  • mysql-connector-j(runtime scope)
  • flyway-core + flyway-mysql(10.x)
  • jjwt-api + jjwt-impl + jjwt-jackson(0.12.x;impl/jackson 用 runtime scope)
  • lombok(optional)
  • hutool-all 5.8.x

application.yml 关键片段:

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  flyway:
    locations: classpath:db/migration
    baseline-on-migrate: true
    baseline-version: 1
server:
  port: 8080
jwt:
  secret: ${JWT_SECRET}
  access-token-expiry: 86400
  refresh-token-expiry: 604800

application-dev.yml:

spring:
  config:
    import: optional:file:.env.local[.properties]

说明: .env.localDB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_SCHEMA, JWT_SECRET。dev profile 通过 spring.config.import 自动加载;baseline-on-migrate=true + baseline-version=1 让 Flyway 把已有 schema 标记为 V1 已应用,不重复执行。

  • Step 1: 写失败测试

    • 测试名: ApplicationContextTest#contextLoads
    • 意图: @SpringBootTest 启动 ApplicationContext 不报错(验证 Bean 配置和 DB 连接)
    • 子会话确认 FAIL(项目不存在,无法编译)
  • Step 2: 创建 Maven 项目结构

    • 创建目录树:backend/src/main/java/com/example/erp/backend/src/main/resources/db/migration/backend/src/test/java/com/example/erp/
    • 写 pom.xml(含上述依赖)
    • 写 Application.java(@SpringBootApplication,标准 main 方法)
    • 写 application.yml + application-dev.yml(按上述片段)
    • 复制 sql/migrations/V1__initial_schema.sql 内容到 backend/src/main/resources/db/migration/V1__initial_schema.sql
    • ApplicationContextTest.java@SpringBootTest,空的 contextLoads() 方法)
    • backend/src/test/java/com/example/erp/TestApplication.java(继承 Application,供测试使用 dev profile)
  • Step 3: 子会话运行 cd backend && mvn test -Dspring.profiles.active=dev,确认 contextLoads PASS

  • Step 4: Commit

    • git add backend/
    • git commit -m "chore(backend): init Spring Boot 3 project skeleton REQ-USR-004"

Task 2: Common 层(Result / BizException / ErrorCode / GlobalExceptionHandler)

Files:

  • 创建: backend/src/main/java/com/example/erp/common/response/Result.java
  • 创建: backend/src/main/java/com/example/erp/common/exception/BizException.java
  • 创建: backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java
  • 创建: backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java
  • 测试: backend/src/test/java/com/example/erp/common/ResultTest.java

API shape:

  • Result<T> — 字段:int codeString messageT datalong timestamp
    • static <T> Result<T> ok(T data) → code=200, message="操作成功", timestamp=System.currentTimeMillis()
    • static <T> Result<T> fail(int code, String message) → data=null, timestamp=System.currentTimeMillis()
  • BizException(int code, String message) — 继承 RuntimeException,含 int code 字段
  • GlobalExceptionHandler (@RestControllerAdvice):
    • handleBizException(BizException e)Result.fail(e.getCode(), e.getMessage()),HTTP 200
    • handleMethodArgumentNotValid(MethodArgumentNotValidException e)Result.fail(40001, 首个字段错误信息),HTTP 200
    • handleException(Exception e)Result.fail(99000, "系统内部错误"),记 Logback error 级日志

合同级错误码常量(AuthErrorCode.java,public static final int):

USERNAME_OR_PASSWORD_ERROR = 40100   // 用户名或密码错误(不区分哪个,防枚举)
ACCOUNT_DISABLED = 40101             // 账号已被禁用,请联系管理员
ACCOUNT_LOCKED = 40102               // 账号已被锁定,请 N 分钟后重试
REFRESH_TOKEN_INVALID = 40103        // Refresh Token 已失效,请重新登录
  • Step 1: 写失败测试

    • ResultTest#ok_setsCode200AndDataResult.ok("hello").getCode() == 200 && "hello".equals(result.getData())
    • ResultTest#fail_setsCodeAndNullDataResult.fail(40100, "msg").getCode() == 40100 && result.getData() == null
    • ResultTest#ok_hasTimestampResult.ok(null).getTimestamp() > 0
    • 子会话确认 FAIL(类不存在)
  • Step 2: 实现 Result.java + BizException.java + AuthErrorCode.java + GlobalExceptionHandler.java

  • Step 3: 子会话运行 mvn test,确认 3 个 ResultTest PASS

  • Step 4: Commit

    • git commit -m "feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004"

Task 3: JwtUtil

Files:

  • 创建: backend/src/main/java/com/example/erp/config/JwtProperties.java
  • 创建: backend/src/main/java/com/example/erp/common/util/JwtUtil.java
  • 测试: backend/src/test/java/com/example/erp/common/JwtUtilTest.java

API shape:

  • JwtProperties (@ConfigurationProperties("jwt"))String secretlong accessTokenExpiry(秒),long refreshTokenExpiry(秒);加 @EnableConfigurationProperties(JwtProperties.class) 于 Application 或 Config 类
  • JwtUtil (@Component):注入 JwtProperties

    • generateAccessToken(String userId, String username, String userType, String brandId) : String
    • claims: sub=userId, "username"=username, "userType"=userType, "brandId"=brandId, exp=now + accessTokenExpiry 秒
    • HMAC-SHA256,key = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8))
    • generateRefreshToken(String userId, String brandId) : String
    • claims: sub=userId, "brandId"=brandId, "type"="refresh", exp=now + refreshTokenExpiry 秒
    • parseAccessToken(String token) : Claims
    • 若签名无效或过期 → throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Token 已失效,请重新登录")
    • parseRefreshToken(String token) : Claims
    • 解析同 parseAccessToken
    • 验证 claim "type" == "refresh";否则 → throw BizException(40103)
  • Step 1: 写失败测试

    • JwtUtilTest#generateAndParseAccessToken_containsAllClaims
    • 构造 JwtUtil(properties: secret="testSecretKey32CharacterMinLength!", accessTokenExpiry=86400, refreshTokenExpiry=604800)
    • 生成 access token,parseAccessToken → sub=="u1", username=="admin", userType=="超级管理员", brandId=="b1"
    • JwtUtilTest#parseRefreshToken_withAccessToken_throws40103
    • 生成 access token,传入 parseRefreshToken → 抛 BizException,code=40103
    • JwtUtilTest#parseAccessToken_withExpiredToken_throws40103
    • 构造 JwtUtil(accessTokenExpiry=-1 或 0),生成 token 后 parseAccessToken → 抛 BizException,code=40103
    • 子会话确认 FAIL(类不存在)
  • Step 2: 实现 JwtProperties + JwtUtil

    • JJWT 0.12.x API:Jwts.builder()...signWith(key, Jwts.SIG.HS256).compact();解析:Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()
  • Step 3: 子会话运行 JwtUtilTest(3 个测试),确认 PASS

  • Step 4: Commit

    • git commit -m "feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004"

Task 4: Entity + Mapper(BrandEntity / UsrUserEntity)

Files:

  • 创建: backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java
  • 创建: backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java
  • 测试: backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java

API shape:

  • BrandEntity (@TableName("brand")) — 字段(均 @TableField("<column_name>")):iIncrement(@TableId,AUTO),sIdsNosNamesShortNamesBrandsIdsSubsidiaryIdtCreateDate(LocalDateTime)
  • UsrUserEntity (@TableName("usr_user")) — 字段:iIncrement(@TableId,AUTO),sIdsBrandsIdsSubsidiaryIdtCreateDate(LocalDateTime)sUserCodesUsernamesPasswordHashsUserTypesLanguagebCanEditDoc(Integer)bIsDisabled(Integer)sEmployeeIdsCreatorUsernametLastLoginDate(LocalDateTime)iLoginFailCount(Integer)tLockUntil(LocalDateTime)
  • BrandMapper extends BaseMapper<BrandEntity>(无额外方法)
  • UsrUserMapper extends BaseMapper<UsrUserEntity>(无额外方法;更新逻辑在 Service 用 LambdaUpdateWrapper 完成)
  • MyBatisPlusConfig (@Configuration)@MapperScan("com.example.erp.module.*.mapper");注册 MybatisPlusInterceptor + PaginationInnerInterceptor(DbType.MYSQL)

测试(BrandMapperTest — @SpringBootTest,使用真实测试库):

  • @BeforeEach 插入 brand 行:sId='b-test-001', sNo='TST', sName='测试版', iIncrement=null(自增),其余字段留 null
  • @AfterEach DELETE WHERE sNo='TST'
  • 测试方法 findByNo_returnsCorrectBrandnew LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, "TST") selectOne → sName == "测试版"

  • Step 1: 写失败测试

    • BrandMapperTest#findByNo_returnsCorrectBrand
    • 子会话确认 FAIL(类不存在)
  • Step 2: 实现 Entity + Mapper + MyBatisPlusConfig

    • 注意:Entity 字段名用 Java camelCase,@TableField 注解对应数据库实际列名(如 @TableField("sBrandsId") 或直接用 MyBatis-Plus 全局下划线转换——由于列名本身是驼峰,需关闭 map-underscore-to-camel-case 或手动 @TableField)
  • Step 3: 子会话运行 BrandMapperTest,确认 PASS

  • Step 4: Commit

    • git commit -m "feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004"

Task 5: AuthService — 登录核心逻辑

Files:

  • 创建: backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/service/AuthService.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java
  • 创建: backend/src/main/java/com/example/erp/config/BeanConfig.java
  • 测试: backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java

API shape:

  • LoginReqDTO@NotBlank String brandNo@NotBlank String username@NotBlank String password
  • LoginVOString accessTokenString refreshTokenlong expiresIn(固定值 86400),UserInfoVO userInfo
    • UserInfoVO (static inner class)String userIdString usernameString userTypeString languageString brandId
  • AuthServiceLoginVO login(LoginReqDTO req)String refresh(String refreshToken)List<BrandVO> getBrands()
  • AuthServiceImpl (@Service @Transactional) — 注入 BrandMapperUsrUserMapperJwtUtilBCryptPasswordEncoder

AuthServiceImpl.login 业务规则(按顺序):

  1. brandMapper.selectOne(new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo())) → null → throw new BizException(40100, "用户名或密码错误")
  2. userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId())) → null → throw new BizException(40100, "用户名或密码错误")
  3. user.getBIsDisabled() == 1throw new BizException(40101, "账号已被禁用,请联系管理员")
  4. user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now()) → 计算 remainMinutes = (int) Math.ceil(ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil()) / 60.0)throw new BizException(40102, "账号已被锁定,请 " + remainMinutes + " 分钟后重试")
  5. !passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())int newCount = user.getILoginFailCount() + 1
    • newCount >= 5: LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30)userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount).set(UsrUserEntity::getTLockUntil, lockUntil))throw new BizException(40102, "账号已被锁定,请 30 分钟后重试")
    • newCount < 5: userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount))throw new BizException(40100, "用户名或密码错误")
  6. 成功:userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, 0).set(UsrUserEntity::getTLockUntil, null).set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now()));签发 tokens;返回 LoginVO
  • Step 1: 写 7 个失败单元测试(AuthServiceTest — @ExtendWith(MockitoExtension.class),mock BrandMapper/UsrUserMapper/JwtUtil/BCryptPasswordEncoder)

    • login_brandNotFound_throws40100 — brandMapper.selectOne → null → BizException(40100)
    • login_userNotFound_throws40100 — brand 存在,userMapper.selectOne → null → BizException(40100)
    • login_accountDisabled_throws40101 — user.bIsDisabled=1 → BizException(40101)
    • login_accountLocked_throws40102WithRemainingMinutes — user.tLockUntil=now+20min → BizException(40102),message 含 "20 分钟"
    • login_wrongPassword_firstTime_throws40100AndIncrementsCount — BCrypt 不匹配,iLoginFailCount=0 → 更新为 1,throw 40100
    • login_wrongPassword_5thTime_setsLockAndThrows40102 — BCrypt 不匹配,iLoginFailCount=4 → 更新为 5,设 tLockUntil,throw 40102
    • login_success_resetsCountAndReturnsTokens — BCrypt 匹配 → reset count,issue tokens,返回 LoginVO
  • Step 2: 实现 LoginReqDTO + LoginVO + AuthService + AuthServiceImpl.login()

    • BCryptPasswordEncoder 注入:在 Task 5 中同步创建 BeanConfig.java@Configuration @Bean BCryptPasswordEncoder passwordEncoder()),使 Spring context(ApplicationContextTest)在 Task 5 之后仍能正常加载;SecurityConfig(Task 7)不重复声明此 Bean
  • Step 3: 子会话运行 AuthServiceTest(7 个测试),确认 PASS

  • Step 4: Commit

    • git commit -m "feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004"

Task 6: AuthService — refresh + getBrands

Files:

  • 创建: backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java
  • 修改: backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java(新增 refresh + getBrands)
  • 测试: backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java(追加 3 个测试方法)

API shape:

  • RefreshTokenReqDTO@NotBlank String refreshToken
  • BrandVOString sNoString sName
  • AuthServiceImpl#refresh(String refreshToken) : String
    1. Claims claims = jwtUtil.parseRefreshToken(refreshToken) — 无效/过期自动抛 BizException(40103)
    2. String userId = claims.getSubject(); String brandId = claims.get("brandId", String.class)
    3. UsrUserEntity user = userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId)) → null 或 bIsDisabled=1 → throw BizException(40103, "Refresh Token 已失效,请重新登录")
    4. 签发新 accessToken:jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId);返回新 accessToken 字符串
  • AuthServiceImpl#getBrands() : List<BrandVO>

    • brandMapper.selectList(new QueryWrapper<BrandEntity>().select("sNo", "sName").orderByAsc("sName")) → 映射为 BrandVO 列表
  • Step 1: 写 3 个失败测试(追加到 AuthServiceTest)

    • refresh_validRefreshToken_returnsNewAccessToken — parseRefreshToken 成功,查库返回有效 user → generateAccessToken 被调用,返回新 token
    • refresh_invalidRefreshToken_throws40103 — parseRefreshToken 抛 BizException(40103)
    • getBrands_returnsListSortedByName — brandMapper.selectList 返回 [b1, b2] → 结果 List 包含对应 sNo/sName
  • Step 2: 实现 RefreshTokenReqDTO + BrandVO + AuthServiceImpl#refresh() + AuthServiceImpl#getBrands()

  • Step 3: 子会话运行 AuthServiceTest(新增 3 个测试),确认 PASS

  • Step 4: Commit

    • git commit -m "feat(usr): AuthService.refresh + getBrands REQ-USR-004"

Task 7: SecurityConfig + JwtAuthenticationFilter + AuthController

Files:

  • 创建: backend/src/main/java/com/example/erp/config/SecurityConfig.java
  • 创建: backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java
  • 创建: backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java
  • 测试: backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java

API shape:

  • SecurityConfig (@Configuration @EnableWebSecurity):
    • @Bean SecurityFilterChain: csrf().disable()sessionManagement(STATELESS)authorizeHttpRequests: permitAll for /api/auth/**,其余 authenticated
    • addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)
    • 不再声明 BCryptPasswordEncoder @Bean(已在 BeanConfig.java 声明)
  • JwtAuthenticationFilter extends OncePerRequestFilter:
    • Authorization header,提取 Bearer token
    • jwtUtil.parseAccessToken(token) → 设 UsernamePasswordAuthenticationToken 入 SecurityContextHolder
    • token 无效 → 不设 context(Spring Security 后续返回 401);请求路径匹配 /api/auth/** → 直接放行不解析
  • AuthController (@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor):

    • @PostMapping("/login") Result<LoginVO> login(@Valid @RequestBody LoginReqDTO req)Result.ok(authService.login(req))
    • @PostMapping("/refresh") Result<Map<String,String>> refresh(@Valid @RequestBody RefreshTokenReqDTO req)Result.ok(Map.of("accessToken", authService.refresh(req.getRefreshToken())))
    • @GetMapping("/brands") Result<List<BrandVO>> brands()Result.ok(authService.getBrands())
  • Step 1: 写 4 个 MockMvc 失败测试(AuthControllerTest — @WebMvcTest + @MockBean AuthService)

    • login_wrongPassword_returns40100 — authService.login 抛 BizException(40100) → 响应 JSON code=40100
    • login_validCredentials_returns200AndTokens — authService.login 返回 LoginVO → 响应 JSON code=200,accessToken 非空
    • refresh_invalidToken_returns40103 — authService.refresh 抛 BizException(40103) → code=40103
    • getBrands_returns200AndList — authService.getBrands 返回 [BrandVO{sNo="STD", sName="标准版"}] → code=200,list 含该项
  • Step 2: 实现 SecurityConfig + JwtAuthenticationFilter + AuthController

  • Step 3: 子会话运行 AuthControllerTest(4 个测试),确认 PASS

  • Step 4: 手动 smoke test(如果后端可本地运行)

    • cd backend && mvn spring-boot:run -Dspring-boot.run.profiles=dev(需 .env.local 在 backend/ 父目录可找到)
    • curl -s -X GET http://localhost:8080/api/auth/brands | jq .
    • curl -s -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"brandNo":"STD","username":"admin","password":"666666"}' | jq .
  • Step 5: Commit

    • git commit -m "feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004"

Task 8: 前端项目骨架

Files:

  • 创建: frontend/package.json
  • 创建: frontend/vite.config.ts
  • 创建: frontend/tsconfig.json
  • 创建: frontend/index.html
  • 创建: frontend/src/main.tsx
  • 创建: frontend/src/App.tsx
  • 创建: frontend/src/styles/tokens.css

package.json 关键依赖:

"dependencies": {
  "react": "^18.3.0", "react-dom": "^18.3.0",
  "antd": "^5.17.0", "@ant-design/icons": "^5.3.0",
  "@reduxjs/toolkit": "^2.2.0", "react-redux": "^9.1.0",
  "react-router-dom": "^6.23.0",
  "axios": "^1.7.0", "dayjs": "^1.11.0"
},
"devDependencies": {
  "vite": "^5.2.0", "@vitejs/plugin-react": "^4.3.0",
  "typescript": "^5.4.0",
  "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0",
  "vitest": "^1.6.0",
  "@testing-library/react": "^15.0.0",
  "@testing-library/jest-dom": "^6.4.0",
  "@testing-library/user-event": "^14.5.0",
  "jsdom": "^24.0.0"
}

vite.config.ts 关键配置:

export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } },
  test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts' }
})

App.tsx 骨架: <BrowserRouter> 包裹路由,/login<LoginPage />/ → PrivateRoute(暂时重定向 /login,后续 REQ 补充),* → 404

  • Step 1: 创建前端项目文件结构

    • mkdir -p frontend/src/{styles,api,store/slices,pages/usr,test,hooks,components,utils}
    • 写 package.json(上述依赖)
    • 写 vite.config.ts、tsconfig.json、index.html、src/main.tsx、src/App.tsx(骨架)
    • 复制 src/styles/tokens.css 内容到 frontend/src/styles/tokens.css
    • frontend/src/test/setup.tsimport "@testing-library/jest-dom"
    • cd frontend && npm install
  • Step 2: 验证骨架构建

    • cd frontend && npm run build(应成功,0 错误)
  • Step 3: Commit

    • git add frontend/
    • git commit -m "chore(frontend): init Vite React project skeleton REQ-USR-004"

Task 9: 前端 Auth API 层 + Redux authSlice

Files:

  • 创建: frontend/src/api/request.ts
  • 创建: frontend/src/api/auth.ts
  • 创建: frontend/src/store/index.ts
  • 创建: frontend/src/store/slices/authSlice.ts
  • 测试: frontend/src/test/authSlice.test.ts

API shape:

  • request.ts — Axios 实例 baseURL: '/api'timeout: 10000
    • 请求拦截器:从 store.getState().auth.accessToken 读 token,注入 Authorization: Bearer ${token}
    • 响应拦截器(成功):response.data.code !== 200 → 抛 new Error(response.data.message)
    • 响应拦截器(HTTP 401):调 auth.refresh(store.getState().auth.refreshToken) → 更新 store → 重试原请求;refresh 失败 → store.dispatch(clearCredentials())window.location.href = '/login'
  • auth.ts:
    • login(params: { brandNo: string; username: string; password: string }) : Promise<LoginVO> — POST /api/auth/login
    • refresh(refreshToken: string) : Promise<{ accessToken: string }> — POST /api/auth/refresh
    • getBrands() : Promise<BrandVO[]> — GET /api/auth/brands
    • 类型定义(TypeScript interface):LoginVO(含 accessToken, refreshToken, expiresIn, userInfo: UserInfoVO),UserInfoVO(userId, username, userType, language, brandId),BrandVO(sNo, sName)
  • authSlicecreateSlice({ name: 'auth', initialState: { accessToken: null, refreshToken: null, userInfo: null } })
    • setCredentials(state, action: PayloadAction<{ accessToken: string; refreshToken: string; userInfo: UserInfoVO }>) — 更新三字段
    • clearCredentials(state) — 三字段重置 null
  • store/index.tsconfigureStore({ reducer: { auth: authReducer } });导出 RootStateAppDispatch

  • Step 1: 写 2 个失败单元测试(authSlice.test.ts)

    • setCredentials_updatesAllStateFields — dispatch setCredentials({accessToken:"t1", refreshToken:"r1", userInfo:{userId:"u1",...}}) → state.auth.accessToken=="t1"
    • clearCredentials_resetsToNull — dispatch clearCredentials → state.auth.accessToken==null
  • Step 2: 实现 request.ts + auth.ts + authSlice + store/index.ts

  • Step 3: 子会话运行 cd frontend && npm run test -- --run,确认 authSlice 2 个测试 PASS

  • Step 4: Commit

    • git commit -m "feat(usr): auth API layer + Redux authSlice REQ-USR-004"

Task 10: 登录页 LoginPage.tsx

Files:

  • 创建: frontend/src/pages/usr/LoginPage.tsx
  • 修改: frontend/src/App.tsx(添加 /login 路由,PrivateRoute 守卫读 authSlice)
  • 测试: frontend/src/test/LoginPage.test.tsx

UI 规范(来自 docs/06 § 五):

  • 版本 Select(label="公司/版本",name="brandNo"):组件 mount 时调 getBrands(),填充选项(value=sNo, label=sName);defaultValue = sName=="标准版" 对应的 sNo;若无"标准版"则选第一项
  • 用户名 Input(label="用户名",name="username"):必填
  • 密码 Input.Password(label="密码",name="password"):必填
  • 提交按钮(text="登录",loading={loading}):防重复点击
  • 失败:message.error(接口返回 message)
  • 成功:dispatch(setCredentials({accessToken, refreshToken, userInfo}))navigate('/')

LoginPage 内部数据流:

  1. useEffect(() => { getBrands().then(setBrandOptions) }, []) — 挂载时获取 brand 列表
  2. onFinish(values)setLoading(true)await login(values)dispatch(setCredentials(...))navigate('/')finally setLoading(false)
  • Step 1: 写 2 个失败测试(LoginPage.test.tsx)

    • renders_brandSelect_username_and_password_fields
    • render LoginPage(with Redux Provider + MemoryRouter + mock getBrands → [{sNo:"STD",sName:"标准版"}])
    • 断言:combobox(brand Select)存在,getByPlaceholderText 或 label "用户名" Input 存在,"密码" Input 存在,"登录" Button 存在
    • submit_withValidCredentials_dispatchesSetCredentials
    • mock auth.login 返回 {accessToken:"at", refreshToken:"rt", expiresIn:86400, userInfo:{userId:"u1",username:"admin",userType:"超级管理员",language:"中文",brandId:"b1"}}
    • 填写 username + password,点击登录 → store.getState().auth.accessToken == "at"
  • Step 2: 实现 LoginPage.tsx + 更新 App.tsx(/login 路由)

  • Step 3: 子会话运行 npm run test -- --run,确认所有前端测试(包括 authSlice + LoginPage)PASS

  • Step 4: Commit

    • git commit -m "feat(usr): LoginPage brand selector + login form REQ-USR-004"

提交计划

提交消息 覆盖 Task
chore(backend): init Spring Boot 3 project skeleton REQ-USR-004 Task 1
feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004 Task 2
feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004 Task 3
feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004 Task 4
feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004 Task 5
feat(usr): AuthService.refresh + getBrands REQ-USR-004 Task 6
feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004 Task 7
chore(frontend): init Vite React project skeleton REQ-USR-004 Task 8
feat(usr): auth API layer + Redux authSlice REQ-USR-004 Task 9
feat(usr): LoginPage brand selector + login form REQ-USR-004 Task 10