Sfoglia il codice sorgente

docs: 地址联系人回填真实姓名实现计划(含文档索引同步)

Sisyphus Agent 3 giorni fa
parent
commit
a949ac24f6

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -384,6 +384,7 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
+| `2026-10-10-address-contact-backfill-realname.md` | 🟡 待执行(4 任务:RealNameBackfillService 组件 TDD + UserAddressService 接线 + ConsigneeService 接线 + 整体验证;12 单测/纯 Mockito) | 地址联系人回填真实姓名实现计划 |
 | `2026-10-04-plan-review-workbench.md` | 🟢 已实施(15 任务全部完成:0 修 test-compile 基线、1 GuideFamilyAccessGuard、2 confirmBind 重绑复位、3 七个端点接入绑定校验、4 rejectPlan 状态守卫 + getPlanForFamily IDOR 反查、5 approveAndPublish 返回 taskGenerated(新增 PlanApproveResultDTO)、6 GuidePlanReviewController 方案审核 4 端点(含 planId IDOR 反查,6/6 绿)、7 GuideFamilyQueryService/Controller 新增 my-families + members 端点(授权源与 isBound 同源,4/4 绿)、8 后端整体验证(compile/test-compile EXIT 0 + 五个新增单测类共 32 个用例全绿 + 全仓 1688 条映射零重复路由)、9 utils/api.js 新增审核工作台 8 个 API 封装(各导出唯一,语法校验须用 .mjs 副本)、10 review-workbench.vue 审核台首页、11 review-family.vue 单家庭双 Tab(Task 用 deadline 非 dueDate;HealthPlan 无 title)、12 plan-detail.vue 方案详情(含 taskGenerated=false 部分成功提示;updatePlanContent 用 `if (planJson != null)` 守卫,3 参调用不会清空 plan_json)、13 pages.json 分包注册 3 页面(裸 path)+ families.vue 加入口、14 文档同步 API_REFERENCE 4.47;计划本身核实并修正 26 处初版事实错误) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库。⚠️ `pom.xml:28` 有 `<skipTests>true</skipTests>`,跑单测必须显式 `-DskipTests=false`,否则只输出 `Tests are skipped` 的假通过。⚠️ 路由查重必须按「类级 @RequestMapping 前缀 + 方法级字面量」归一化比较,只比方法字面量会产生假阳性) |
 | `2026-09-10-report-quick-analyze.md` | 🟡 待执行(5 任务:Redis 依赖/配置 + AIChatController 抽 buildChatInputs 新增 report/analyze 端点 + api.js 封装 + report-detail 浮层 + 验证) | 报告快速分析实现计划 |
 | `2026-09-10-key-person-expansion-octopus.md` | 🟡 待执行(12 任务:迁移305-307 三表 + 3实体/Mapper/Service/Controller + 新 OctopusDiagram 三层画布 + 成效/关键人录入页 + action-detail 集成 + API 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |

+ 528 - 0
docs/superpowers/plans/2026-10-10-address-contact-backfill-realname.md

@@ -0,0 +1,528 @@
+# 地址联系人回填真实姓名 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 用户保存商城收货地址或收货人时,若其 `users.real_name` 为空,则用联系人姓名自动回填真实姓名(幂等,仅首次生效)。
+
+**架构:** 新增独立组件 `RealNameBackfillService`(依赖 `UserMapper`,提供 `backfill(userId, contactName)`);在 `UserAddressService.create/update` 与 `ConsigneeService.save`(新增/编辑两支)保存成功后调用。回填失败 try-catch 静默,不阻塞地址保存主流程(风格参照 `ConsigneeService.syncUserRegion()`)。零 schema 变更、零前端改动、无新接口。
+
+**技术栈:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / JUnit 5 + Mockito(纯单测,无 Spring 上下文、无 MySQL 连接)
+
+**规格:** `docs/superpowers/specs/2026-10-10-address-contact-backfill-realname-design.md`
+
+**⚠️ 工作区约束:** 当前工作区有与本任务无关的未提交改动(`ProductRecommendationController.java`、`ProductRecommendationService.java`、若干根目录临时文件等)。所有 `git add` 必须精确指定本任务文件,**禁止 `git add -A` / `git add .`**。
+
+**⚠️ 测试运行约束:** `cfc-backend/pom.xml:28` 有 `<skipTests>true</skipTests>`,跑单测必须显式 `-DskipTests=false`,否则输出 `Tests are skipped` 是假通过。
+
+---
+
+## 文件结构
+
+| 文件 | 动作 | 职责 |
+|---|---|---|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java` | 创建 | 真实姓名回填守卫:realName 空白才写入,幂等,失败静默 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/UserAddressService.java` | 修改 | `create`/`update` 保存成功后调用 backfill |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java` | 修改 | `save` 新增/编辑两支保存成功后调用 backfill |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/RealNameBackfillServiceTest.java` | 创建 | 回填守卫逻辑单测(8 用例) |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java` | 创建 | UserAddressService 接线单测(2 用例) |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java` | 创建 | ConsigneeService 接线单测(2 用例) |
+
+**关键顺序决策(任务 3):** `ConsigneeService.save` 中 backfill 必须放在 `syncUserRegion(...)` **之后**。`syncUserRegion` 在 realName 为空时回退用 nickname 匹配联系人姓名;若先回填 realName 会改变其匹配结果,破坏既有地区同步行为。放后面可完全保持既有行为不变。
+
+任务分解:
+- 任务 1:`RealNameBackfillService`(TDD:测试→失败→实现→通过→commit)
+- 任务 2:`UserAddressService` 接线(TDD)
+- 任务 3:`ConsigneeService` 接线(TDD)
+- 任务 4:整体验证(compile + 三测试类 12 用例全绿)
+
+---
+
+### 任务 1:RealNameBackfillService 回填组件
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/RealNameBackfillServiceTest.java`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/RealNameBackfillServiceTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class RealNameBackfillServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private RealNameBackfillService realNameBackfillService;
+
+    @Test
+    void backfill_真实姓名为null时回填联系人姓名() {
+        User user = new User();
+        user.setRealName(null);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "张三");
+
+        assertEquals("张三", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_真实姓名为空白时回填联系人姓名() {
+        User user = new User();
+        user.setRealName("   ");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "李四");
+
+        assertEquals("李四", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_联系人姓名去除首尾空白后回填() {
+        User user = new User();
+        user.setRealName(null);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "  王五  ");
+
+        assertEquals("王五", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_真实姓名已有值时不覆盖() {
+        User user = new User();
+        user.setRealName("原有姓名");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "张三");
+
+        assertEquals("原有姓名", user.getRealName());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    @Test
+    void backfill_联系人姓名为null时跳过() {
+        realNameBackfillService.backfill(1L, null);
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_联系人姓名为空白时跳过() {
+        realNameBackfillService.backfill(1L, "   ");
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_userId为null时跳过() {
+        realNameBackfillService.backfill(null, "张三");
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_用户不存在时跳过() {
+        when(userMapper.selectById(99L)).thenReturn(null);
+
+        realNameBackfillService.backfill(99L, "张三");
+
+        verify(userMapper, never()).updateById(any());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行(PowerShell,仓库根目录):
+
+```powershell
+cd cfc-backend
+mvn test -DskipTests=false -Dtest=RealNameBackfillServiceTest
+```
+
+预期:COMPILATION ERROR — 找不到符号 `RealNameBackfillService`(类不存在,这就是本任务的红灯)。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+/**
+ * 真实姓名回填:用户第一次输入地址/收货人时,若 users.real_name 尚未填写,
+ * 用联系人姓名回填(规格:docs/superpowers/specs/2026-10-10-address-contact-backfill-realname-design.md)。
+ * 幂等:回填一次后 realName 非空,后续保存不再触发。
+ * 风格参照 ConsigneeService.syncUserRegion():失败静默,不阻塞主流程。
+ */
+@Service
+public class RealNameBackfillService {
+
+    private static final Logger log = LoggerFactory.getLogger(RealNameBackfillService.class);
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 若 userId 对应用户的 realName 为空白,则用 contactName(去首尾空白后)回填。
+     * contactName 空白、userId 为空、用户不存在时直接跳过。
+     */
+    public void backfill(Long userId, String contactName) {
+        try {
+            if (userId == null) return;
+            if (contactName == null || contactName.trim().isEmpty()) return;
+            User user = userMapper.selectById(userId);
+            if (user == null) return;
+            String realName = user.getRealName();
+            if (realName != null && !realName.trim().isEmpty()) return;
+            user.setRealName(contactName.trim());
+            userMapper.updateById(user);
+        } catch (Exception e) {
+            log.warn("[RealNameBackfill] 回填失败 userId={} : {}", userId, e.getMessage());
+        }
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行(同步骤 2):
+
+```powershell
+mvn test -DskipTests=false -Dtest=RealNameBackfillServiceTest
+```
+
+预期:`Tests run: 8, Failures: 0, Errors: 0, Skipped: 0`,BUILD SUCCESS。
+
+- [ ] **步骤 5:Commit**
+
+```powershell
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java cfc-backend/src/test/java/com/etotem/cfc/service/RealNameBackfillServiceTest.java
+git commit -m "feat(realname): 新增 RealNameBackfillService 真实姓名回填组件"
+```
+
+---
+
+### 任务 2:UserAddressService 接线
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/UserAddressService.java`(字段区 17-18 行、`create` 37-49 行、`update` 51-63 行)
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.UserAddress;
+import com.etotem.cfc.mapper.UserAddressMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class UserAddressServiceBackfillTest {
+
+    @Mock
+    private UserAddressMapper userAddressMapper;
+
+    @Mock
+    private RealNameBackfillService realNameBackfillService;
+
+    @InjectMocks
+    private UserAddressService userAddressService;
+
+    @Test
+    void create_保存成功后以收货人姓名回填真实姓名() {
+        UserAddress address = new UserAddress();
+        address.setReceiverName("张三");
+        address.setIsDefault(0);
+
+        userAddressService.create(address, 1L);
+
+        verify(userAddressMapper).insert(address);
+        verify(realNameBackfillService).backfill(1L, "张三");
+    }
+
+    @Test
+    void update_保存成功后以收货人姓名回填真实姓名() {
+        UserAddress existing = new UserAddress();
+        existing.setId(10L);
+        existing.setUserId(1L);
+        when(userAddressMapper.selectOne(any())).thenReturn(existing);
+
+        UserAddress address = new UserAddress();
+        address.setId(10L);
+        address.setReceiverName("李四");
+        address.setIsDefault(0);
+
+        userAddressService.update(address, 1L);
+
+        verify(userAddressMapper).updateById(address);
+        verify(realNameBackfillService).backfill(1L, "李四");
+    }
+}
+```
+
+说明:`update` 走到 `updateById` 前会经 `get()` → `SortUtil.applySort`(`SortContext` 无值时为 no-op,单测安全)。
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```powershell
+mvn test -DskipTests=false -Dtest=UserAddressServiceBackfillTest
+```
+
+预期:`Tests run: 2, Failures: 2, Errors: 0` — 两个用例均因 `verify` 失败:`Wanted but not invoked: realNameBackfillService.backfill(...)`(此时 `UserAddressService` 还没有回填调用;Mockito 未注入不存在的字段,`verify` 的是 mock 对象本身,断言"未发生"。Mockito 的 `WantedButNotInvoked` 是 `AssertionError` 子类,故计入 Failures)。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+修改 `UserAddressService.java` 三处:
+
+字段区(18 行后)新增:
+
+```java
+    @Resource
+    private RealNameBackfillService realNameBackfillService;
+```
+
+`create` 方法(48 行 `userAddressMapper.insert(address);` 之后)追加一行:
+
+```java
+        userAddressMapper.insert(address);
+        realNameBackfillService.backfill(userId, address.getReceiverName());
+```
+
+`update` 方法(62 行 `userAddressMapper.updateById(address);` 之后)追加一行:
+
+```java
+        userAddressMapper.updateById(address);
+        realNameBackfillService.backfill(userId, address.getReceiverName());
+```
+
+`RealNameBackfillService` 同包,无需新增 import。
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```powershell
+mvn test -DskipTests=false -Dtest=UserAddressServiceBackfillTest
+```
+
+预期:`Tests run: 2, Failures: 0, Errors: 0`,BUILD SUCCESS。
+
+- [ ] **步骤 5:Commit**
+
+```powershell
+git add cfc-backend/src/main/java/com/etotem/cfc/service/UserAddressService.java cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java
+git commit -m "feat(realname): 商城地址保存后回填用户真实姓名"
+```
+
+---
+
+### 任务 3:ConsigneeService 接线
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java`(字段区 18-22 行、`save` 41-76 行)
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.mapper.ConsigneeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ConsigneeServiceBackfillTest {
+
+    @Mock
+    private ConsigneeMapper consigneeMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private RealNameBackfillService realNameBackfillService;
+
+    @InjectMocks
+    private ConsigneeService consigneeService;
+
+    @Test
+    void save_新增分支保存成功后以联系人姓名回填真实姓名() {
+        Consignee consignee = new Consignee();
+        consignee.setUserId(1L);
+        consignee.setName("张三");
+        consignee.setIsDefault(0);
+
+        consigneeService.save(consignee);
+
+        verify(consigneeMapper).insert(consignee);
+        verify(realNameBackfillService).backfill(1L, "张三");
+    }
+
+    @Test
+    void save_编辑分支保存成功后以联系人姓名回填真实姓名() {
+        Consignee existing = new Consignee();
+        existing.setId(10L);
+        existing.setUserId(1L);
+        when(consigneeMapper.selectById(10L)).thenReturn(existing);
+
+        Consignee consignee = new Consignee();
+        consignee.setId(10L);
+        consignee.setName("李四");
+        consignee.setIsDefault(0);
+
+        consigneeService.save(consignee);
+
+        verify(consigneeMapper).updateById(consignee);
+        verify(realNameBackfillService).backfill(1L, "李四");
+    }
+}
+```
+
+说明:`save` 内部 `syncUserRegion` 会调 `userMapper.selectById`(mock 默认返回 null → 其内部 early-return),`@Mock UserMapper` 仅为满足字段注入。
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```powershell
+mvn test -DskipTests=false -Dtest=ConsigneeServiceBackfillTest
+```
+
+预期:`Tests run: 2, Failures: 2, Errors: 0` — `Wanted but not invoked: realNameBackfillService.backfill(...)`(`WantedButNotInvoked` 是 `AssertionError` 子类,计入 Failures)。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+修改 `ConsigneeService.java` 三处:
+
+字段区(22 行后)新增:
+
+```java
+    @Resource
+    private RealNameBackfillService realNameBackfillService;
+```
+
+`save` 编辑分支(61 行 `syncUserRegion(existing.getUserId(), consignee);` 之后、`return` 之前)追加:
+
+```java
+            syncUserRegion(existing.getUserId(), consignee);
+            // 回填写在 syncUserRegion 之后:保持既有同步逻辑看到回填前的 realName,行为不变
+            realNameBackfillService.backfill(existing.getUserId(), consignee.getName());
+            return Result.success(consignee.getId());
+```
+
+`save` 新增分支(73 行 `syncUserRegion(consignee.getUserId(), consignee);` 之后、`return` 之前)追加:
+
+```java
+            syncUserRegion(consignee.getUserId(), consignee);
+            realNameBackfillService.backfill(consignee.getUserId(), consignee.getName());
+            return Result.success(consignee.getId());
+```
+
+`RealNameBackfillService` 同包,无需新增 import。
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```powershell
+mvn test -DskipTests=false -Dtest=ConsigneeServiceBackfillTest
+```
+
+预期:`Tests run: 2, Failures: 0, Errors: 0`,BUILD SUCCESS。
+
+- [ ] **步骤 5:Commit**
+
+```powershell
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java
+git commit -m "feat(realname): 收货人保存后回填用户真实姓名"
+```
+
+---
+
+### 任务 4:整体验证
+
+**文件:** 无新改动,仅验证。
+
+- [ ] **步骤 1:全量编译**
+
+```powershell
+cd cfc-backend
+mvn clean compile
+```
+
+预期:BUILD SUCCESS,无编译错误。
+
+- [ ] **步骤 2:运行本任务全部 12 个单测**
+
+```powershell
+mvn test -DskipTests=false -Dtest="RealNameBackfillServiceTest,UserAddressServiceBackfillTest,ConsigneeServiceBackfillTest"
+```
+
+预期:`Tests run: 12, Failures: 0, Errors: 0, Skipped: 0`,BUILD SUCCESS。
+
+- [ ] **步骤 3:核对提交记录**
+
+```powershell
+git log --oneline -4
+```
+
+预期:看到 3 个 feat 提交 + 之前的 docs 提交,且 `git status` 中本任务涉及的 6 个文件全部干净(无关的既有改动仍在,属正常)。
+
+---
+
+## 测试矩阵 ↔ 用例映射(规格覆盖度自证)
+
+| 规格测试要求 | 承载用例 |
+|---|---|
+| realName 为空 + 创建地址 → 回填 | `UserAddressServiceBackfillTest.create_保存成功后以收货人姓名回填真实姓名` |
+| realName 为空 + 编辑地址 → 回填 | `UserAddressServiceBackfillTest.update_保存成功后以收货人姓名回填真实姓名` |
+| realName 已有 → 不覆盖 | `RealNameBackfillServiceTest.backfill_真实姓名已有值时不覆盖` |
+| 联系人空白 → 跳过 | `RealNameBackfillServiceTest.backfill_联系人姓名为null时跳过` / `backfill_联系人姓名为空白时跳过` |
+| ConsigneeService 新增、编辑两支 | `ConsigneeServiceBackfillTest` 两个用例 |
+| 空白 realName(空串/纯空格)也回填 | `RealNameBackfillServiceTest.backfill_真实姓名为空白时回填联系人姓名` |
+| trim 后回填 | `RealNameBackfillServiceTest.backfill_联系人姓名去除首尾空白后回填` |
+| 防御路径(userId null / 用户不存在) | `backfill_userId为null时跳过` / `backfill_用户不存在时跳过` |
+
+守卫逻辑集中在 `RealNameBackfillServiceTest`(8 用例),两个接线测试只验证"保存成功后以正确参数调用 backfill"——合起来完整覆盖规格的测试矩阵,避免在接线层重复 mock 同一守卫逻辑。