# 商家会话统一认证实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将商家 JWT 签名、有效期和 Redis 会话校验集中到 `@Auth` 认证切面,删除业务 Controller 对 `MerchantTokenSessionService.requireUserId` 的二次认证。 **Architecture:** `@Auth(session = true)` 显式声明需要 Redis 会话的商家接口;`AuthAspect` 一次解析已验证 JWT、校验 JTI 并把可信用户 ID/JTI 写入当前请求属性。Controller 通过只读的 `AuthContext` 获取认证结果并继续执行门店、订单和商品归属校验;`MerchantTokenSessionService` 只管理退出、撤销和在线状态。 **Tech Stack:** Java 21、Spring Boot、Spring AOP、Auth0 Java JWT、Redis、JUnit 5、Mockito、Maven。 ## Global Constraints - 保持模块依赖方向 `ruoyi-admin -> ruoyi-system`,`ruoyi-system` 不导入 `com.ruoyi.app.*`。 - 普通用户、骑手和未显式设置 `session = true` 的 `@Auth` 接口保持现有 JWT 校验行为。 - 商家接口继续直接声明 `@RequestHeader String token`,不使用 `HttpServletRequest` 读取 Controller 入参。 - Controller 的门店、订单和商品实际归属校验必须保留,不得把认证收口等同于业务授权。 - 所有错误使用现有国际化键 `merchant.session.invalid`,不新增硬编码单语言业务错误。 - 使用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`,只在当前命令设置 `JAVA_HOME` 和 `PATH`。 - 仅暂存本任务源码、测试和规格,不混入 `.claude/homunculus/observations.jsonl`、`.tmp/` 或其他已有脏文件。 --- ### Task 1:认证切面与可信身份上下文 **Files:** - Modify: `ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java` - Modify: `ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java` - Modify: `ruoyi-system/src/main/java/com/ruoyi/system/utils/JwtUtil.java` - Create: `ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthContext.java` - Create: `ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java` **Interfaces:** - Produces: `Auth.session(): boolean`,默认 `false`。 - Produces: `AuthContext.requireUserId(): Long` 与 `AuthContext.requireJti(): String`。 - Produces: 请求属性 `AuthContext.USER_ID_ATTRIBUTE`、`AuthContext.JTI_ATTRIBUTE`。 - [ ] **Step 1: 编写失败测试** 在 `AuthAspectTest` 定义分别标注 `@Auth` 和 `@Auth(session = true)` 的样例方法,并验证三项行为:默认认证不访问 Redis;启用会话认证且 Redis 含 JTI 时调用 `joinPoint.proceed()`;Redis 缺少 JTI 时抛出 `ServiceException` 且不进入目标方法。 ```java static class SecuredMethods { @Auth public void jwtOnly() {} @Auth(session = true) public void merchantSession() {} } assertSame(expected, aspect.around(joinPoint)); verify(redisCache, never()).hasKey(anyString()); when(redisCache.hasKey(jti)).thenReturn(false); assertThrows(ServiceException.class, () -> aspect.around(joinPoint)); verify(joinPoint, never()).proceed(); ``` - [ ] **Step 2: 运行测试确认 RED** Run: ```powershell $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7' $env:PATH="$env:JAVA_HOME\bin;$env:PATH" mvn -pl ruoyi-system -Dtest=AuthAspectTest test ``` Expected: FAIL,因为 `Auth.session`、Redis 会话校验和可信身份请求属性尚不存在。 - [ ] **Step 3: 实现最小认证能力** 为 `Auth` 增加默认关闭的选项: ```java boolean session() default false; ``` 修正 `JwtUtil.verifyToken`,将已验证的 `id` 等 Claim 写入返回结果,避免认证切面再次验签: ```java result.put(key, value); ``` `AuthAspect` 使用 `verifyToken` 完成一次认证;`session = true` 时检查 JTI Redis Key,并在放行前写入可信请求属性: ```java Map claims = JwtUtil.verifyToken(rawToken); if (!Boolean.TRUE.equals(claims.get("valid"))) { return AjaxResult.error(401, "token已过期,请重新登录!"); } Long userId = parseUserId(claims.get("id")); Object jtiValue = claims.get("jti"); String jti = jtiValue instanceof String ? (String) jtiValue : null; if (auth.session() && (userId == null || jti == null || jti.isBlank() || !redisCache.hasKey(jti))) { throw new ServiceException(MessageUtils.message("merchant.session.invalid")); } request.setAttribute(AuthContext.USER_ID_ATTRIBUTE, userId); request.setAttribute(AuthContext.JTI_ATTRIBUTE, jti); ``` `AuthContext` 只读取认证切面写入的属性,不解析或重新校验 Token: ```java public static Long requireUserId() { Object value = ServletUtils.getRequest().getAttribute(USER_ID_ATTRIBUTE); if (value instanceof Long userId) { return userId; } throw new ServiceException(MessageUtils.message("merchant.session.invalid")); } ``` - [ ] **Step 4: 运行测试确认 GREEN** Run: `mvn -pl ruoyi-system -Dtest=AuthAspectTest test` Expected: PASS,且无 Redis 访问发生在默认 `@Auth` 场景。 --- ### Task 2:会话服务职责收缩与账号管理入口迁移 **Files:** - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantTokenSessionService.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountControllerTest.java` **Interfaces:** - Consumes: `@Auth(session = true)`、`AuthContext.requireUserId()`、`AuthContext.requireJti()`。 - Produces: `MerchantTokenSessionService.logoutCurrent(String jti)` 只删除已经过认证的当前 JTI。 - Preserves: `revokeAll(Long)`、`isOnline(Long)`、`hasAppSession(Long)`。 - [ ] **Step 1: 更新失败测试** 账号管理 Controller 测试不再模拟 `tokenSessionService.requireUserId(token)`,改为静态模拟认证上下文;退出测试验证删除可信 JTI 而不是重新解析 Token: ```java try (MockedStatic auth = mockStatic(AuthContext.class)) { auth.when(AuthContext::requireUserId).thenReturn(936L); controller.list("merchant-token"); verify(applicationService).listForOwner(936L); } auth.when(AuthContext::requireJti).thenReturn("qtw_tokens:sh:app:936:test"); verify(merchantTokenSessionService).logoutCurrent("qtw_tokens:sh:app:936:test"); ``` - [ ] **Step 2: 运行定向测试确认 RED** Run: ```powershell mvn -pl ruoyi-admin -am -Dtest=InfoUserControllerTest,MerchantSubaccountControllerTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL,因为 Controller 仍调用 `requireUserId(token)`,退出仍把原 Token 传给会话服务。 - [ ] **Step 3: 迁移账号入口** 将商家退出和五个分管账号接口改为 `@Auth(session = true)`;Controller 使用 `AuthContext`: ```java @Auth(session = true) public AjaxResult list(@RequestHeader String token) { return success(applicationService.listForOwner(AuthContext.requireUserId())); } @Auth(session = true) public AjaxResult merchantLogout(@RequestHeader String token) { merchantTokenSessionService.logoutCurrent(AuthContext.requireJti()); return success(); } ``` 删除 `MerchantTokenSessionService.requireUserId`,`logoutCurrent` 只删除非空 JTI;撤销和在线查询方法不变。 - [ ] **Step 4: 运行定向测试确认 GREEN** Run: `mvn -pl ruoyi-admin -am -Dtest=InfoUserControllerTest,MerchantSubaccountControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: PASS。 --- ### Task 3:店铺、商品和订单 Controller 迁移 **Files:** - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java` - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosFoodControllerAccessTest.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosFoodControllerShelfFilterTest.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreControllerTest.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java` **Interfaces:** - Consumes: `@Auth(session = true)` 与 `AuthContext.requireUserId()`。 - Preserves: `MerchantStoreAccessService` 的门店、商品和订单归属校验。 - Removes: 六个业务 Controller 对 `MerchantTokenSessionService` 的注入和调用。 - [ ] **Step 1: 更新 Controller 定向测试形成 RED** 在直接调用 Controller 的测试中静态模拟 `AuthContext.requireUserId()`,删除 `MerchantTokenSessionService.requireUserId` 桩;保留并断言原有归属校验: ```java authContext = mockStatic(AuthContext.class); authContext.when(AuthContext::requireUserId).thenReturn(936L); controller.getmystorelist("merchant-token"); verify(merchantStoreAccessService).getAccessibleStoreIds(936L); ``` - [ ] **Step 2: 运行店铺与订单测试确认 RED** Run: ```powershell mvn -pl ruoyi-admin -am -Dtest=PosStoreControllerTest,PosFoodControllerAccessTest,PosFoodControllerShelfFilterTest,PosOrderShOprateControllerTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL,因为生产 Controller 仍依赖 `MerchantTokenSessionService.requireUserId(token)`。 - [ ] **Step 3: 迁移六个业务 Controller** 所有当前依赖 `requireUserId(token)` 的公开入口改为 `@Auth(session = true)`,方法体改取可信上下文: ```java Long loginUserId = AuthContext.requireUserId(); merchantStoreAccessService.requireStoreAccess(loginUserId, mdId); ``` 私有辅助方法不再接收 Token 来认证,统一读取或接收已经取得的 `loginUserId`;删除六个 Controller 中不再使用的 `MerchantTokenSessionService` import 和字段。不得删除 `requireStoreAccess`、`requireFoodAccess`、`requireOrderAccess` 或订单所有权检查。 - [ ] **Step 4: 静态确认没有 Controller 二次认证** Run: ```powershell rg -n "MerchantTokenSessionService.*requireUserId|merchantTokenSessionService\.requireUserId|tokenSessionService\.requireUserId" ruoyi-admin/src/main/java ``` Expected: 无匹配。 - [ ] **Step 5: 运行店铺与订单测试确认 GREEN** Run: `mvn -pl ruoyi-admin -am -Dtest=PosStoreControllerTest,PosFoodControllerAccessTest,PosFoodControllerShelfFilterTest,PosOrderShOprateControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: PASS。 --- ### Task 4:统一验证、规格回填、提交和推送 **Files:** - Modify: `specs/022-merchant-store-subaccounts/tasks.md` - Verify: Task 1~3 的全部源码与测试文件。 **Interfaces:** - Verifies: FR-036、FR-037、SC-010。 - Produces: 一个不混入既有脏文件的中文实现提交并推送当前分支。 - [ ] **Step 1: 运行全部定向测试** Run: ```powershell $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7' $env:PATH="$env:JAVA_HOME\bin;$env:PATH" mvn -pl ruoyi-admin -am -Dtest=AuthAspectTest,InfoUserControllerTest,MerchantSubaccountControllerTest,PosStoreControllerTest,PosFoodControllerAccessTest,PosFoodControllerShelfFilterTest,PosOrderShOprateControllerTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: BUILD SUCCESS,所有指定测试零失败。 - [ ] **Step 2: 构建后端模块** Run: `mvn -pl ruoyi-admin -am -DskipTests package` Expected: BUILD SUCCESS。 - [ ] **Step 3: 回填任务状态并检查差异** 将 `tasks.md` 的 T012 六项全部勾选;运行: ```powershell git diff --check rg -n "MerchantTokenSessionService.*requireUserId|merchantTokenSessionService\.requireUserId|tokenSessionService\.requireUserId" ruoyi-admin/src/main/java git status --short ``` Expected: `git diff --check` 无错误,`rg` 无匹配,状态清单只包含本任务文件和既有未暂存文件。 - [ ] **Step 4: 精确暂存并提交** 仅暂存 Task 1~3 的源码、测试及 `specs/022-merchant-store-subaccounts` 本任务文件,核对 `git diff --cached --name-only` 后提交: ```powershell git commit -m "统一商家会话认证入口" -m "将 JWT 与 Redis 会话校验集中到认证切面,业务控制器仅使用可信身份执行门店和订单归属校验。" ``` - [ ] **Step 5: 推送当前分支** Run: `git push` Expected: 当前分支推送成功,记录提交 SHA 和远端分支。