From 2c78bc87b51f18e456eb71f189b9df17cb6539e5 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 10:27:47 +0800 Subject: [PATCH] fix(usr): review round-1 修复 — 多租户唯一索引 + LambdaUpdateWrapper + refresh 锁定检查 REQ-USR-004 --- backend/.mvn/jvm.config | 1 + backend/pom.xml | 1 + backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java | 24 +++++++++++++----------- backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java | 13 +++++++++++++ docs/03-数据库设计文档.md | 2 +- docs/superpowers/plans/2026-05-08-REQ-USR-004.md | 536 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/reviews/2026-05-08-REQ-USR-004.md | 31 +++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-05-08-REQ-USR-004.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ scripts/test.sh | 8 ++++++++ sql/migrations/V2__fix_username_unique_per_tenant.sql | 7 +++++++ 10 files changed, 753 insertions(+), 12 deletions(-) create mode 100644 backend/.mvn/jvm.config create mode 100644 docs/superpowers/plans/2026-05-08-REQ-USR-004.md create mode 100644 docs/superpowers/reviews/2026-05-08-REQ-USR-004.md create mode 100644 docs/superpowers/specs/2026-05-08-REQ-USR-004.md create mode 100644 sql/migrations/V2__fix_username_unique_per_tenant.sql diff --git a/backend/.mvn/jvm.config b/backend/.mvn/jvm.config new file mode 100644 index 0000000..b55b5a7 --- /dev/null +++ b/backend/.mvn/jvm.config @@ -0,0 +1 @@ +-XX:+EnableDynamicAgentLoading diff --git a/backend/pom.xml b/backend/pom.xml index 864ac17..44ebf24 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -20,6 +20,7 @@ 21 + 1.18.36 3.5.7 0.12.6 5.8.28 diff --git a/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java index de5d607..d5fd0fe 100644 --- a/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java +++ b/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; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.erp.common.constants.AuthErrorCode; import com.example.erp.common.exception.BizException; import com.example.erp.common.util.JwtUtil; @@ -68,12 +68,12 @@ public class AuthServiceImpl implements AuthService { // 5. 密码校验 if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) { int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1; - UpdateWrapper updateWrapper = new UpdateWrapper() - .eq("sId", user.getSId()) - .set("iLoginFailCount", newCount); + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .eq(UsrUserEntity::getSId, user.getSId()) + .set(UsrUserEntity::getILoginFailCount, newCount); if (newCount >= 5) { LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30); - updateWrapper.set("tLockUntil", lockUntil); + updateWrapper.set(UsrUserEntity::getTLockUntil, lockUntil); userMapper.update(null, updateWrapper); throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试"); } @@ -82,11 +82,11 @@ public class AuthServiceImpl implements AuthService { } // 6. 登录成功 - userMapper.update(null, new UpdateWrapper() - .eq("sId", user.getSId()) - .set("iLoginFailCount", 0) - .set("tLockUntil", null) - .set("tLastLoginDate", LocalDateTime.now())); + userMapper.update(null, new LambdaUpdateWrapper() + .eq(UsrUserEntity::getSId, user.getSId()) + .set(UsrUserEntity::getILoginFailCount, 0) + .set(UsrUserEntity::getTLockUntil, null) + .set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now())); String accessToken = jwtUtil.generateAccessToken( user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId()); @@ -115,7 +115,9 @@ public class AuthServiceImpl implements AuthService { UsrUserEntity user = userMapper.selectOne( new LambdaQueryWrapper().eq(UsrUserEntity::getSId, userId)); - if (user == null || Integer.valueOf(1).equals(user.getBIsDisabled())) { + if (user == null + || Integer.valueOf(1).equals(user.getBIsDisabled()) + || (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now()))) { throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录"); } diff --git a/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java index bcaef42..655822a 100644 --- a/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java +++ b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java @@ -154,6 +154,19 @@ class AuthServiceTest { } @Test + void refresh_lockedUser_throws40103() { + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn("u1"); + when(claims.get("brandId", String.class)).thenReturn("b1"); + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims); + user.setTLockUntil(LocalDateTime.now().plusMinutes(25)); + when(userMapper.selectOne(any())).thenReturn(user); + + BizException ex = assertThrows(BizException.class, () -> authService.refresh("valid-refresh")); + assertEquals(40103, ex.getCode()); + } + + @Test void refresh_invalidRefreshToken_throws40103() { when(jwtUtil.parseRefreshToken("bad-token")) .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录")); diff --git a/docs/03-数据库设计文档.md b/docs/03-数据库设计文档.md index b543682..b038d06 100644 --- a/docs/03-数据库设计文档.md +++ b/docs/03-数据库设计文档.md @@ -64,7 +64,7 @@ usr_user(用户主表) ### 索引 -- `uk_usr_user_username` (UNIQUE): `sUsername` — 全局唯一约束 +- `uk_usr_user_username_tenant` (UNIQUE): `(sUsername, sBrandsId)` — 用户名在同一 brand 内唯一(V2 迁移:原全局唯一改为多租户复合唯一) - `uk_usr_user_usercode` (UNIQUE): `sUserCode` — 用户号唯一约束 - `idx_usr_user_tenant` (INDEX): `sBrandsId, sSubsidiaryId` — 多租户隔离查询 - `idx_usr_user_type` (INDEX): `sUserType` — 按用户类型过滤 diff --git a/docs/superpowers/plans/2026-05-08-REQ-USR-004.md b/docs/superpowers/plans/2026-05-08-REQ-USR-004.md new file mode 100644 index 0000000..ebdb67c --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-REQ-USR-004.md @@ -0,0 +1,536 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-08 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-004.md +--- + +# REQ-USR-004 用户登录 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 从零搭建后端 Spring Boot 3 项目与前端 Vite+React 项目,实现三条公开认证接口(POST /api/auth/login、POST /api/auth/refresh、GET /api/auth/brands),完成多租户隔离的 JWT 登录认证及账号锁定防暴力破解。 + +**Architecture:** 后端分四层推进:公共层(Result/BizException/JwtUtil)→ 数据访问层(BrandMapper/UsrUserMapper)→ 业务层(AuthServiceImpl,含 brand 多租户查找、BCrypt 校验、禁用/锁定检查、失败计数、JWT 签发 6 条业务规则)→ 接口层(SecurityConfig + AuthController)。前端分三层推进:项目骨架 → Axios 封装 + Redux authSlice → LoginPage.tsx 含 brand 下拉 + 用户名密码表单。 + +**Tech Stack:** Spring Boot 3.x · Spring Security · JJWT 0.12.x · MyBatis-Plus 3.5.x · Flyway 10.x · MySQL 8.x · Vite · React 18 · Ant Design 5.x · Redux Toolkit · Axios · Vitest + @testing-library/react + +--- + +## Schema 改动 + +无(`sql/migrations/V1__initial_schema.sql` 已包含 `usr_user` + `brand` 表,已 apply 至测试库 `xlyweberp_vibe_erp_test`) + +## 文件变更清单 + +### 后端(全部新建) +- `backend/pom.xml` — 创建(Spring Boot 3 Maven 项目根 POM) +- `backend/src/main/java/com/example/erp/Application.java` — 创建(启动类) +- `backend/src/main/resources/application.yml` — 创建(主配置,DB/JWT 用 `${ENV_VAR}` 占位) +- `backend/src/main/resources/application-dev.yml` — 创建(dev profile,Flyway baseline 防重复迁移) +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` — 创建(内容与 `sql/migrations/V1__initial_schema.sql` 完全一致,供 Flyway classpath 找到) +- `backend/src/main/java/com/example/erp/common/response/Result.java` — 创建(统一响应体,含 timestamp) +- `backend/src/main/java/com/example/erp/common/exception/BizException.java` — 创建(业务异常基类) +- `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java` — 创建(认证错误码常量) +- `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java` — 创建(@RestControllerAdvice) +- `backend/src/main/java/com/example/erp/common/util/JwtUtil.java` — 创建(JWT 生成 + 解析) +- `backend/src/main/java/com/example/erp/config/JwtProperties.java` — 创建(@ConfigurationProperties("jwt")) +- `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java` — 创建(brand 表映射) +- `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java` — 创建(usr_user 表映射) +- `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java` — 创建(extends BaseMapper) +- `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 创建(extends BaseMapper) +- `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java` — 创建(@MapperScan + 分页插件) +- `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java` — 创建(登录入参) +- `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java` — 创建(刷新入参) +- `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java` — 创建(登录出参,含内部静态类 UserInfoVO) +- `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java` — 创建(brand 下拉出参) +- `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java` — 创建(接口) +- `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java` — 创建(业务实现) +- `backend/src/main/java/com/example/erp/config/BeanConfig.java` — 创建(@Bean BCryptPasswordEncoder;与 SecurityConfig 分离,避免循环依赖) +- `backend/src/main/java/com/example/erp/config/SecurityConfig.java` — 创建(放行 /api/auth/**,注册 JwtFilter) +- `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` — 创建(OncePerRequestFilter,验证 Bearer Token) +- `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java` — 创建(3 个端点) +- `backend/src/test/java/com/example/erp/ApplicationContextTest.java` — 创建 +- `backend/src/test/java/com/example/erp/common/JwtUtilTest.java` — 创建 +- `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java` — 创建 +- `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java` — 创建 +- `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java` — 创建 + +### 前端(全部新建) +- `frontend/package.json` — 创建 +- `frontend/vite.config.ts` — 创建(代理 /api → http://localhost:8080) +- `frontend/tsconfig.json` — 创建 +- `frontend/index.html` — 创建 +- `frontend/src/main.tsx` — 创建(Provider + BrowserRouter + App) +- `frontend/src/App.tsx` — 创建(路由表,/ 重定向 /login,受保护路由守卫 PrivateRoute) +- `frontend/src/styles/tokens.css` — 创建(内容与根目录 `src/styles/tokens.css` 一致) +- `frontend/src/api/request.ts` — 创建(Axios 实例 + 请求/响应拦截器 + 401 refresh 流程) +- `frontend/src/api/auth.ts` — 创建(login / refresh / getBrands 接口函数) +- `frontend/src/store/index.ts` — 创建(configureStore) +- `frontend/src/store/slices/authSlice.ts` — 创建(setCredentials / clearCredentials) +- `frontend/src/pages/usr/LoginPage.tsx` — 创建(AntD Form:brand Select + 用户名 + 密码 + 提交) +- `frontend/src/test/setup.ts` — 创建(@testing-library/jest-dom setup) +- `frontend/src/test/authSlice.test.ts` — 创建 +- `frontend/src/test/LoginPage.test.tsx` — 创建 + +--- + +## 任务步骤 + +### Task 1: 后端项目骨架 + +**Files:** +- 创建: `backend/pom.xml` +- 创建: `backend/src/main/java/com/example/erp/Application.java` +- 创建: `backend/src/main/resources/application.yml` +- 创建: `backend/src/main/resources/application-dev.yml` +- 创建: `backend/src/main/resources/db/migration/V1__initial_schema.sql` +- 测试: `backend/src/test/java/com/example/erp/ApplicationContextTest.java` + +**pom.xml 必须包含的依赖:** +- `spring-boot-starter-parent` 3.x(parent) +- `spring-boot-starter-web`, `spring-boot-starter-security`, `spring-boot-starter-validation`, `spring-boot-starter-test` +- `mybatis-plus-spring-boot3-starter` 3.5.x +- `mysql-connector-j`(runtime scope) +- `flyway-core` + `flyway-mysql`(10.x) +- `jjwt-api` + `jjwt-impl` + `jjwt-jackson`(0.12.x;impl/jackson 用 runtime scope) +- `lombok`(optional) +- `hutool-all` 5.8.x + +**application.yml 关键片段:** +```yaml +spring: + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 1 +server: + port: 8080 +jwt: + secret: ${JWT_SECRET} + access-token-expiry: 86400 + refresh-token-expiry: 604800 +``` + +**application-dev.yml:** +```yaml +spring: + config: + import: optional:file:.env.local[.properties] +``` + +**说明:** `.env.local` 含 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_SCHEMA`, `JWT_SECRET`。dev profile 通过 `spring.config.import` 自动加载;`baseline-on-migrate=true` + `baseline-version=1` 让 Flyway 把已有 schema 标记为 V1 已应用,不重复执行。 + +- [ ] **Step 1: 写失败测试** + - 测试名: `ApplicationContextTest#contextLoads` + - 意图: `@SpringBootTest` 启动 ApplicationContext 不报错(验证 Bean 配置和 DB 连接) + - 子会话确认 FAIL(项目不存在,无法编译) + +- [ ] **Step 2: 创建 Maven 项目结构** + - 创建目录树:`backend/src/main/java/com/example/erp/`、`backend/src/main/resources/db/migration/`、`backend/src/test/java/com/example/erp/` + - 写 pom.xml(含上述依赖) + - 写 Application.java(`@SpringBootApplication`,标准 main 方法) + - 写 application.yml + application-dev.yml(按上述片段) + - 复制 `sql/migrations/V1__initial_schema.sql` 内容到 `backend/src/main/resources/db/migration/V1__initial_schema.sql` + - 写 `ApplicationContextTest.java`(`@SpringBootTest`,空的 `contextLoads()` 方法) + - 写 `backend/src/test/java/com/example/erp/TestApplication.java`(继承 `Application`,供测试使用 dev profile) + +- [ ] **Step 3: 子会话运行 `cd backend && mvn test -Dspring.profiles.active=dev`,确认 contextLoads PASS** + +- [ ] **Step 4: Commit** + - `git add backend/` + - `git commit -m "chore(backend): init Spring Boot 3 project skeleton REQ-USR-004"` + +--- + +### Task 2: Common 层(Result / BizException / ErrorCode / GlobalExceptionHandler) + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/common/response/Result.java` +- 创建: `backend/src/main/java/com/example/erp/common/exception/BizException.java` +- 创建: `backend/src/main/java/com/example/erp/common/constants/AuthErrorCode.java` +- 创建: `backend/src/main/java/com/example/erp/common/exception/GlobalExceptionHandler.java` +- 测试: `backend/src/test/java/com/example/erp/common/ResultTest.java` + +**API shape:** +- `Result` — 字段:`int code`,`String message`,`T data`,`long timestamp` + - `static Result ok(T data)` → code=200, message="操作成功", timestamp=System.currentTimeMillis() + - `static Result fail(int code, String message)` → data=null, timestamp=System.currentTimeMillis() +- `BizException(int code, String message)` — 继承 RuntimeException,含 `int code` 字段 +- `GlobalExceptionHandler (@RestControllerAdvice)`: + - `handleBizException(BizException e)` → `Result.fail(e.getCode(), e.getMessage())`,HTTP 200 + - `handleMethodArgumentNotValid(MethodArgumentNotValidException e)` → `Result.fail(40001, 首个字段错误信息)`,HTTP 200 + - `handleException(Exception e)` → `Result.fail(99000, "系统内部错误")`,记 Logback error 级日志 + +**合同级错误码常量(AuthErrorCode.java,`public static final int`):** +```java +USERNAME_OR_PASSWORD_ERROR = 40100 // 用户名或密码错误(不区分哪个,防枚举) +ACCOUNT_DISABLED = 40101 // 账号已被禁用,请联系管理员 +ACCOUNT_LOCKED = 40102 // 账号已被锁定,请 N 分钟后重试 +REFRESH_TOKEN_INVALID = 40103 // Refresh Token 已失效,请重新登录 +``` + +- [ ] **Step 1: 写失败测试** + - `ResultTest#ok_setsCode200AndData` — `Result.ok("hello").getCode() == 200 && "hello".equals(result.getData())` + - `ResultTest#fail_setsCodeAndNullData` — `Result.fail(40100, "msg").getCode() == 40100 && result.getData() == null` + - `ResultTest#ok_hasTimestamp` — `Result.ok(null).getTimestamp() > 0` + - 子会话确认 FAIL(类不存在) + +- [ ] **Step 2: 实现 Result.java + BizException.java + AuthErrorCode.java + GlobalExceptionHandler.java** + +- [ ] **Step 3: 子会话运行 `mvn test`,确认 3 个 ResultTest PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004"` + +--- + +### Task 3: JwtUtil + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/config/JwtProperties.java` +- 创建: `backend/src/main/java/com/example/erp/common/util/JwtUtil.java` +- 测试: `backend/src/test/java/com/example/erp/common/JwtUtilTest.java` + +**API shape:** +- `JwtProperties (@ConfigurationProperties("jwt"))` — `String secret`,`long accessTokenExpiry`(秒),`long refreshTokenExpiry`(秒);加 `@EnableConfigurationProperties(JwtProperties.class)` 于 Application 或 Config 类 +- `JwtUtil (@Component)`:注入 JwtProperties + - `generateAccessToken(String userId, String username, String userType, String brandId) : String` + - claims: sub=userId, "username"=username, "userType"=userType, "brandId"=brandId, exp=now + accessTokenExpiry 秒 + - HMAC-SHA256,key = `Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8))` + - `generateRefreshToken(String userId, String brandId) : String` + - claims: sub=userId, "brandId"=brandId, "type"="refresh", exp=now + refreshTokenExpiry 秒 + - `parseAccessToken(String token) : Claims` + - 若签名无效或过期 → throw `new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Token 已失效,请重新登录")` + - `parseRefreshToken(String token) : Claims` + - 解析同 parseAccessToken + - 验证 claim "type" == "refresh";否则 → throw BizException(40103) + +- [ ] **Step 1: 写失败测试** + - `JwtUtilTest#generateAndParseAccessToken_containsAllClaims` + - 构造 JwtUtil(properties: secret="testSecretKey32CharacterMinLength!", accessTokenExpiry=86400, refreshTokenExpiry=604800) + - 生成 access token,parseAccessToken → sub=="u1", username=="admin", userType=="超级管理员", brandId=="b1" + - `JwtUtilTest#parseRefreshToken_withAccessToken_throws40103` + - 生成 access token,传入 parseRefreshToken → 抛 BizException,code=40103 + - `JwtUtilTest#parseAccessToken_withExpiredToken_throws40103` + - 构造 JwtUtil(accessTokenExpiry=-1 或 0),生成 token 后 parseAccessToken → 抛 BizException,code=40103 + - 子会话确认 FAIL(类不存在) + +- [ ] **Step 2: 实现 JwtProperties + JwtUtil** + - JJWT 0.12.x API:`Jwts.builder()...signWith(key, Jwts.SIG.HS256).compact()`;解析:`Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()` + +- [ ] **Step 3: 子会话运行 JwtUtilTest(3 个测试),确认 PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004"` + +--- + +### Task 4: Entity + Mapper(BrandEntity / UsrUserEntity) + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/BrandEntity.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/entity/UsrUserEntity.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/BrandMapper.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` +- 创建: `backend/src/main/java/com/example/erp/config/MyBatisPlusConfig.java` +- 测试: `backend/src/test/java/com/example/erp/module/usr/BrandMapperTest.java` + +**API shape:** +- `BrandEntity (@TableName("brand"))` — 字段(均 `@TableField("")`):`iIncrement`(@TableId,AUTO),`sId`,`sNo`,`sName`,`sShortName`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)` +- `UsrUserEntity (@TableName("usr_user"))` — 字段:`iIncrement`(@TableId,AUTO),`sId`,`sBrandsId`,`sSubsidiaryId`,`tCreateDate(LocalDateTime)`,`sUserCode`,`sUsername`,`sPasswordHash`,`sUserType`,`sLanguage`,`bCanEditDoc(Integer)`,`bIsDisabled(Integer)`,`sEmployeeId`,`sCreatorUsername`,`tLastLoginDate(LocalDateTime)`,`iLoginFailCount(Integer)`,`tLockUntil(LocalDateTime)` +- `BrandMapper extends BaseMapper`(无额外方法) +- `UsrUserMapper extends BaseMapper`(无额外方法;更新逻辑在 Service 用 LambdaUpdateWrapper 完成) +- `MyBatisPlusConfig (@Configuration)` — `@MapperScan("com.example.erp.module.*.mapper")`;注册 `MybatisPlusInterceptor` + `PaginationInnerInterceptor(DbType.MYSQL)` + +**测试(BrandMapperTest — @SpringBootTest,使用真实测试库):** +- `@BeforeEach` 插入 brand 行:`sId='b-test-001', sNo='TST', sName='测试版', iIncrement=null`(自增),其余字段留 null +- `@AfterEach` DELETE WHERE sNo='TST' +- 测试方法 `findByNo_returnsCorrectBrand` — `new LambdaQueryWrapper().eq(BrandEntity::getSNo, "TST")` selectOne → sName == "测试版" + +- [ ] **Step 1: 写失败测试** + - `BrandMapperTest#findByNo_returnsCorrectBrand` + - 子会话确认 FAIL(类不存在) + +- [ ] **Step 2: 实现 Entity + Mapper + MyBatisPlusConfig** + - 注意:Entity 字段名用 Java camelCase,`@TableField` 注解对应数据库实际列名(如 `@TableField("sBrandsId")` 或直接用 MyBatis-Plus 全局下划线转换——由于列名本身是驼峰,需关闭 `map-underscore-to-camel-case` 或手动 @TableField) + +- [ ] **Step 3: 子会话运行 BrandMapperTest,确认 PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004"` + +--- + +### Task 5: AuthService — 登录核心逻辑 + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/AuthService.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java` +- 创建: `backend/src/main/java/com/example/erp/config/BeanConfig.java` +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java` + +**API shape:** +- `LoginReqDTO` — `@NotBlank String brandNo`,`@NotBlank String username`,`@NotBlank String password` +- `LoginVO` — `String accessToken`,`String refreshToken`,`long expiresIn`(固定值 86400),`UserInfoVO userInfo` + - `UserInfoVO (static inner class)` — `String userId`,`String username`,`String userType`,`String language`,`String brandId` +- `AuthService` — `LoginVO login(LoginReqDTO req)`;`String refresh(String refreshToken)`;`List getBrands()` +- `AuthServiceImpl (@Service @Transactional)` — 注入 `BrandMapper`,`UsrUserMapper`,`JwtUtil`,`BCryptPasswordEncoder` + +**AuthServiceImpl.login 业务规则(按顺序):** +1. `brandMapper.selectOne(new LambdaQueryWrapper().eq(BrandEntity::getSNo, req.getBrandNo()))` → null → `throw new BizException(40100, "用户名或密码错误")` +2. `userMapper.selectOne(new LambdaQueryWrapper().eq(UsrUserEntity::getSUsername, req.getUsername()).eq(UsrUserEntity::getSBrandsId, brand.getSId()))` → null → `throw new BizException(40100, "用户名或密码错误")` +3. `user.getBIsDisabled() == 1` → `throw new BizException(40101, "账号已被禁用,请联系管理员")` +4. `user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())` → 计算 remainMinutes = `(int) Math.ceil(ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil()) / 60.0)` → `throw new BizException(40102, "账号已被锁定,请 " + remainMinutes + " 分钟后重试")` +5. `!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())` → `int newCount = user.getILoginFailCount() + 1` + - `newCount >= 5`: `LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30)`;`userMapper.update(null, new LambdaUpdateWrapper().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount).set(UsrUserEntity::getTLockUntil, lockUntil))` → `throw new BizException(40102, "账号已被锁定,请 30 分钟后重试")` + - `newCount < 5`: `userMapper.update(null, new LambdaUpdateWrapper().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, newCount))` → `throw new BizException(40100, "用户名或密码错误")` +6. 成功:`userMapper.update(null, new LambdaUpdateWrapper().eq(UsrUserEntity::getSId, user.getSId()).set(UsrUserEntity::getILoginFailCount, 0).set(UsrUserEntity::getTLockUntil, null).set(UsrUserEntity::getTLastLoginDate, LocalDateTime.now()))`;签发 tokens;返回 LoginVO + +- [ ] **Step 1: 写 7 个失败单元测试(AuthServiceTest — @ExtendWith(MockitoExtension.class),mock BrandMapper/UsrUserMapper/JwtUtil/BCryptPasswordEncoder)** + - `login_brandNotFound_throws40100` — brandMapper.selectOne → null → BizException(40100) + - `login_userNotFound_throws40100` — brand 存在,userMapper.selectOne → null → BizException(40100) + - `login_accountDisabled_throws40101` — user.bIsDisabled=1 → BizException(40101) + - `login_accountLocked_throws40102WithRemainingMinutes` — user.tLockUntil=now+20min → BizException(40102),message 含 "20 分钟" + - `login_wrongPassword_firstTime_throws40100AndIncrementsCount` — BCrypt 不匹配,iLoginFailCount=0 → 更新为 1,throw 40100 + - `login_wrongPassword_5thTime_setsLockAndThrows40102` — BCrypt 不匹配,iLoginFailCount=4 → 更新为 5,设 tLockUntil,throw 40102 + - `login_success_resetsCountAndReturnsTokens` — BCrypt 匹配 → reset count,issue tokens,返回 LoginVO + +- [ ] **Step 2: 实现 LoginReqDTO + LoginVO + AuthService + AuthServiceImpl.login()** + - `BCryptPasswordEncoder` 注入:在 Task 5 中同步创建 `BeanConfig.java`(`@Configuration @Bean BCryptPasswordEncoder passwordEncoder()`),使 Spring context(ApplicationContextTest)在 Task 5 之后仍能正常加载;SecurityConfig(Task 7)不重复声明此 Bean + +- [ ] **Step 3: 子会话运行 AuthServiceTest(7 个测试),确认 PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004"` + +--- + +### Task 6: AuthService — refresh + getBrands + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java` +- 修改: `backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java`(新增 refresh + getBrands) +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java`(追加 3 个测试方法) + +**API shape:** +- `RefreshTokenReqDTO` — `@NotBlank String refreshToken` +- `BrandVO` — `String sNo`,`String sName` +- `AuthServiceImpl#refresh(String refreshToken) : String` + 1. `Claims claims = jwtUtil.parseRefreshToken(refreshToken)` — 无效/过期自动抛 BizException(40103) + 2. `String userId = claims.getSubject(); String brandId = claims.get("brandId", String.class)` + 3. `UsrUserEntity user = userMapper.selectOne(new LambdaQueryWrapper().eq(UsrUserEntity::getSId, userId))` → null 或 `bIsDisabled=1` → throw BizException(40103, "Refresh Token 已失效,请重新登录") + 4. 签发新 accessToken:`jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId)`;返回新 accessToken 字符串 +- `AuthServiceImpl#getBrands() : List` + - `brandMapper.selectList(new QueryWrapper().select("sNo", "sName").orderByAsc("sName"))` → 映射为 BrandVO 列表 + +- [ ] **Step 1: 写 3 个失败测试(追加到 AuthServiceTest)** + - `refresh_validRefreshToken_returnsNewAccessToken` — parseRefreshToken 成功,查库返回有效 user → generateAccessToken 被调用,返回新 token + - `refresh_invalidRefreshToken_throws40103` — parseRefreshToken 抛 BizException(40103) + - `getBrands_returnsListSortedByName` — brandMapper.selectList 返回 [b1, b2] → 结果 List 包含对应 sNo/sName + +- [ ] **Step 2: 实现 RefreshTokenReqDTO + BrandVO + AuthServiceImpl#refresh() + AuthServiceImpl#getBrands()** + +- [ ] **Step 3: 子会话运行 AuthServiceTest(新增 3 个测试),确认 PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): AuthService.refresh + getBrands REQ-USR-004"` + +--- + +### Task 7: SecurityConfig + JwtAuthenticationFilter + AuthController + +**Files:** +- 创建: `backend/src/main/java/com/example/erp/config/SecurityConfig.java` +- 创建: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` +- 创建: `backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java` +- 测试: `backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java` + +**API shape:** +- `SecurityConfig (@Configuration @EnableWebSecurity)`: + - `@Bean SecurityFilterChain`: `csrf().disable()`;`sessionManagement(STATELESS)`;`authorizeHttpRequests`: `permitAll` for `/api/auth/**`,其余 `authenticated` + - `addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)` + - 不再声明 BCryptPasswordEncoder @Bean(已在 BeanConfig.java 声明) +- `JwtAuthenticationFilter extends OncePerRequestFilter`: + - 读 `Authorization` header,提取 Bearer token + - `jwtUtil.parseAccessToken(token)` → 设 `UsernamePasswordAuthenticationToken` 入 SecurityContextHolder + - token 无效 → 不设 context(Spring Security 后续返回 401);请求路径匹配 `/api/auth/**` → 直接放行不解析 +- `AuthController (@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor)`: + - `@PostMapping("/login") Result login(@Valid @RequestBody LoginReqDTO req)` → `Result.ok(authService.login(req))` + - `@PostMapping("/refresh") Result> refresh(@Valid @RequestBody RefreshTokenReqDTO req)` → `Result.ok(Map.of("accessToken", authService.refresh(req.getRefreshToken())))` + - `@GetMapping("/brands") Result> brands()` → `Result.ok(authService.getBrands())` + +- [ ] **Step 1: 写 4 个 MockMvc 失败测试(AuthControllerTest — @WebMvcTest + @MockBean AuthService)** + - `login_wrongPassword_returns40100` — authService.login 抛 BizException(40100) → 响应 JSON code=40100 + - `login_validCredentials_returns200AndTokens` — authService.login 返回 LoginVO → 响应 JSON code=200,accessToken 非空 + - `refresh_invalidToken_returns40103` — authService.refresh 抛 BizException(40103) → code=40103 + - `getBrands_returns200AndList` — authService.getBrands 返回 [BrandVO{sNo="STD", sName="标准版"}] → code=200,list 含该项 + +- [ ] **Step 2: 实现 SecurityConfig + JwtAuthenticationFilter + AuthController** + +- [ ] **Step 3: 子会话运行 AuthControllerTest(4 个测试),确认 PASS** + +- [ ] **Step 4: 手动 smoke test(如果后端可本地运行)** + - `cd backend && mvn spring-boot:run -Dspring-boot.run.profiles=dev`(需 .env.local 在 backend/ 父目录可找到) + - `curl -s -X GET http://localhost:8080/api/auth/brands | jq .` + - `curl -s -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"brandNo":"STD","username":"admin","password":"666666"}' | jq .` + +- [ ] **Step 5: Commit** + - `git commit -m "feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004"` + +--- + +### Task 8: 前端项目骨架 + +**Files:** +- 创建: `frontend/package.json` +- 创建: `frontend/vite.config.ts` +- 创建: `frontend/tsconfig.json` +- 创建: `frontend/index.html` +- 创建: `frontend/src/main.tsx` +- 创建: `frontend/src/App.tsx` +- 创建: `frontend/src/styles/tokens.css` + +**package.json 关键依赖:** +```json +"dependencies": { + "react": "^18.3.0", "react-dom": "^18.3.0", + "antd": "^5.17.0", "@ant-design/icons": "^5.3.0", + "@reduxjs/toolkit": "^2.2.0", "react-redux": "^9.1.0", + "react-router-dom": "^6.23.0", + "axios": "^1.7.0", "dayjs": "^1.11.0" +}, +"devDependencies": { + "vite": "^5.2.0", "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.4.0", + "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "vitest": "^1.6.0", + "@testing-library/react": "^15.0.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/user-event": "^14.5.0", + "jsdom": "^24.0.0" +} +``` + +**vite.config.ts 关键配置:** +```ts +export default defineConfig({ + plugins: [react()], + server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }, + test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts' } +}) +``` + +**App.tsx 骨架:** `` 包裹路由,`/login` → ``,`/` → PrivateRoute(暂时重定向 /login,后续 REQ 补充),`*` → 404 + +- [ ] **Step 1: 创建前端项目文件结构** + - `mkdir -p frontend/src/{styles,api,store/slices,pages/usr,test,hooks,components,utils}` + - 写 package.json(上述依赖) + - 写 vite.config.ts、tsconfig.json、index.html、src/main.tsx、src/App.tsx(骨架) + - 复制 `src/styles/tokens.css` 内容到 `frontend/src/styles/tokens.css` + - 写 `frontend/src/test/setup.ts`(`import "@testing-library/jest-dom"`) + - `cd frontend && npm install` + +- [ ] **Step 2: 验证骨架构建** + - `cd frontend && npm run build`(应成功,0 错误) + +- [ ] **Step 3: Commit** + - `git add frontend/` + - `git commit -m "chore(frontend): init Vite React project skeleton REQ-USR-004"` + +--- + +### Task 9: 前端 Auth API 层 + Redux authSlice + +**Files:** +- 创建: `frontend/src/api/request.ts` +- 创建: `frontend/src/api/auth.ts` +- 创建: `frontend/src/store/index.ts` +- 创建: `frontend/src/store/slices/authSlice.ts` +- 测试: `frontend/src/test/authSlice.test.ts` + +**API shape:** +- `request.ts` — Axios 实例 `baseURL: '/api'`,`timeout: 10000` + - 请求拦截器:从 `store.getState().auth.accessToken` 读 token,注入 `Authorization: Bearer ${token}` + - 响应拦截器(成功):`response.data.code !== 200` → 抛 `new Error(response.data.message)` + - 响应拦截器(HTTP 401):调 `auth.refresh(store.getState().auth.refreshToken)` → 更新 store → 重试原请求;refresh 失败 → `store.dispatch(clearCredentials())` → `window.location.href = '/login'` +- `auth.ts`: + - `login(params: { brandNo: string; username: string; password: string }) : Promise` — POST /api/auth/login + - `refresh(refreshToken: string) : Promise<{ accessToken: string }>` — POST /api/auth/refresh + - `getBrands() : Promise` — GET /api/auth/brands + - 类型定义(TypeScript interface):`LoginVO`(含 accessToken, refreshToken, expiresIn, userInfo: UserInfoVO),`UserInfoVO`(userId, username, userType, language, brandId),`BrandVO`(sNo, sName) +- `authSlice` — `createSlice({ name: 'auth', initialState: { accessToken: null, refreshToken: null, userInfo: null } })` + - `setCredentials(state, action: PayloadAction<{ accessToken: string; refreshToken: string; userInfo: UserInfoVO }>)` — 更新三字段 + - `clearCredentials(state)` — 三字段重置 null +- `store/index.ts` — `configureStore({ reducer: { auth: authReducer } })`;导出 `RootState`、`AppDispatch` + +- [ ] **Step 1: 写 2 个失败单元测试(authSlice.test.ts)** + - `setCredentials_updatesAllStateFields` — dispatch setCredentials({accessToken:"t1", refreshToken:"r1", userInfo:{userId:"u1",...}}) → state.auth.accessToken=="t1" + - `clearCredentials_resetsToNull` — dispatch clearCredentials → state.auth.accessToken==null + +- [ ] **Step 2: 实现 request.ts + auth.ts + authSlice + store/index.ts** + +- [ ] **Step 3: 子会话运行 `cd frontend && npm run test -- --run`,确认 authSlice 2 个测试 PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): auth API layer + Redux authSlice REQ-USR-004"` + +--- + +### Task 10: 登录页 LoginPage.tsx + +**Files:** +- 创建: `frontend/src/pages/usr/LoginPage.tsx` +- 修改: `frontend/src/App.tsx`(添加 /login 路由,PrivateRoute 守卫读 authSlice) +- 测试: `frontend/src/test/LoginPage.test.tsx` + +**UI 规范(来自 docs/06 § 五):** +- 版本 `Select`(label="公司/版本",name="brandNo"):组件 mount 时调 `getBrands()`,填充选项(value=sNo, label=sName);defaultValue = sName=="标准版" 对应的 sNo;若无"标准版"则选第一项 +- 用户名 `Input`(label="用户名",name="username"):必填 +- 密码 `Input.Password`(label="密码",name="password"):必填 +- 提交按钮(text="登录",`loading={loading}`):防重复点击 +- 失败:`message.error(接口返回 message)` +- 成功:`dispatch(setCredentials({accessToken, refreshToken, userInfo}))` → `navigate('/')` + +**LoginPage 内部数据流:** +1. `useEffect(() => { getBrands().then(setBrandOptions) }, [])` — 挂载时获取 brand 列表 +2. `onFinish(values)` → `setLoading(true)` → `await login(values)` → `dispatch(setCredentials(...))` → `navigate('/')` → `finally setLoading(false)` + +- [ ] **Step 1: 写 2 个失败测试(LoginPage.test.tsx)** + - `renders_brandSelect_username_and_password_fields` + - render LoginPage(with Redux Provider + MemoryRouter + mock getBrands → [{sNo:"STD",sName:"标准版"}]) + - 断言:combobox(brand Select)存在,`getByPlaceholderText` 或 label "用户名" Input 存在,"密码" Input 存在,"登录" Button 存在 + - `submit_withValidCredentials_dispatchesSetCredentials` + - mock `auth.login` 返回 `{accessToken:"at", refreshToken:"rt", expiresIn:86400, userInfo:{userId:"u1",username:"admin",userType:"超级管理员",language:"中文",brandId:"b1"}}` + - 填写 username + password,点击登录 → `store.getState().auth.accessToken == "at"` + +- [ ] **Step 2: 实现 LoginPage.tsx + 更新 App.tsx(/login 路由)** + +- [ ] **Step 3: 子会话运行 `npm run test -- --run`,确认所有前端测试(包括 authSlice + LoginPage)PASS** + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): LoginPage brand selector + login form REQ-USR-004"` + +--- + +## 提交计划 + +| 提交消息 | 覆盖 Task | +|---|---| +| `chore(backend): init Spring Boot 3 project skeleton REQ-USR-004` | Task 1 | +| `feat(usr): common Result/BizException/AuthErrorCode/GlobalExceptionHandler REQ-USR-004` | Task 2 | +| `feat(usr): JwtUtil generate + parse access/refresh token REQ-USR-004` | Task 3 | +| `feat(usr): BrandEntity/UsrUserEntity + Mapper REQ-USR-004` | Task 4 | +| `feat(usr): AuthService.login multi-tenant + lockout logic REQ-USR-004` | Task 5 | +| `feat(usr): AuthService.refresh + getBrands REQ-USR-004` | Task 6 | +| `feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004` | Task 7 | +| `chore(frontend): init Vite React project skeleton REQ-USR-004` | Task 8 | +| `feat(usr): auth API layer + Redux authSlice REQ-USR-004` | Task 9 | +| `feat(usr): LoginPage brand selector + login form REQ-USR-004` | Task 10 | diff --git a/docs/superpowers/reviews/2026-05-08-REQ-USR-004.md b/docs/superpowers/reviews/2026-05-08-REQ-USR-004.md new file mode 100644 index 0000000..574cb13 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-08-REQ-USR-004.md @@ -0,0 +1,31 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-08 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-004 — round 1 + +## 结论 +request-changes (approve / request-changes) + +## Must-fix +- [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) +- [medium] backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:71 — UpdateWrapper 使用裸字符串列名("sId", "iLoginFailCount", "tLockUntil", "tLastLoginDate"),与项目其余代码 LambdaQueryWrapper 不一致,列重命名时不会编译报错(建议:改为 LambdaUpdateWrapper 使用方法引用,同时在 AuthServiceTest @BeforeAll 初始化 TableInfo 以支持单元测试) +- [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 测试) + +## Nice-to-have +- docs/05-API接口契约.md:49 — API 契约为 POST /api/auth/login 列出了 40400(公司编号不存在),但规格和实现均正确使用 40100(防枚举),契约文档应删除 40400 行 +- backend/src/main/java/com/example/erp/common/util/JwtUtil.java:72 — doParse() 对 access token 过期也抛 40103(REFRESH_TOKEN_INVALID),语义污染;建议区分 access/refresh 解析路径或使用更通用的 token 失效码 +- 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 +- backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java:110 — refresh() 和 getBrands() 为只读操作,应加 @Transactional(readOnly = true) +- backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java:93 — login_accountLocked_throws40102WithRemainingMinutes 只断言 message.contains("分钟"),未验证具体分钟数 + +## 反例 / 测试覆盖缺口 +1. 缺少「锁定期内再次尝试登录仍返回 40102」测试(验收标准第 3 条后半段) +2. 缺少 refresh_lockedUser_throws40103 测试(见 must_fix 第 3 条) +3. 缺少多租户隔离集成测试(验收标准第 7 条):同一用户名、不同 brandId 各自独立 +4. 前端 LoginPage.test.tsx 缺少登录失败时 message.error 展示的断言 +5. request.ts 的 401 自动刷新 pendingQueue 并发重试场景无测试覆盖 +6. JwtUtilTest 缺少 generateRefreshToken claims 验证正向测试 diff --git a/docs/superpowers/specs/2026-05-08-REQ-USR-004.md b/docs/superpowers/specs/2026-05-08-REQ-USR-004.md new file mode 100644 index 0000000..d85aeca --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-REQ-USR-004.md @@ -0,0 +1,142 @@ +--- +req_id: REQ-USR-004 +date: 2026-05-08 +module: module_usr +--- + +# Spec: REQ-USR-004 — 用户登录 + +## 目标 + +用户在登录页选择所属公司(brand)、输入用户名和密码,完成身份认证后获取 JWT Access Token 及 Refresh Token,用于后续所有接口鉴权。多租户隔离:每次登录的用户查询限定在所选 brand 的 sBrandsId 范围内。 + +## 输入 / 触发 + +**POST /api/auth/login**(公开接口,无需 Bearer Token) + +```json +{ + "brandNo": "string", // brand.sNo,前端版本下拉选中项 + "username": "string", // usr_user.sUsername + "password": "string" // 明文密码(HTTPS 传输),后端用 BCrypt 校验 +} +``` + +**辅助:版本下拉数据** +前端需预先调用 `GET /api/auth/brands` 获取 brand 列表(sNo / sName),填充版本下拉框。默认选中 `sName = "标准版"` 对应的项(若无则选第一项)。此接口亦无需鉴权。 + +**POST /api/auth/refresh**(无需 Bearer Token,需 Refresh Token) + +```json +{ "refreshToken": "string" } +``` + +## 输出 / 结果 + +**登录成功(HTTP 200)** + +```json +{ + "code": 200, + "message": "登录成功", + "data": { + "accessToken": "", + "refreshToken": "", + "expiresIn": 86400, + "userInfo": { + "userId": "string", // usr_user.sId + "username": "string", // usr_user.sUsername + "userType": "string", // 普通用户 | 超级管理员 + "language": "string", // 中文 | 英文 | 繁体 + "brandId": "string" // brand.sId,写入 token claims 用于后续多租户隔离 + } + } +} +``` + +**失败(业务错误,HTTP 200 + code ≠ 0)** + +| code | message | 场景 | +|---|---|---| +| 40100 | 用户名或密码错误 | 密码错误 / 用户不存在 / brand 不存在(统一文案,防枚举) | +| 40101 | 账号已被禁用,请联系管理员 | bIsDisabled = 1 | +| 40102 | 账号已被锁定,请 {N} 分钟后重试 | tLockUntil > NOW() | +| 40103 | Refresh Token 已失效,请重新登录 | refresh 接口 token 过期或篡改 | + +## 业务规则 + +1. **多租户查询**:先 `SELECT * FROM brand WHERE sNo = ?` 得 `brandId = brand.sId`;再 `SELECT * FROM usr_user WHERE sUsername = ? AND sBrandsId = ?`(brandId)。brand 不存在或用户不存在统一返回 40100(防枚举)。 + +2. **密码校验**:`BCryptPasswordEncoder.matches(plainPassword, usr_user.sPasswordHash)`。 + +3. **禁用检查**:`bIsDisabled = 1` → 返回 40101,不更新失败计数。 + +4. **锁定检查**:`tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 返回 40102,剩余分钟数 = `CEIL((tLockUntil - NOW()) / 60)`。 + +5. **失败计数**:密码错误时 `iLoginFailCount += 1`;达到 5 次时设 `tLockUntil = NOW() + 30 分钟`,同时返回 40102。 + +6. **登录成功**: + - 重置 `iLoginFailCount = 0`,`tLockUntil = NULL` + - 更新 `tLastLoginDate = NOW()` + - 签发 Access Token(24h)+ Refresh Token(7d),均为 JWT,签名密钥来自 `JWT_SECRET`(从 `.env.local` 注入,`application.yml` 中 `${JWT_SECRET}`) + +7. **JWT Claims**: + - Access Token:`sub = usr_user.sId`,`username`,`userType`,`brandId = brand.sId`,`exp = now + 24h` + - Refresh Token:`sub = usr_user.sId`,`brandId`,`type = refresh`,`exp = now + 7d` + +8. **Token 刷新**(POST /api/auth/refresh):校验 Refresh Token 签名与 `type=refresh`;从 claims 读 `sub`(userId)和 `brandId` 重新查库确认用户仍有效;签发新 Access Token(24h);Refresh Token **不续期**(滑动窗口留给后续迭代)。 + +9. **版本下拉**(GET /api/auth/brands):`SELECT sNo, sName FROM brand ORDER BY sName`;无需分页(品牌数量通常极少)。 + +## 边界与约束 + +- 所有接口均走 HTTPS(Nginx 层保障,后端不强制) +- 登录日志(IP + 时间戳)通过 Spring Security 的 `AuthenticationSuccessHandler` / `AuthenticationFailureHandler` 写 Logback,不写业务表 +- Refresh Token 无服务端存储(无状态 JWT);强制退出由后续迭代(引入 token 黑名单)实现;本期用 7 天过期控制 +- 密码明文绝不落库,`sPasswordHash` 仅存 BCrypt 60 字符哈希 +- Access Token 不含密码 hash,不含任何敏感字段 +- 防暴力破解:5 次失败锁 30 分钟(数据库层,不依赖内存,重启后有效) +- `GET /api/auth/brands` 结果可加短缓存(30s),减少登录页加载压力 + +## 依赖的 schema 表 / 字段 + +**usr_user**(主要读写字段) + +| 字段 | 操作 | 说明 | +|---|---|---| +| `sUsername` | READ | 登录名匹配 | +| `sBrandsId` | READ | 多租户范围限定 | +| `sPasswordHash` | READ | BCrypt 校验 | +| `bIsDisabled` | READ | 禁用检查 | +| `tLockUntil` | READ / WRITE | 锁定截止时间 | +| `iLoginFailCount` | READ / WRITE | 连续失败次数 | +| `tLastLoginDate` | WRITE | 成功后更新 | +| `sId` | READ | 写入 JWT sub | +| `sUserType` | READ | 写入 JWT claims | +| `sLanguage` | READ | 返回 userInfo | + +**brand** + +| 字段 | 操作 | 说明 | +|---|---|---| +| `sNo` | READ | 版本下拉匹配键 | +| `sId` | READ | 写入 JWT brandId claim | +| `sName` | READ | 版本下拉显示名 | + +## 依赖的接口 + +- 自身即认证入口,无上游接口依赖 +- 对外暴露:`POST /api/auth/login`、`POST /api/auth/refresh`、`GET /api/auth/brands`(均无鉴权) + +## 验收标准 + +1. 正确 brandNo + username + password → HTTP 200,返回有效 accessToken,`POST /api/usr/users` 携带该 token 鉴权通过 +2. 错误密码 → code=40100,且不暴露是用户名还是密码错了 +3. 连续 5 次错误密码 → 第 5 次返回 code=40102(含剩余分钟数),后续在锁定期内再请求仍返回 40102 +4. 锁定 30 分钟后自动解锁,正确凭据可再次登录 +5. `bIsDisabled = 1` 的账号登录 → code=40101 +6. brand.sNo 不存在 → code=40100(与密码错误同一文案) +7. 用同一账号、不同 brandId 登录 → 互不干扰(多租户隔离) +8. 有效 refreshToken → POST /api/auth/refresh 返回新 accessToken +9. 过期 / 伪造 refreshToken → code=40103 +10. GET /api/auth/brands 返回所有 brand 列表(sNo + sName) diff --git a/scripts/test.sh b/scripts/test.sh index d91a597..fd854a0 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,6 +8,14 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$PROJECT_ROOT" +# Use Java 21 for Lombok compatibility (system default may be Java 25+) +if [ -d "/opt/homebrew/Cellar/openjdk@21" ]; then + JAVA21="$(ls -d /opt/homebrew/Cellar/openjdk@21/*/bin/java 2>/dev/null | tail -1)" + if [ -n "$JAVA21" ]; then + export JAVA_HOME="$(dirname "$(dirname "$JAVA21")")" + fi +fi + # Stack detection (runtime, mode-agnostic) HAS_BACKEND=0; [ -d backend ] && HAS_BACKEND=1 HAS_FRONTEND=0; [ -d frontend ] && HAS_FRONTEND=1 diff --git a/sql/migrations/V2__fix_username_unique_per_tenant.sql b/sql/migrations/V2__fix_username_unique_per_tenant.sql new file mode 100644 index 0000000..e207bbf --- /dev/null +++ b/sql/migrations/V2__fix_username_unique_per_tenant.sql @@ -0,0 +1,7 @@ +-- Flyway migration V2 — fix usr_user username uniqueness to per-tenant scope +-- Generated: 2026-05-08 +-- Reason: uk_usr_user_username was globally unique; same username must be allowed across different brands (sBrandsId) +-- New unique constraint: (sUsername, sBrandsId) composite + +ALTER TABLE usr_user DROP INDEX uk_usr_user_username; +CREATE UNIQUE INDEX uk_usr_user_username_tenant ON usr_user (sUsername, sBrandsId); -- libgit2 0.22.2