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

> **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<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：

```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<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:

```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 和远端分支。
