Commit 2c78bc87b51f18e456eb71f189b9df17cb6539e5
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 编译兼容性修复
Showing
10 changed files
with
753 additions
and
12 deletions
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); |