--- 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 关键片段:** ```yaml 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:** ```yaml spring: config: import: optional:file:.env.local[.properties] ``` **说明:** `.env.local` 含 `DB_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` — 字段:`int code`,`String message`,`T data`,`long timestamp` - `static Result ok(T data)` → code=200, message="操作成功", timestamp=System.currentTimeMillis() - `static Result 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`):** ```java USERNAME_OR_PASSWORD_ERROR = 40100 // 用户名或密码错误(不区分哪个,防枚举) ACCOUNT_DISABLED = 40101 // 账号已被禁用,请联系管理员 ACCOUNT_LOCKED = 40102 // 账号已被锁定,请 N 分钟后重试 REFRESH_TOKEN_INVALID = 40103 // Refresh Token 已失效,请重新登录 ``` - [ ] **Step 1: 写失败测试** - `ResultTest#ok_setsCode200AndData` — `Result.ok("hello").getCode() == 200 && "hello".equals(result.getData())` - `ResultTest#fail_setsCodeAndNullData` — `Result.fail(40100, "msg").getCode() == 40100 && result.getData() == null` - `ResultTest#ok_hasTimestamp` — `Result.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 secret`,`long 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("")`):`iIncrement`(@TableId,AUTO),`sId`,`sNo`,`sName`,`sShortName`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)` - `UsrUserEntity (@TableName("usr_user"))` — 字段:`iIncrement`(@TableId,AUTO),`sId`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)`,`sUserCode`,`sUsername`,`sPasswordHash`,`sUserType`,`sLanguage`,`bCanEditDoc(Integer)`,`bIsDisabled(Integer)`,`sEmployeeId`,`sCreatorUsername`,`tLastLoginDate(LocalDateTime)`,`iLoginFailCount(Integer)`,`tLockUntil(LocalDateTime)` - `BrandMapper extends BaseMapper`(无额外方法) - `UsrUserMapper extends BaseMapper`(无额外方法;更新逻辑在 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_returnsCorrectBrand` — `new LambdaQueryWrapper().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` - `LoginVO` — `String accessToken`,`String refreshToken`,`long expiresIn`(固定值 86400),`UserInfoVO userInfo` - `UserInfoVO (static inner class)` — `String userId`,`String username`,`String userType`,`String language`,`String brandId` - `AuthService` — `LoginVO login(LoginReqDTO req)`;`String refresh(String refreshToken)`;`List getBrands()` - `AuthServiceImpl (@Service @Transactional)` — 注入 `BrandMapper`,`UsrUserMapper`,`JwtUtil`,`BCryptPasswordEncoder` **AuthServiceImpl.login 业务规则(按顺序):** 1. `brandMapper.selectOne(new LambdaQueryWrapper().eq(BrandEntity::getSNo, req.getBrandNo()))` → null → `throw new BizException(40100, "用户名或密码错误")` 2. `userMapper.selectOne(new LambdaQueryWrapper().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId()))` → null → `throw new BizException(40100, "用户名或密码错误")` 3. `user.getBIsDisabled() == 1` → `throw 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().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().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount))` → `throw new BizException(40100, "用户名或密码错误")` 6. 成功:`userMapper.update(null, new LambdaUpdateWrapper().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` - `BrandVO` — `String sNo`,`String 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().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` - `brandMapper.selectList(new QueryWrapper().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 login(@Valid @RequestBody LoginReqDTO req)` → `Result.ok(authService.login(req))` - `@PostMapping("/refresh") Result> refresh(@Valid @RequestBody RefreshTokenReqDTO req)` → `Result.ok(Map.of("accessToken", authService.refresh(req.getRefreshToken())))` - `@GetMapping("/brands") Result> 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 关键依赖:** ```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 关键配置:** ```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 骨架:** `` 包裹路由,`/login` → ``,`/` → 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.ts`(`import "@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` — POST /api/auth/login - `refresh(refreshToken: string) : Promise<{ accessToken: string }>` — POST /api/auth/refresh - `getBrands() : Promise` — GET /api/auth/brands - 类型定义(TypeScript interface):`LoginVO`(含 accessToken, refreshToken, expiresIn, userInfo: UserInfoVO),`UserInfoVO`(userId, username, userType, language, brandId),`BrandVO`(sNo, sName) - `authSlice` — `createSlice({ name: 'auth', initialState: { accessToken: null, refreshToken: null, userInfo: null } })` - `setCredentials(state, action: PayloadAction<{ accessToken: string; refreshToken: string; userInfo: UserInfoVO }>)` — 更新三字段 - `clearCredentials(state)` — 三字段重置 null - `store/index.ts` — `configureStore({ reducer: { auth: authReducer } })`;导出 `RootState`、`AppDispatch` - [ ] **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 |