t012-auth-session-implementation-plan.md 13 KB

商家会话统一认证实施计划

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-systemruoyi-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_HOMEPATH
  • 仅暂存本任务源码、测试和规格,不混入 .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(): LongAuthContext.requireJti(): String
  • Produces: 请求属性 AuthContext.USER_ID_ATTRIBUTEAuthContext.JTI_ATTRIBUTE

  • [ ] Step 1: 编写失败测试

AuthAspectTest 定义分别标注 @Auth@Auth(session = true) 的样例方法,并验证三项行为:默认认证不访问 Redis;启用会话认证且 Redis 含 JTI 时调用 joinPoint.proceed();Redis 缺少 JTI 时抛出 ServiceException 且不进入目标方法。

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:

$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 增加默认关闭的选项:

boolean session() default false;

修正 JwtUtil.verifyToken,将已验证的 id 等 Claim 写入返回结果,避免认证切面再次验签:

result.put(key, value);

AuthAspect 使用 verifyToken 完成一次认证;session = true 时检查 JTI Redis Key,并在放行前写入可信请求属性:

Map<String, Object> 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:

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:

try (MockedStatic<AuthContext> 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:

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

@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.requireUserIdlogoutCurrent 只删除非空 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 桩;保留并断言原有归属校验:

authContext = mockStatic(AuthContext.class);
authContext.when(AuthContext::requireUserId).thenReturn(936L);

controller.getmystorelist("merchant-token");
verify(merchantStoreAccessService).getAccessibleStoreIds(936L);
  • Step 2: 运行店铺与订单测试确认 RED

Run:

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),方法体改取可信上下文:

Long loginUserId = AuthContext.requireUserId();
merchantStoreAccessService.requireStoreAccess(loginUserId, mdId);

私有辅助方法不再接收 Token 来认证,统一读取或接收已经取得的 loginUserId;删除六个 Controller 中不再使用的 MerchantTokenSessionService import 和字段。不得删除 requireStoreAccessrequireFoodAccessrequireOrderAccess 或订单所有权检查。

  • Step 4: 静态确认没有 Controller 二次认证

Run:

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:

$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 六项全部勾选;运行:

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 后提交:

git commit -m "统一商家会话认证入口" -m "将 JWT 与 Redis 会话校验集中到认证切面,业务控制器仅使用可信身份执行门店和订单归属校验。"
  • Step 5: 推送当前分支

Run: git push

Expected: 当前分支推送成功,记录提交 SHA 和远端分支。