Commit 2c78bc87b51f18e456eb71f189b9df17cb6539e5

Authored by zichun
1 parent 0e97c779

fix(usr): review round-1 修复 — 多租户唯一索引 + LambdaUpdateWrapper + refresh 锁定检查 REQ-USR-004

1. V2 migration: uk_usr_user_username 改为 (sUsername, sBrandsId) 复合唯一
2. AuthServiceImpl: UpdateWrapper 换 LambdaUpdateWrapper(一致性)
3. AuthServiceImpl.refresh(): 追加 tLockUntil 检查,防绕过锁定
4. AuthServiceTest: 新增 refresh_lockedUser_throws40103
5. pom.xml: Lombok 1.18.36 适配 Java 25,surefire ByteBuddy 实验模式
6. .mvn/jvm.config + scripts/test.sh: Java 21 编译兼容性修复
backend/.mvn/jvm.config 0 → 100644
  1 +-XX:+EnableDynamicAgentLoading
backend/pom.xml
@@ -20,6 +20,7 @@ @@ -20,6 +20,7 @@
20 20
21 <properties> 21 <properties>
22 <java.version>21</java.version> 22 <java.version>21</java.version>
  23 + <lombok.version>1.18.36</lombok.version>
23 <mybatis-plus.version>3.5.7</mybatis-plus.version> 24 <mybatis-plus.version>3.5.7</mybatis-plus.version>
24 <jjwt.version>0.12.6</jjwt.version> 25 <jjwt.version>0.12.6</jjwt.version>
25 <hutool.version>5.8.28</hutool.version> 26 <hutool.version>5.8.28</hutool.version>
backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java
@@ -2,7 +2,7 @@ package com.example.erp.module.usr.service.impl; @@ -2,7 +2,7 @@ package com.example.erp.module.usr.service.impl;
2 2
3 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 3 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; 4 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
5 -import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 5 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
6 import com.example.erp.common.constants.AuthErrorCode; 6 import com.example.erp.common.constants.AuthErrorCode;
7 import com.example.erp.common.exception.BizException; 7 import com.example.erp.common.exception.BizException;
8 import com.example.erp.common.util.JwtUtil; 8 import com.example.erp.common.util.JwtUtil;
@@ -68,12 +68,12 @@ public class AuthServiceImpl implements AuthService { @@ -68,12 +68,12 @@ public class AuthServiceImpl implements AuthService {
68 // 5. 密码校验 68 // 5. 密码校验
69 if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) { 69 if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) {
70 int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1; 70 int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1;
71 - UpdateWrapper<UsrUserEntity> updateWrapper = new UpdateWrapper<UsrUserEntity>()  
72 - .eq("sId", user.getSId())  
73 - .set("iLoginFailCount", newCount); 71 + LambdaUpdateWrapper<UsrUserEntity> updateWrapper = new LambdaUpdateWrapper<UsrUserEntity>()
  72 + .eq(UsrUserEntity::getSId, user.getSId())
  73 + .set(UsrUserEntity::getILoginFailCount, newCount);
74 if (newCount >= 5) { 74 if (newCount >= 5) {
75 LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30); 75 LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30);
76 - updateWrapper.set("tLockUntil", lockUntil); 76 + updateWrapper.set(UsrUserEntity::getTLockUntil, lockUntil);
77 userMapper.update(null, updateWrapper); 77 userMapper.update(null, updateWrapper);
78 throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试"); 78 throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试");
79 } 79 }
@@ -82,11 +82,11 @@ public class AuthServiceImpl implements AuthService { @@ -82,11 +82,11 @@ public class AuthServiceImpl implements AuthService {
82 } 82 }
83 83
84 // 6. 登录成功 84 // 6. 登录成功
85 - userMapper.update(null, new UpdateWrapper<UsrUserEntity>()  
86 - .eq("sId", user.getSId())  
87 - .set("iLoginFailCount", 0)  
88 - .set("tLockUntil", null)  
89 - .set("tLastLoginDate", LocalDateTime.now())); 85 + userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>()
  86 + .eq(UsrUserEntity::getSId, user.getSId())
  87 + .set(UsrUserEntity::getILoginFailCount, 0)
  88 + .set(UsrUserEntity::getTLockUntil, null)
  89 + .set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now()));
90 90
91 String accessToken = jwtUtil.generateAccessToken( 91 String accessToken = jwtUtil.generateAccessToken(
92 user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId()); 92 user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId());
@@ -115,7 +115,9 @@ public class AuthServiceImpl implements AuthService { @@ -115,7 +115,9 @@ public class AuthServiceImpl implements AuthService {
115 115
116 UsrUserEntity user = userMapper.selectOne( 116 UsrUserEntity user = userMapper.selectOne(
117 new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId)); 117 new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId));
118 - if (user == null || Integer.valueOf(1).equals(user.getBIsDisabled())) { 118 + if (user == null
  119 + || Integer.valueOf(1).equals(user.getBIsDisabled())
  120 + || (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now()))) {
119 throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录"); 121 throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录");
120 } 122 }
121 123
backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java
@@ -154,6 +154,19 @@ class AuthServiceTest { @@ -154,6 +154,19 @@ class AuthServiceTest {
154 } 154 }
155 155
156 @Test 156 @Test
  157 + void refresh_lockedUser_throws40103() {
  158 + Claims claims = mock(Claims.class);
  159 + when(claims.getSubject()).thenReturn("u1");
  160 + when(claims.get("brandId", String.class)).thenReturn("b1");
  161 + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims);
  162 + user.setTLockUntil(LocalDateTime.now().plusMinutes(25));
  163 + when(userMapper.selectOne(any())).thenReturn(user);
  164 +
  165 + BizException ex = assertThrows(BizException.class, () -> authService.refresh("valid-refresh"));
  166 + assertEquals(40103, ex.getCode());
  167 + }
  168 +
  169 + @Test
157 void refresh_invalidRefreshToken_throws40103() { 170 void refresh_invalidRefreshToken_throws40103() {
158 when(jwtUtil.parseRefreshToken("bad-token")) 171 when(jwtUtil.parseRefreshToken("bad-token"))
159 .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录")); 172 .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录"));
docs/03-数据库设计文档.md
@@ -64,7 +64,7 @@ usr_user(用户主表) @@ -64,7 +64,7 @@ usr_user(用户主表)
64 64
65 ### 索引 65 ### 索引
66 66
67 -- `uk_usr_user_username` (UNIQUE): `sUsername` — 全局唯一约束 67 +- `uk_usr_user_username_tenant` (UNIQUE): `(sUsername, sBrandsId)` — 用户名在同一 brand 内唯一(V2 迁移:原全局唯一改为多租户复合唯一)
68 - `uk_usr_user_usercode` (UNIQUE): `sUserCode` — 用户号唯一约束 68 - `uk_usr_user_usercode` (UNIQUE): `sUserCode` — 用户号唯一约束
69 - `idx_usr_user_tenant` (INDEX): `sBrandsId, sSubsidiaryId` — 多租户隔离查询 69 - `idx_usr_user_tenant` (INDEX): `sBrandsId, sSubsidiaryId` — 多租户隔离查询
70 - `idx_usr_user_type` (INDEX): `sUserType` — 按用户类型过滤 70 - `idx_usr_user_type` (INDEX): `sUserType` — 按用户类型过滤
docs/superpowers/plans/2026-05-08-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-004.md
  5 +---
  6 +
  7 +# REQ-USR-004 用户登录 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 从零搭建后端 Spring Boot 3 项目与前端 Vite+React 项目,实现三条公开认证接口(POST /api/auth/login、POST /api/auth/refresh、GET /api/auth/brands),完成多租户隔离的 JWT 登录认证及账号锁定防暴力破解。
  12 +
  13 +**Architecture:** 后端分四层推进:公共层(Result/BizException/JwtUtil)→ 数据访问层(BrandMapper/UsrUserMapper)→ 业务层(AuthServiceImpl,含 brand 多租户查找、BCrypt 校验、禁用/锁定检查、失败计数、JWT 签发 6 条业务规则)→ 接口层(SecurityConfig + AuthController)。前端分三层推进:项目骨架 → Axios 封装 + Redux authSlice → LoginPage.tsx 含 brand 下拉 + 用户名密码表单。
  14 +
  15 +**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
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(`sql/migrations/V1__initial_schema.sql` 已包含 `usr_user` + `brand` 表,已 apply 至测试库 `xlyweberp_vibe_erp_test`)
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 后端(全部新建)
  26 +- `backend/pom.xml` — 创建(Spring Boot 3 Maven 项目根 POM)
  27 +- `backend/src/main/java/com/example/erp/Application.java` — 创建(启动类)
  28 +- `backend/src/main/resources/application.yml` — 创建(主配置,DB/JWT 用 `${ENV_VAR}` 占位)
  29 +- `backend/src/main/resources/application-dev.yml` — 创建(dev profile,Flyway baseline 防重复迁移)
  30 +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` — 创建(内容与 `sql/migrations/V1__initial_schema.sql` 完全一致,供 Flyway classpath 找到)
  31 +- `backend/src/main/java/com/example/erp/common/response/Result.java` — 创建(统一响应体,含 timestamp)
  32 +- `backend/src/main/java/com/example/erp/common/exception/BizException.java` — 创建(业务异常基类)
  33 +- `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java` — 创建(认证错误码常量)
  34 +- `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java` — 创建(@RestControllerAdvice)
  35 +- `backend/src/main/java/com/example/erp/common/util/JwtUtil.java` — 创建(JWT 生成 + 解析)
  36 +- `backend/src/main/java/com/example/erp/config/JwtProperties.java` — 创建(@ConfigurationProperties("jwt"))
  37 +- `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java` — 创建(brand 表映射)
  38 +- `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java` — 创建(usr_user 表映射)
  39 +- `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java` — 创建(extends BaseMapper<BrandEntity>)
  40 +- `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 创建(extends BaseMapper<UsrUserEntity>)
  41 +- `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java` — 创建(@MapperScan + 分页插件)
  42 +- `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java` — 创建(登录入参)
  43 +- `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java` — 创建(刷新入参)
  44 +- `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java` — 创建(登录出参,含内部静态类 UserInfoVO)
  45 +- `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java` — 创建(brand 下拉出参)
  46 +- `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java` — 创建(接口)
  47 +- `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java` — 创建(业务实现)
  48 +- `backend/src/main/java/com/example/erp/config/BeanConfig.java` — 创建(@Bean BCryptPasswordEncoder;与 SecurityConfig 分离,避免循环依赖)
  49 +- `backend/src/main/java/com/example/erp/config/SecurityConfig.java` — 创建(放行 /api/auth/**,注册 JwtFilter)
  50 +- `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` — 创建(OncePerRequestFilter,验证 Bearer Token)
  51 +- `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java` — 创建(3 个端点)
  52 +- `backend/src/test/java/com/example/erp/ApplicationContextTest.java` — 创建
  53 +- `backend/src/test/java/com/example/erp/common/JwtUtilTest.java` — 创建
  54 +- `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java` — 创建
  55 +- `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java` — 创建
  56 +- `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java` — 创建
  57 +
  58 +### 前端(全部新建)
  59 +- `frontend/package.json` — 创建
  60 +- `frontend/vite.config.ts` — 创建(代理 /api → http://localhost:8080)
  61 +- `frontend/tsconfig.json` — 创建
  62 +- `frontend/index.html` — 创建
  63 +- `frontend/src/main.tsx` — 创建(Provider + BrowserRouter + App)
  64 +- `frontend/src/App.tsx` — 创建(路由表,/ 重定向 /login,受保护路由守卫 PrivateRoute)
  65 +- `frontend/src/styles/tokens.css` — 创建(内容与根目录 `src/styles/tokens.css` 一致)
  66 +- `frontend/src/api/request.ts` — 创建(Axios 实例 + 请求/响应拦截器 + 401 refresh 流程)
  67 +- `frontend/src/api/auth.ts` — 创建(login / refresh / getBrands 接口函数)
  68 +- `frontend/src/store/index.ts` — 创建(configureStore)
  69 +- `frontend/src/store/slices/authSlice.ts` — 创建(setCredentials / clearCredentials)
  70 +- `frontend/src/pages/usr/LoginPage.tsx` — 创建(AntD Form:brand Select + 用户名 + 密码 + 提交)
  71 +- `frontend/src/test/setup.ts` — 创建(@testing-library/jest-dom setup)
  72 +- `frontend/src/test/authSlice.test.ts` — 创建
  73 +- `frontend/src/test/LoginPage.test.tsx` — 创建
  74 +
  75 +---
  76 +
  77 +## 任务步骤
  78 +
  79 +### Task 1: 后端项目骨架
  80 +
  81 +**Files:**
  82 +- 创建: `backend/pom.xml`
  83 +- 创建: `backend/src/main/java/com/example/erp/Application.java`
  84 +- 创建: `backend/src/main/resources/application.yml`
  85 +- 创建: `backend/src/main/resources/application-dev.yml`
  86 +- 创建: `backend/src/main/resources/db/migration/V1__initial_schema.sql`
  87 +- 测试: `backend/src/test/java/com/example/erp/ApplicationContextTest.java`
  88 +
  89 +**pom.xml 必须包含的依赖:**
  90 +- `spring-boot-starter-parent` 3.x(parent)
  91 +- `spring-boot-starter-web`, `spring-boot-starter-security`, `spring-boot-starter-validation`, `spring-boot-starter-test`
  92 +- `mybatis-plus-spring-boot3-starter` 3.5.x
  93 +- `mysql-connector-j`(runtime scope)
  94 +- `flyway-core` + `flyway-mysql`(10.x)
  95 +- `jjwt-api` + `jjwt-impl` + `jjwt-jackson`(0.12.x;impl/jackson 用 runtime scope)
  96 +- `lombok`(optional)
  97 +- `hutool-all` 5.8.x
  98 +
  99 +**application.yml 关键片段:**
  100 +```yaml
  101 +spring:
  102 + datasource:
  103 + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
  104 + username: ${DB_USER}
  105 + password: ${DB_PASSWORD}
  106 + driver-class-name: com.mysql.cj.jdbc.Driver
  107 + flyway:
  108 + locations: classpath:db/migration
  109 + baseline-on-migrate: true
  110 + baseline-version: 1
  111 +server:
  112 + port: 8080
  113 +jwt:
  114 + secret: ${JWT_SECRET}
  115 + access-token-expiry: 86400
  116 + refresh-token-expiry: 604800
  117 +```
  118 +
  119 +**application-dev.yml:**
  120 +```yaml
  121 +spring:
  122 + config:
  123 + import: optional:file:.env.local[.properties]
  124 +```
  125 +
  126 +**说明:** `.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 已应用,不重复执行。
  127 +
  128 +- [ ] **Step 1: 写失败测试**
  129 + - 测试名: `ApplicationContextTest#contextLoads`
  130 + - 意图: `@SpringBootTest` 启动 ApplicationContext 不报错(验证 Bean 配置和 DB 连接)
  131 + - 子会话确认 FAIL(项目不存在,无法编译)
  132 +
  133 +- [ ] **Step 2: 创建 Maven 项目结构**
  134 + - 创建目录树:`backend/src/main/java/com/example/erp/`、`backend/src/main/resources/db/migration/`、`backend/src/test/java/com/example/erp/`
  135 + - 写 pom.xml(含上述依赖)
  136 + - 写 Application.java(`@SpringBootApplication`,标准 main 方法)
  137 + - 写 application.yml + application-dev.yml(按上述片段)
  138 + - 复制 `sql/migrations/V1__initial_schema.sql` 内容到 `backend/src/main/resources/db/migration/V1__initial_schema.sql`
  139 + - 写 `ApplicationContextTest.java`(`@SpringBootTest`,空的 `contextLoads()` 方法)
  140 + - 写 `backend/src/test/java/com/example/erp/TestApplication.java`(继承 `Application`,供测试使用 dev profile)
  141 +
  142 +- [ ] **Step 3: 子会话运行 `cd backend && mvn test -Dspring.profiles.active=dev`,确认 contextLoads PASS**
  143 +
  144 +- [ ] **Step 4: Commit**
  145 + - `git add backend/`
  146 + - `git commit -m "chore(backend): init Spring Boot 3 project skeleton REQ-USR-004"`
  147 +
  148 +---
  149 +
  150 +### Task 2: Common 层(Result / BizException / ErrorCode / GlobalExceptionHandler)
  151 +
  152 +**Files:**
  153 +- 创建: `backend/src/main/java/com/example/erp/common/response/Result.java`
  154 +- 创建: `backend/src/main/java/com/example/erp/common/exception/BizException.java`
  155 +- 创建: `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java`
  156 +- 创建: `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java`
  157 +- 测试: `backend/src/test/java/com/example/erp/common/ResultTest.java`
  158 +
  159 +**API shape:**
  160 +- `Result<T>` — 字段:`int code`,`String message`,`T data`,`long timestamp`
  161 + - `static <T> Result<T> ok(T data)` → code=200, message="操作成功", timestamp=System.currentTimeMillis()
  162 + - `static <T> Result<T> fail(int code, String message)` → data=null, timestamp=System.currentTimeMillis()
  163 +- `BizException(int code, String message)` — 继承 RuntimeException,含 `int code` 字段
  164 +- `GlobalExceptionHandler (@RestControllerAdvice)`:
  165 + - `handleBizException(BizException e)` → `Result.fail(e.getCode(), e.getMessage())`,HTTP 200
  166 + - `handleMethodArgumentNotValid(MethodArgumentNotValidException e)` → `Result.fail(40001, 首个字段错误信息)`,HTTP 200
  167 + - `handleException(Exception e)` → `Result.fail(99000, "系统内部错误")`,记 Logback error 级日志
  168 +
  169 +**合同级错误码常量(AuthErrorCode.java,`public static final int`):**
  170 +```java
  171 +USERNAME_OR_PASSWORD_ERROR = 40100 // 用户名或密码错误(不区分哪个,防枚举)
  172 +ACCOUNT_DISABLED = 40101 // 账号已被禁用,请联系管理员
  173 +ACCOUNT_LOCKED = 40102 // 账号已被锁定,请 N 分钟后重试
  174 +REFRESH_TOKEN_INVALID = 40103 // Refresh Token 已失效,请重新登录
  175 +```
  176 +
  177 +- [ ] **Step 1: 写失败测试**
  178 + - `ResultTest#ok_setsCode200AndData` — `Result.ok("hello").getCode() == 200 && "hello".equals(result.getData())`
  179 + - `ResultTest#fail_setsCodeAndNullData` — `Result.fail(40100, "msg").getCode() == 40100 && result.getData() == null`
  180 + - `ResultTest#ok_hasTimestamp` — `Result.ok(null).getTimestamp() > 0`
  181 + - 子会话确认 FAIL(类不存在)
  182 +
  183 +- [ ] **Step 2: 实现 Result.java + BizException.java + AuthErrorCode.java + GlobalExceptionHandler.java**
  184 +
  185 +- [ ] **Step 3: 子会话运行 `mvn test`,确认 3 个 ResultTest PASS**
  186 +
  187 +- [ ] **Step 4: Commit**
  188 + - `git commit -m "feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004"`
  189 +
  190 +---
  191 +
  192 +### Task 3: JwtUtil
  193 +
  194 +**Files:**
  195 +- 创建: `backend/src/main/java/com/example/erp/config/JwtProperties.java`
  196 +- 创建: `backend/src/main/java/com/example/erp/common/util/JwtUtil.java`
  197 +- 测试: `backend/src/test/java/com/example/erp/common/JwtUtilTest.java`
  198 +
  199 +**API shape:**
  200 +- `JwtProperties (@ConfigurationProperties("jwt"))` — `String secret`,`long accessTokenExpiry`(秒),`long refreshTokenExpiry`(秒);加 `@EnableConfigurationProperties(JwtProperties.class)` 于 Application 或 Config 类
  201 +- `JwtUtil (@Component)`:注入 JwtProperties
  202 + - `generateAccessToken(String userId, String username, String userType, String brandId) : String`
  203 + - claims: sub=userId, "username"=username, "userType"=userType, "brandId"=brandId, exp=now + accessTokenExpiry 秒
  204 + - HMAC-SHA256,key = `Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8))`
  205 + - `generateRefreshToken(String userId, String brandId) : String`
  206 + - claims: sub=userId, "brandId"=brandId, "type"="refresh", exp=now + refreshTokenExpiry 秒
  207 + - `parseAccessToken(String token) : Claims`
  208 + - 若签名无效或过期 → throw `new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Token 已失效,请重新登录")`
  209 + - `parseRefreshToken(String token) : Claims`
  210 + - 解析同 parseAccessToken
  211 + - 验证 claim "type" == "refresh";否则 → throw BizException(40103)
  212 +
  213 +- [ ] **Step 1: 写失败测试**
  214 + - `JwtUtilTest#generateAndParseAccessToken_containsAllClaims`
  215 + - 构造 JwtUtil(properties: secret="testSecretKey32CharacterMinLength!", accessTokenExpiry=86400, refreshTokenExpiry=604800)
  216 + - 生成 access token,parseAccessToken → sub=="u1", username=="admin", userType=="超级管理员", brandId=="b1"
  217 + - `JwtUtilTest#parseRefreshToken_withAccessToken_throws40103`
  218 + - 生成 access token,传入 parseRefreshToken → 抛 BizException,code=40103
  219 + - `JwtUtilTest#parseAccessToken_withExpiredToken_throws40103`
  220 + - 构造 JwtUtil(accessTokenExpiry=-1 或 0),生成 token 后 parseAccessToken → 抛 BizException,code=40103
  221 + - 子会话确认 FAIL(类不存在)
  222 +
  223 +- [ ] **Step 2: 实现 JwtProperties + JwtUtil**
  224 + - JJWT 0.12.x API:`Jwts.builder()...signWith(key, Jwts.SIG.HS256).compact()`;解析:`Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()`
  225 +
  226 +- [ ] **Step 3: 子会话运行 JwtUtilTest(3 个测试),确认 PASS**
  227 +
  228 +- [ ] **Step 4: Commit**
  229 + - `git commit -m "feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004"`
  230 +
  231 +---
  232 +
  233 +### Task 4: Entity + Mapper(BrandEntity / UsrUserEntity)
  234 +
  235 +**Files:**
  236 +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java`
  237 +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java`
  238 +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java`
  239 +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java`
  240 +- 创建: `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java`
  241 +- 测试: `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java`
  242 +
  243 +**API shape:**
  244 +- `BrandEntity (@TableName("brand"))` — 字段(均 `@TableField("<column_name>")`):`iIncrement`(@TableId,AUTO),`sId`,`sNo`,`sName`,`sShortName`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)`
  245 +- `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)`
  246 +- `BrandMapper extends BaseMapper<BrandEntity>`(无额外方法)
  247 +- `UsrUserMapper extends BaseMapper<UsrUserEntity>`(无额外方法;更新逻辑在 Service 用 LambdaUpdateWrapper 完成)
  248 +- `MyBatisPlusConfig (@Configuration)` — `@MapperScan("com.example.erp.module.*.mapper")`;注册 `MybatisPlusInterceptor` + `PaginationInnerInterceptor(DbType.MYSQL)`
  249 +
  250 +**测试(BrandMapperTest — @SpringBootTest,使用真实测试库):**
  251 +- `@BeforeEach` 插入 brand 行:`sId='b-test-001', sNo='TST', sName='测试版', iIncrement=null`(自增),其余字段留 null
  252 +- `@AfterEach` DELETE WHERE sNo='TST'
  253 +- 测试方法 `findByNo_returnsCorrectBrand` — `new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, "TST")` selectOne → sName == "测试版"
  254 +
  255 +- [ ] **Step 1: 写失败测试**
  256 + - `BrandMapperTest#findByNo_returnsCorrectBrand`
  257 + - 子会话确认 FAIL(类不存在)
  258 +
  259 +- [ ] **Step 2: 实现 Entity + Mapper + MyBatisPlusConfig**
  260 + - 注意:Entity 字段名用 Java camelCase,`@TableField` 注解对应数据库实际列名(如 `@TableField("sBrandsId")` 或直接用 MyBatis-Plus 全局下划线转换——由于列名本身是驼峰,需关闭 `map-underscore-to-camel-case` 或手动 @TableField)
  261 +
  262 +- [ ] **Step 3: 子会话运行 BrandMapperTest,确认 PASS**
  263 +
  264 +- [ ] **Step 4: Commit**
  265 + - `git commit -m "feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004"`
  266 +
  267 +---
  268 +
  269 +### Task 5: AuthService — 登录核心逻辑
  270 +
  271 +**Files:**
  272 +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java`
  273 +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java`
  274 +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java`
  275 +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java`
  276 +- 创建: `backend/src/main/java/com/example/erp/config/BeanConfig.java`
  277 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java`
  278 +
  279 +**API shape:**
  280 +- `LoginReqDTO` — `@NotBlank String brandNo`,`@NotBlank String username`,`@NotBlank String password`
  281 +- `LoginVO` — `String accessToken`,`String refreshToken`,`long expiresIn`(固定值 86400),`UserInfoVO userInfo`
  282 + - `UserInfoVO (static inner class)` — `String userId`,`String username`,`String userType`,`String language`,`String brandId`
  283 +- `AuthService` — `LoginVO login(LoginReqDTO req)`;`String refresh(String refreshToken)`;`List<BrandVO> getBrands()`
  284 +- `AuthServiceImpl (@Service @Transactional)` — 注入 `BrandMapper`,`UsrUserMapper`,`JwtUtil`,`BCryptPasswordEncoder`
  285 +
  286 +**AuthServiceImpl.login 业务规则(按顺序):**
  287 +1. `brandMapper.selectOne(new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo()))` → null → `throw new BizException(40100, "用户名或密码错误")`
  288 +2. `userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId()))` → null → `throw new BizException(40100, "用户名或密码错误")`
  289 +3. `user.getBIsDisabled() == 1` → `throw new BizException(40101, "账号已被禁用,请联系管理员")`
  290 +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 + " 分钟后重试")`
  291 +5. `!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())` → `int newCount = user.getILoginFailCount() + 1`
  292 + - `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 分钟后重试")`
  293 + - `newCount < 5`: `userMapper.update(null, new LambdaUpdateWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount))` → `throw new BizException(40100, "用户名或密码错误")`
  294 +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
  295 +
  296 +- [ ] **Step 1: 写 7 个失败单元测试(AuthServiceTest — @ExtendWith(MockitoExtension.class),mock BrandMapper/UsrUserMapper/JwtUtil/BCryptPasswordEncoder)**
  297 + - `login_brandNotFound_throws40100` — brandMapper.selectOne → null → BizException(40100)
  298 + - `login_userNotFound_throws40100` — brand 存在,userMapper.selectOne → null → BizException(40100)
  299 + - `login_accountDisabled_throws40101` — user.bIsDisabled=1 → BizException(40101)
  300 + - `login_accountLocked_throws40102WithRemainingMinutes` — user.tLockUntil=now+20min → BizException(40102),message 含 "20 分钟"
  301 + - `login_wrongPassword_firstTime_throws40100AndIncrementsCount` — BCrypt 不匹配,iLoginFailCount=0 → 更新为 1,throw 40100
  302 + - `login_wrongPassword_5thTime_setsLockAndThrows40102` — BCrypt 不匹配,iLoginFailCount=4 → 更新为 5,设 tLockUntil,throw 40102
  303 + - `login_success_resetsCountAndReturnsTokens` — BCrypt 匹配 → reset count,issue tokens,返回 LoginVO
  304 +
  305 +- [ ] **Step 2: 实现 LoginReqDTO + LoginVO + AuthService + AuthServiceImpl.login()**
  306 + - `BCryptPasswordEncoder` 注入:在 Task 5 中同步创建 `BeanConfig.java`(`@Configuration @Bean BCryptPasswordEncoder passwordEncoder()`),使 Spring context(ApplicationContextTest)在 Task 5 之后仍能正常加载;SecurityConfig(Task 7)不重复声明此 Bean
  307 +
  308 +- [ ] **Step 3: 子会话运行 AuthServiceTest(7 个测试),确认 PASS**
  309 +
  310 +- [ ] **Step 4: Commit**
  311 + - `git commit -m "feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004"`
  312 +
  313 +---
  314 +
  315 +### Task 6: AuthService — refresh + getBrands
  316 +
  317 +**Files:**
  318 +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java`
  319 +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java`
  320 +- 修改: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java`(新增 refresh + getBrands)
  321 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java`(追加 3 个测试方法)
  322 +
  323 +**API shape:**
  324 +- `RefreshTokenReqDTO` — `@NotBlank String refreshToken`
  325 +- `BrandVO` — `String sNo`,`String sName`
  326 +- `AuthServiceImpl#refresh(String refreshToken) : String`
  327 + 1. `Claims claims = jwtUtil.parseRefreshToken(refreshToken)` — 无效/过期自动抛 BizException(40103)
  328 + 2. `String userId = claims.getSubject(); String brandId = claims.get("brandId", String.class)`
  329 + 3. `UsrUserEntity user = userMapper.selectOne(new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId))` → null 或 `bIsDisabled=1` → throw BizException(40103, "Refresh Token 已失效,请重新登录")
  330 + 4. 签发新 accessToken:`jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId)`;返回新 accessToken 字符串
  331 +- `AuthServiceImpl#getBrands() : List<BrandVO>`
  332 + - `brandMapper.selectList(new QueryWrapper<BrandEntity>().select("sNo", "sName").orderByAsc("sName"))` → 映射为 BrandVO 列表
  333 +
  334 +- [ ] **Step 1: 写 3 个失败测试(追加到 AuthServiceTest)**
  335 + - `refresh_validRefreshToken_returnsNewAccessToken` — parseRefreshToken 成功,查库返回有效 user → generateAccessToken 被调用,返回新 token
  336 + - `refresh_invalidRefreshToken_throws40103` — parseRefreshToken 抛 BizException(40103)
  337 + - `getBrands_returnsListSortedByName` — brandMapper.selectList 返回 [b1, b2] → 结果 List<BrandVO> 包含对应 sNo/sName
  338 +
  339 +- [ ] **Step 2: 实现 RefreshTokenReqDTO + BrandVO + AuthServiceImpl#refresh() + AuthServiceImpl#getBrands()**
  340 +
  341 +- [ ] **Step 3: 子会话运行 AuthServiceTest(新增 3 个测试),确认 PASS**
  342 +
  343 +- [ ] **Step 4: Commit**
  344 + - `git commit -m "feat(usr): AuthService.refresh + getBrands REQ-USR-004"`
  345 +
  346 +---
  347 +
  348 +### Task 7: SecurityConfig + JwtAuthenticationFilter + AuthController
  349 +
  350 +**Files:**
  351 +- 创建: `backend/src/main/java/com/example/erp/config/SecurityConfig.java`
  352 +- 创建: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  353 +- 创建: `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java`
  354 +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java`
  355 +
  356 +**API shape:**
  357 +- `SecurityConfig (@Configuration @EnableWebSecurity)`:
  358 + - `@Bean SecurityFilterChain`: `csrf().disable()`;`sessionManagement(STATELESS)`;`authorizeHttpRequests`: `permitAll` for `/api/auth/**`,其余 `authenticated`
  359 + - `addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)`
  360 + - 不再声明 BCryptPasswordEncoder @Bean(已在 BeanConfig.java 声明)
  361 +- `JwtAuthenticationFilter extends OncePerRequestFilter`:
  362 + - 读 `Authorization` header,提取 Bearer token
  363 + - `jwtUtil.parseAccessToken(token)` → 设 `UsernamePasswordAuthenticationToken` 入 SecurityContextHolder
  364 + - token 无效 → 不设 context(Spring Security 后续返回 401);请求路径匹配 `/api/auth/**` → 直接放行不解析
  365 +- `AuthController (@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor)`:
  366 + - `@PostMapping("/login") Result<LoginVO> login(@Valid @RequestBody LoginReqDTO req)` → `Result.ok(authService.login(req))`
  367 + - `@PostMapping("/refresh") Result<Map<String,String>> refresh(@Valid @RequestBody RefreshTokenReqDTO req)` → `Result.ok(Map.of("accessToken", authService.refresh(req.getRefreshToken())))`
  368 + - `@GetMapping("/brands") Result<List<BrandVO>> brands()` → `Result.ok(authService.getBrands())`
  369 +
  370 +- [ ] **Step 1: 写 4 个 MockMvc 失败测试(AuthControllerTest — @WebMvcTest + @MockBean AuthService)**
  371 + - `login_wrongPassword_returns40100` — authService.login 抛 BizException(40100) → 响应 JSON code=40100
  372 + - `login_validCredentials_returns200AndTokens` — authService.login 返回 LoginVO → 响应 JSON code=200,accessToken 非空
  373 + - `refresh_invalidToken_returns40103` — authService.refresh 抛 BizException(40103) → code=40103
  374 + - `getBrands_returns200AndList` — authService.getBrands 返回 [BrandVO{sNo="STD", sName="标准版"}] → code=200,list 含该项
  375 +
  376 +- [ ] **Step 2: 实现 SecurityConfig + JwtAuthenticationFilter + AuthController**
  377 +
  378 +- [ ] **Step 3: 子会话运行 AuthControllerTest(4 个测试),确认 PASS**
  379 +
  380 +- [ ] **Step 4: 手动 smoke test(如果后端可本地运行)**
  381 + - `cd backend && mvn spring-boot:run -Dspring-boot.run.profiles=dev`(需 .env.local 在 backend/ 父目录可找到)
  382 + - `curl -s -X GET http://localhost:8080/api/auth/brands | jq .`
  383 + - `curl -s -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"brandNo":"STD","username":"admin","password":"666666"}' | jq .`
  384 +
  385 +- [ ] **Step 5: Commit**
  386 + - `git commit -m "feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004"`
  387 +
  388 +---
  389 +
  390 +### Task 8: 前端项目骨架
  391 +
  392 +**Files:**
  393 +- 创建: `frontend/package.json`
  394 +- 创建: `frontend/vite.config.ts`
  395 +- 创建: `frontend/tsconfig.json`
  396 +- 创建: `frontend/index.html`
  397 +- 创建: `frontend/src/main.tsx`
  398 +- 创建: `frontend/src/App.tsx`
  399 +- 创建: `frontend/src/styles/tokens.css`
  400 +
  401 +**package.json 关键依赖:**
  402 +```json
  403 +"dependencies": {
  404 + "react": "^18.3.0", "react-dom": "^18.3.0",
  405 + "antd": "^5.17.0", "@ant-design/icons": "^5.3.0",
  406 + "@reduxjs/toolkit": "^2.2.0", "react-redux": "^9.1.0",
  407 + "react-router-dom": "^6.23.0",
  408 + "axios": "^1.7.0", "dayjs": "^1.11.0"
  409 +},
  410 +"devDependencies": {
  411 + "vite": "^5.2.0", "@vitejs/plugin-react": "^4.3.0",
  412 + "typescript": "^5.4.0",
  413 + "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0",
  414 + "vitest": "^1.6.0",
  415 + "@testing-library/react": "^15.0.0",
  416 + "@testing-library/jest-dom": "^6.4.0",
  417 + "@testing-library/user-event": "^14.5.0",
  418 + "jsdom": "^24.0.0"
  419 +}
  420 +```
  421 +
  422 +**vite.config.ts 关键配置:**
  423 +```ts
  424 +export default defineConfig({
  425 + plugins: [react()],
  426 + server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } },
  427 + test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts' }
  428 +})
  429 +```
  430 +
  431 +**App.tsx 骨架:** `<BrowserRouter>` 包裹路由,`/login` → `<LoginPage />`,`/` → PrivateRoute(暂时重定向 /login,后续 REQ 补充),`*` → 404
  432 +
  433 +- [ ] **Step 1: 创建前端项目文件结构**
  434 + - `mkdir -p frontend/src/{styles,api,store/slices,pages/usr,test,hooks,components,utils}`
  435 + - 写 package.json(上述依赖)
  436 + - 写 vite.config.ts、tsconfig.json、index.html、src/main.tsx、src/App.tsx(骨架)
  437 + - 复制 `src/styles/tokens.css` 内容到 `frontend/src/styles/tokens.css`
  438 + - 写 `frontend/src/test/setup.ts`(`import "@testing-library/jest-dom"`)
  439 + - `cd frontend && npm install`
  440 +
  441 +- [ ] **Step 2: 验证骨架构建**
  442 + - `cd frontend && npm run build`(应成功,0 错误)
  443 +
  444 +- [ ] **Step 3: Commit**
  445 + - `git add frontend/`
  446 + - `git commit -m "chore(frontend): init Vite React project skeleton REQ-USR-004"`
  447 +
  448 +---
  449 +
  450 +### Task 9: 前端 Auth API 层 + Redux authSlice
  451 +
  452 +**Files:**
  453 +- 创建: `frontend/src/api/request.ts`
  454 +- 创建: `frontend/src/api/auth.ts`
  455 +- 创建: `frontend/src/store/index.ts`
  456 +- 创建: `frontend/src/store/slices/authSlice.ts`
  457 +- 测试: `frontend/src/test/authSlice.test.ts`
  458 +
  459 +**API shape:**
  460 +- `request.ts` — Axios 实例 `baseURL: '/api'`,`timeout: 10000`
  461 + - 请求拦截器:从 `store.getState().auth.accessToken` 读 token,注入 `Authorization: Bearer ${token}`
  462 + - 响应拦截器(成功):`response.data.code !== 200` → 抛 `new Error(response.data.message)`
  463 + - 响应拦截器(HTTP 401):调 `auth.refresh(store.getState().auth.refreshToken)` → 更新 store → 重试原请求;refresh 失败 → `store.dispatch(clearCredentials())` → `window.location.href = '/login'`
  464 +- `auth.ts`:
  465 + - `login(params: { brandNo: string; username: string; password: string }) : Promise<LoginVO>` — POST /api/auth/login
  466 + - `refresh(refreshToken: string) : Promise<{ accessToken: string }>` — POST /api/auth/refresh
  467 + - `getBrands() : Promise<BrandVO[]>` — GET /api/auth/brands
  468 + - 类型定义(TypeScript interface):`LoginVO`(含 accessToken, refreshToken, expiresIn, userInfo: UserInfoVO),`UserInfoVO`(userId, username, userType, language, brandId),`BrandVO`(sNo, sName)
  469 +- `authSlice` — `createSlice({ name: 'auth', initialState: { accessToken: null, refreshToken: null, userInfo: null } })`
  470 + - `setCredentials(state, action: PayloadAction<{ accessToken: string; refreshToken: string; userInfo: UserInfoVO }>)` — 更新三字段
  471 + - `clearCredentials(state)` — 三字段重置 null
  472 +- `store/index.ts` — `configureStore({ reducer: { auth: authReducer } })`;导出 `RootState`、`AppDispatch`
  473 +
  474 +- [ ] **Step 1: 写 2 个失败单元测试(authSlice.test.ts)**
  475 + - `setCredentials_updatesAllStateFields` — dispatch setCredentials({accessToken:"t1", refreshToken:"r1", userInfo:{userId:"u1",...}}) → state.auth.accessToken=="t1"
  476 + - `clearCredentials_resetsToNull` — dispatch clearCredentials → state.auth.accessToken==null
  477 +
  478 +- [ ] **Step 2: 实现 request.ts + auth.ts + authSlice + store/index.ts**
  479 +
  480 +- [ ] **Step 3: 子会话运行 `cd frontend && npm run test -- --run`,确认 authSlice 2 个测试 PASS**
  481 +
  482 +- [ ] **Step 4: Commit**
  483 + - `git commit -m "feat(usr): auth API layer + Redux authSlice REQ-USR-004"`
  484 +
  485 +---
  486 +
  487 +### Task 10: 登录页 LoginPage.tsx
  488 +
  489 +**Files:**
  490 +- 创建: `frontend/src/pages/usr/LoginPage.tsx`
  491 +- 修改: `frontend/src/App.tsx`(添加 /login 路由,PrivateRoute 守卫读 authSlice)
  492 +- 测试: `frontend/src/test/LoginPage.test.tsx`
  493 +
  494 +**UI 规范(来自 docs/06 § 五):**
  495 +- 版本 `Select`(label="公司/版本",name="brandNo"):组件 mount 时调 `getBrands()`,填充选项(value=sNo, label=sName);defaultValue = sName=="标准版" 对应的 sNo;若无"标准版"则选第一项
  496 +- 用户名 `Input`(label="用户名",name="username"):必填
  497 +- 密码 `Input.Password`(label="密码",name="password"):必填
  498 +- 提交按钮(text="登录",`loading={loading}`):防重复点击
  499 +- 失败:`message.error(接口返回 message)`
  500 +- 成功:`dispatch(setCredentials({accessToken, refreshToken, userInfo}))` → `navigate('/')`
  501 +
  502 +**LoginPage 内部数据流:**
  503 +1. `useEffect(() => { getBrands().then(setBrandOptions) }, [])` — 挂载时获取 brand 列表
  504 +2. `onFinish(values)` → `setLoading(true)` → `await login(values)` → `dispatch(setCredentials(...))` → `navigate('/')` → `finally setLoading(false)`
  505 +
  506 +- [ ] **Step 1: 写 2 个失败测试(LoginPage.test.tsx)**
  507 + - `renders_brandSelect_username_and_password_fields`
  508 + - render LoginPage(with Redux Provider + MemoryRouter + mock getBrands → [{sNo:"STD",sName:"标准版"}])
  509 + - 断言:combobox(brand Select)存在,`getByPlaceholderText` 或 label "用户名" Input 存在,"密码" Input 存在,"登录" Button 存在
  510 + - `submit_withValidCredentials_dispatchesSetCredentials`
  511 + - mock `auth.login` 返回 `{accessToken:"at", refreshToken:"rt", expiresIn:86400, userInfo:{userId:"u1",username:"admin",userType:"超级管理员",language:"中文",brandId:"b1"}}`
  512 + - 填写 username + password,点击登录 → `store.getState().auth.accessToken == "at"`
  513 +
  514 +- [ ] **Step 2: 实现 LoginPage.tsx + 更新 App.tsx(/login 路由)**
  515 +
  516 +- [ ] **Step 3: 子会话运行 `npm run test -- --run`,确认所有前端测试(包括 authSlice + LoginPage)PASS**
  517 +
  518 +- [ ] **Step 4: Commit**
  519 + - `git commit -m "feat(usr): LoginPage brand selector + login form REQ-USR-004"`
  520 +
  521 +---
  522 +
  523 +## 提交计划
  524 +
  525 +| 提交消息 | 覆盖 Task |
  526 +|---|---|
  527 +| `chore(backend): init Spring Boot 3 project skeleton REQ-USR-004` | Task 1 |
  528 +| `feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004` | Task 2 |
  529 +| `feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004` | Task 3 |
  530 +| `feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004` | Task 4 |
  531 +| `feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004` | Task 5 |
  532 +| `feat(usr): AuthService.refresh + getBrands REQ-USR-004` | Task 6 |
  533 +| `feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004` | Task 7 |
  534 +| `chore(frontend): init Vite React project skeleton REQ-USR-004` | Task 8 |
  535 +| `feat(usr): auth API layer + Redux authSlice REQ-USR-004` | Task 9 |
  536 +| `feat(usr): LoginPage brand selector + login form REQ-USR-004` | Task 10 |
docs/superpowers/reviews/2026-05-08-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-08
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-004 — round 1
  9 +
  10 +## 结论
  11 +request-changes (approve / request-changes)
  12 +
  13 +## Must-fix
  14 +- [high] sql/migrations/V1__initial_schema.sql:108 — uk_usr_user_username 是全库唯一索引,规格要求多租户隔离,同一 sUsername 应允许出现在不同 brand(sBrandsId)中,唯一性应为 (sUsername, sBrandsId) 复合唯一,否则第二个 brand 的同名用户插入时会触发唯一约束违反(建议:新增 V2__fix_username_unique_per_tenant.sql,DROP 旧索引并 CREATE UNIQUE INDEX uk_usr_user_username_tenant ON usr_user (sUsername, sBrandsId);同步更新 docs/03)
  15 +- [medium] backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:71 — UpdateWrapper 使用裸字符串列名("sId", "iLoginFailCount", "tLockUntil", "tLastLoginDate"),与项目其余代码 LambdaQueryWrapper 不一致,列重命名时不会编译报错(建议:改为 LambdaUpdateWrapper<UsrUserEntity> 使用方法引用,同时在 AuthServiceTest @BeforeAll 初始化 TableInfo 以支持单元测试)
  16 +- [medium] backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:118 — refresh() 只检查 user==null 和 bIsDisabled=1,未检查 tLockUntil;锁定账号可通过持有的 refresh token 持续获取新 access token 长达 7 天,绕过防暴力破解机制(建议:追加 tLockUntil 检查,并新增 refresh_lockedUser_throws40103 测试)
  17 +
  18 +## Nice-to-have
  19 +- docs/05-API接口契约.md:49 — API 契约为 POST /api/auth/login 列出了 40400(公司编号不存在),但规格和实现均正确使用 40100(防枚举),契约文档应删除 40400 行
  20 +- backend/src/main/java/com/example/erp/common/util/JwtUtil.java:72 — doParse() 对 access token 过期也抛 40103(REFRESH_TOKEN_INVALID),语义污染;建议区分 access/refresh 解析路径或使用更通用的 token 失效码
  21 +- backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:57 — Integer.valueOf(1).equals(user.getBIsDisabled()) 写法冗长,可简化为 user.getBIsDisabled() != null && user.getBIsDisabled() == 1
  22 +- backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:110 — refresh() 和 getBrands() 为只读操作,应加 @Transactional(readOnly = true)
  23 +- backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java:93 — login_accountLocked_throws40102WithRemainingMinutes 只断言 message.contains("分钟"),未验证具体分钟数
  24 +
  25 +## 反例 / 测试覆盖缺口
  26 +1. 缺少「锁定期内再次尝试登录仍返回 40102」测试(验收标准第 3 条后半段)
  27 +2. 缺少 refresh_lockedUser_throws40103 测试(见 must_fix 第 3 条)
  28 +3. 缺少多租户隔离集成测试(验收标准第 7 条):同一用户名、不同 brandId 各自独立
  29 +4. 前端 LoginPage.test.tsx 缺少登录失败时 message.error 展示的断言
  30 +5. request.ts 的 401 自动刷新 pendingQueue 并发重试场景无测试覆盖
  31 +6. JwtUtilTest 缺少 generateRefreshToken claims 验证正向测试
docs/superpowers/specs/2026-05-08-REQ-USR-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-004
  3 +date: 2026-05-08
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-004 — 用户登录
  8 +
  9 +## 目标
  10 +
  11 +用户在登录页选择所属公司(brand)、输入用户名和密码,完成身份认证后获取 JWT Access Token 及 Refresh Token,用于后续所有接口鉴权。多租户隔离:每次登录的用户查询限定在所选 brand 的 sBrandsId 范围内。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**POST /api/auth/login**(公开接口,无需 Bearer Token)
  16 +
  17 +```json
  18 +{
  19 + "brandNo": "string", // brand.sNo,前端版本下拉选中项
  20 + "username": "string", // usr_user.sUsername
  21 + "password": "string" // 明文密码(HTTPS 传输),后端用 BCrypt 校验
  22 +}
  23 +```
  24 +
  25 +**辅助:版本下拉数据**
  26 +前端需预先调用 `GET /api/auth/brands` 获取 brand 列表(sNo / sName),填充版本下拉框。默认选中 `sName = "标准版"` 对应的项(若无则选第一项)。此接口亦无需鉴权。
  27 +
  28 +**POST /api/auth/refresh**(无需 Bearer Token,需 Refresh Token)
  29 +
  30 +```json
  31 +{ "refreshToken": "string" }
  32 +```
  33 +
  34 +## 输出 / 结果
  35 +
  36 +**登录成功(HTTP 200)**
  37 +
  38 +```json
  39 +{
  40 + "code": 200,
  41 + "message": "登录成功",
  42 + "data": {
  43 + "accessToken": "<jwt>",
  44 + "refreshToken": "<jwt>",
  45 + "expiresIn": 86400,
  46 + "userInfo": {
  47 + "userId": "string", // usr_user.sId
  48 + "username": "string", // usr_user.sUsername
  49 + "userType": "string", // 普通用户 | 超级管理员
  50 + "language": "string", // 中文 | 英文 | 繁体
  51 + "brandId": "string" // brand.sId,写入 token claims 用于后续多租户隔离
  52 + }
  53 + }
  54 +}
  55 +```
  56 +
  57 +**失败(业务错误,HTTP 200 + code ≠ 0)**
  58 +
  59 +| code | message | 场景 |
  60 +|---|---|---|
  61 +| 40100 | 用户名或密码错误 | 密码错误 / 用户不存在 / brand 不存在(统一文案,防枚举) |
  62 +| 40101 | 账号已被禁用,请联系管理员 | bIsDisabled = 1 |
  63 +| 40102 | 账号已被锁定,请 {N} 分钟后重试 | tLockUntil > NOW() |
  64 +| 40103 | Refresh Token 已失效,请重新登录 | refresh 接口 token 过期或篡改 |
  65 +
  66 +## 业务规则
  67 +
  68 +1. **多租户查询**:先 `SELECT * FROM brand WHERE sNo = ?` 得 `brandId = brand.sId`;再 `SELECT * FROM usr_user WHERE sUsername = ? AND sBrandsId = ?`(brandId)。brand 不存在或用户不存在统一返回 40100(防枚举)。
  69 +
  70 +2. **密码校验**:`BCryptPasswordEncoder.matches(plainPassword, usr_user.sPasswordHash)`。
  71 +
  72 +3. **禁用检查**:`bIsDisabled = 1` → 返回 40101,不更新失败计数。
  73 +
  74 +4. **锁定检查**:`tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 返回 40102,剩余分钟数 = `CEIL((tLockUntil - NOW()) / 60)`。
  75 +
  76 +5. **失败计数**:密码错误时 `iLoginFailCount += 1`;达到 5 次时设 `tLockUntil = NOW() + 30 分钟`,同时返回 40102。
  77 +
  78 +6. **登录成功**:
  79 + - 重置 `iLoginFailCount = 0`,`tLockUntil = NULL`
  80 + - 更新 `tLastLoginDate = NOW()`
  81 + - 签发 Access Token(24h)+ Refresh Token(7d),均为 JWT,签名密钥来自 `JWT_SECRET`(从 `.env.local` 注入,`application.yml` 中 `${JWT_SECRET}`)
  82 +
  83 +7. **JWT Claims**:
  84 + - Access Token:`sub = usr_user.sId`,`username`,`userType`,`brandId = brand.sId`,`exp = now + 24h`
  85 + - Refresh Token:`sub = usr_user.sId`,`brandId`,`type = refresh`,`exp = now + 7d`
  86 +
  87 +8. **Token 刷新**(POST /api/auth/refresh):校验 Refresh Token 签名与 `type=refresh`;从 claims 读 `sub`(userId)和 `brandId` 重新查库确认用户仍有效;签发新 Access Token(24h);Refresh Token **不续期**(滑动窗口留给后续迭代)。
  88 +
  89 +9. **版本下拉**(GET /api/auth/brands):`SELECT sNo, sName FROM brand ORDER BY sName`;无需分页(品牌数量通常极少)。
  90 +
  91 +## 边界与约束
  92 +
  93 +- 所有接口均走 HTTPS(Nginx 层保障,后端不强制)
  94 +- 登录日志(IP + 时间戳)通过 Spring Security 的 `AuthenticationSuccessHandler` / `AuthenticationFailureHandler` 写 Logback,不写业务表
  95 +- Refresh Token 无服务端存储(无状态 JWT);强制退出由后续迭代(引入 token 黑名单)实现;本期用 7 天过期控制
  96 +- 密码明文绝不落库,`sPasswordHash` 仅存 BCrypt 60 字符哈希
  97 +- Access Token 不含密码 hash,不含任何敏感字段
  98 +- 防暴力破解:5 次失败锁 30 分钟(数据库层,不依赖内存,重启后有效)
  99 +- `GET /api/auth/brands` 结果可加短缓存(30s),减少登录页加载压力
  100 +
  101 +## 依赖的 schema 表 / 字段
  102 +
  103 +**usr_user**(主要读写字段)
  104 +
  105 +| 字段 | 操作 | 说明 |
  106 +|---|---|---|
  107 +| `sUsername` | READ | 登录名匹配 |
  108 +| `sBrandsId` | READ | 多租户范围限定 |
  109 +| `sPasswordHash` | READ | BCrypt 校验 |
  110 +| `bIsDisabled` | READ | 禁用检查 |
  111 +| `tLockUntil` | READ / WRITE | 锁定截止时间 |
  112 +| `iLoginFailCount` | READ / WRITE | 连续失败次数 |
  113 +| `tLastLoginDate` | WRITE | 成功后更新 |
  114 +| `sId` | READ | 写入 JWT sub |
  115 +| `sUserType` | READ | 写入 JWT claims |
  116 +| `sLanguage` | READ | 返回 userInfo |
  117 +
  118 +**brand**
  119 +
  120 +| 字段 | 操作 | 说明 |
  121 +|---|---|---|
  122 +| `sNo` | READ | 版本下拉匹配键 |
  123 +| `sId` | READ | 写入 JWT brandId claim |
  124 +| `sName` | READ | 版本下拉显示名 |
  125 +
  126 +## 依赖的接口
  127 +
  128 +- 自身即认证入口,无上游接口依赖
  129 +- 对外暴露:`POST /api/auth/login`、`POST /api/auth/refresh`、`GET /api/auth/brands`(均无鉴权)
  130 +
  131 +## 验收标准
  132 +
  133 +1. 正确 brandNo + username + password → HTTP 200,返回有效 accessToken,`POST /api/usr/users` 携带该 token 鉴权通过
  134 +2. 错误密码 → code=40100,且不暴露是用户名还是密码错了
  135 +3. 连续 5 次错误密码 → 第 5 次返回 code=40102(含剩余分钟数),后续在锁定期内再请求仍返回 40102
  136 +4. 锁定 30 分钟后自动解锁,正确凭据可再次登录
  137 +5. `bIsDisabled = 1` 的账号登录 → code=40101
  138 +6. brand.sNo 不存在 → code=40100(与密码错误同一文案)
  139 +7. 用同一账号、不同 brandId 登录 → 互不干扰(多租户隔离)
  140 +8. 有效 refreshToken → POST /api/auth/refresh 返回新 accessToken
  141 +9. 过期 / 伪造 refreshToken → code=40103
  142 +10. GET /api/auth/brands 返回所有 brand 列表(sNo + sName)
scripts/test.sh
@@ -8,6 +8,14 @@ set -euo pipefail @@ -8,6 +8,14 @@ set -euo pipefail
8 PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 8 PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9 cd "$PROJECT_ROOT" 9 cd "$PROJECT_ROOT"
10 10
  11 +# Use Java 21 for Lombok compatibility (system default may be Java 25+)
  12 +if [ -d "/opt/homebrew/Cellar/openjdk@21" ]; then
  13 + JAVA21="$(ls -d /opt/homebrew/Cellar/openjdk@21/*/bin/java 2>/dev/null | tail -1)"
  14 + if [ -n "$JAVA21" ]; then
  15 + export JAVA_HOME="$(dirname "$(dirname "$JAVA21")")"
  16 + fi
  17 +fi
  18 +
11 # Stack detection (runtime, mode-agnostic) 19 # Stack detection (runtime, mode-agnostic)
12 HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 20 HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1
13 HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 21 HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1
sql/migrations/V2__fix_username_unique_per_tenant.sql 0 → 100644
  1 +-- Flyway migration V2 — fix usr_user username uniqueness to per-tenant scope
  2 +-- Generated: 2026-05-08
  3 +-- Reason: uk_usr_user_username was globally unique; same username must be allowed across different brands (sBrandsId)
  4 +-- New unique constraint: (sUsername, sBrandsId) composite
  5 +
  6 +ALTER TABLE usr_user DROP INDEX uk_usr_user_username;
  7 +CREATE UNIQUE INDEX uk_usr_user_username_tenant ON usr_user (sUsername, sBrandsId);