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-tddexecutes 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-parent3.x(parent) -
spring-boot-starter-web,spring-boot-starter-security,spring-boot-starter-validation,spring-boot-starter-test -
mybatis-plus-spring-boot3-starter3.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-all5.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.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<T>— 字段:int code,String message,T data,long 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_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):注入 JwtPropertiesgenerateAccessToken(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()
- JJWT 0.12.x API:
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),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<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 -
@AfterEachDELETE WHERE sNo='TST' 测试方法
findByNo_returnsCorrectBrand—new 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)
- 注意:Entity 字段名用 Java camelCase,
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<BrandVO> getBrands() -
AuthServiceImpl (@Service @Transactional)— 注入BrandMapper,UsrUserMapper,JwtUtil,BCryptPasswordEncoder
AuthServiceImpl.login 业务规则(按顺序):
-
brandMapper.selectOne(new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo()))→ null →throw new BizException(40100, "用户名或密码错误") -
userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId()))→ null →throw new BizException(40100, "用户名或密码错误") -
user.getBIsDisabled() == 1→throw new BizException(40101, "账号已被禁用,请联系管理员") -
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 + " 分钟后重试") -
!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, "用户名或密码错误")
-
- 成功:
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 -
BrandVO—String sNo,String sName -
AuthServiceImpl#refresh(String refreshToken) : String-
Claims claims = jwtUtil.parseRefreshToken(refreshToken)— 无效/过期自动抛 BizException(40103) String userId = claims.getSubject(); String brandId = claims.get("brandId", String.class)-
UsrUserEntity user = userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId))→ null 或bIsDisabled=1→ throw BizException(40103, "Refresh Token 已失效,请重新登录") - 签发新 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:permitAllfor/api/auth/**,其余authenticated addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)- 不再声明 BCryptPasswordEncoder @Bean(已在 BeanConfig.java 声明)
-
-
JwtAuthenticationFilter extends OncePerRequestFilter:- 读
Authorizationheader,提取 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.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<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)
-
-
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 内部数据流:
-
useEffect(() => { getBrands().then(setBrandOptions) }, [])— 挂载时获取 brand 列表 -
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 |