Просмотр исходного кода

test(S-cfc-208): admin.content.security 契约测试 - 实测实现已存在全绿,交编排侧转测试登记

能力键 admin.content.security(SC-A-13 内容安全审核 + SC-A-18 内容安全审核队列):
- 新增 AdminContentSecurityContractTest(15 例,纯 Mockito/反射,无 Spring/DB/外部服务)
- 覆盖:audit/submit-review/withdraw/re-draft/upload image|video/comments/list 端点家族、
  敏感词命中拦截(checkTextSafe 标题/正文命中拒绝发布 + 空白 fail-open)、
  ReviewCenter summary 13 类待审聚合(含空队列全 0)、ContentFilter by-tags 空标签边界

实测结论:origin/cfclub 该契约端点与行为已全部落地(契约文档 admin-ops-contract
§1.13/§1.18 标记 ✅ 端点存在;B-142-1 /api/admin/ 登录门已收口)。
TDD-RED 任务测试意外全绿(15 通过/0 失败)——按任务执行要求第 2 条汇报注明,
交编排侧将 sc-key-map SC-A-13/A-18 由 RED/未跑通转测试登记为已实现+契约测试锁定;
未改动任何 src/main 实现。
Sisyphus Agent 3 часов назад
Родитель
Сommit
a18b731c3b

+ 505 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminContentSecurityContractTest.java

@@ -0,0 +1,505 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.ContentFilterController;
+import com.etotem.cfc.controller.admin.AdminArticleController;
+import com.etotem.cfc.controller.admin.ReviewCenterController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ButlerProfileMapper;
+import com.etotem.cfc.mapper.PendingRefundMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ServiceRoleApplicationMapper;
+import com.etotem.cfc.mapper.SupplyHierarchyChangeRequestMapper;
+import com.etotem.cfc.mapper.TaskTemplatePackageMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:内容安全审核 + 审核队列(能力键 {@code admin.content.security})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} —— SC-A-13(内容安全审核:UGC 审核 →
+ *       通过/下架,边界:敏感词命中拦截)+ SC-A-18(内容安全审核队列:队列 → 通过/下架,
+ *       边界:敏感词命中拦截)</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(管理员接口)+ §4.60.13
+ *       (SC-A-13/A-18 内容安全审核:{@code ReviewCenterController /api/admin/review-center/summary}
+ *       13 类待审聚合 + 业务 audit 端点)+ §4.65.2(文章发布-审核闭环,端点齐全 ✅)</li>
+ *   <li>编排:{@code docs/tdd/orchestration/sc-key-map.md} —— SC-A-13/A-18 → {@code admin.content.security}
+ *       → RED(S-cfc-208)</li>
+ * </ul>
+ *
+ * <p><b>载体(src/main 现状核实,origin/cfclub @ f941b9212)</b></p>
+ * <ul>
+ *   <li>{@code AdminArticleController /api/admin/articles}:{@code audit} / {@code submit-review} /
+ *       {@code withdraw} / {@code re-draft} / {@code upload/image}(微信 {@code checkImage} 检测)/
+ *       {@code upload/video} / {@code comments/list}</li>
+ *   <li>{@code ArticleService}:{@code submitForReview}(draft→pending+auditStatus=pending)/
+ *       {@code auditArticle}(approved→published / rejected→rejected+原因)/
+ *       {@code withdraw}(published→withdrawn,即"下架")/{@code reDraft}(rejected/withdrawn→draft 清审计字段)</li>
+ *   <li>{@code ReviewCenterController /api/admin/review-center/summary}:13 类待审聚合计数</li>
+ *   <li>{@code ContentFilterController /api/content/by-tags}:按 resultTags + 问题域 code 过滤
+ *       文章/知识库/饮食</li>
+ *   <li>权限流:{@code JwtInterceptor.LOGIN_REQUIRED_PATHS} 已收口 {@code "/api/admin/"}(B-142-1 已处置),
+ *       {@code audit} 已有 {@code role} 角色守卫(非 admin → "无权限")——权限流由
+ *       {@code AdminArticleAuditContractTest} 锁定,本类不重复。</li>
+ * </ul>
+ *
+ * <p><b>RED 任务实证结论</b>:本键(S-cfc-208)为 TDD-RED 契约任务,但契约端点与行为在
+ * origin/cfclub 已全部落地(契约文档 admin-ops-contract §1.13/§1.18 亦标记「✅ 端点存在」)。
+ * 按任务执行要求第 2 条(实测发现实现已存在 → 测试意外全绿 → 汇报注明并交编排侧转测试登记,
+ * 不私改实现),本测试以「契约绿锁定」形式覆盖 SC-A-13/A-18 尚未被既有测试覆盖的行为面
+ * (submit-review/withdraw/re-draft 状态机、敏感词命中拦截、review-center 13 类队列聚合、
+ * content by-tags 空标签边界),作为该能力的契约回归基线。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无真实微信 /
+ * 无外部服务。</p>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(绿锁定):审核端点家族存在;提审/撤回/重编状态机正确流转;敏感词命中拦截;
+ *       队列 13 类聚合;内容过滤空标签边界</li>
+ *   <li>边界流(绿锁定):提审非草稿 / 撤回非发布中 / 重编非法状态 → 拒绝且不写库;空/空白文本
+ *       fail-open 不触达微信检测</li>
+ *   <li>权限流(已由既有契约锁定):/api/admin/ 整族登录门(B-142-1 已修)+ audit 角色守卫</li>
+ * </ul>
+ */
+class AdminContentSecurityContractTest {
+
+    private static final Long ARTICLE_ID = 200L;
+    private static final Long ADMIN_ID = 88L;
+
+    // ==================================================================
+    // 正常流:SC-A-13 审核端点家族存在(@PostMapping,结构契约)
+    // ==================================================================
+
+    /** 契约端点 → 应存在的映射:{endpoint 后缀 → 期望方法名} */
+    private static final Map<String, String> ADMIN_ARTICLE_AUDIT_ENDPOINTS = new HashMap<>();
+
+    static {
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/list", "list");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/audit", "audit");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/submit-review", "submitReview");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/withdraw", "withdraw");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/re-draft", "reDraft");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/image", "uploadImage");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/upload/video", "uploadVideo");
+        ADMIN_ARTICLE_AUDIT_ENDPOINTS.put("/comments/list", "commentList");
+    }
+
+    @Test
+    @DisplayName("正常流-1:AdminArticleController 应提供 SC-A-13 审核端点家族(audit/submit-review/withdraw/re-draft/upload image|video/comments/list)")
+    void adminArticleController_auditFamilyEndpointsExist() {
+        Method[] methods = AdminArticleController.class.getDeclaredMethods();
+        Map<String, String> missing = new HashMap<>();
+        for (Map.Entry<String, String> e : ADMIN_ARTICLE_AUDIT_ENDPOINTS.entrySet()) {
+            Method m = findPostMapping(methods, e.getKey());
+            if (m == null || !m.getName().equals(e.getValue())) {
+                missing.put(e.getKey(), e.getValue());
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[admin.content.security] AdminArticleController 缺契约端点:"
+                        + missing + "(SC-A-13 内容安全审核:audit/submit-review/withdraw/re-draft/"
+                        + "upload image|video/comments/list,API_REFERENCE §4.60.13)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:ReviewCenterController 应提供 /api/admin/review-center/summary(SC-A-18 审核队列)")
+    void reviewCenterController_summaryEndpointExists() {
+        Method m = findPostMapping(ReviewCenterController.class.getDeclaredMethods(), "/summary");
+        assertNotNull(m,
+                "RED[admin.content.security] ReviewCenterController 缺 @PostMapping /summary"
+                        + "(SC-A-18 内容安全审核队列:13 类待审聚合,API_REFERENCE §4.60.13)。");
+        assertEquals("summary", m.getName(), "summary 端点应映射到 summary 方法");
+    }
+
+    @Test
+    @DisplayName("正常流-3:ContentFilterController 应提供 /api/content/by-tags(内容安全过滤)")
+    void contentFilterController_byTagsEndpointExists() {
+        Method m = findPostMapping(ContentFilterController.class.getDeclaredMethods(), "/by-tags");
+        assertNotNull(m,
+                "RED[admin.content.security] ContentFilterController 缺 @PostMapping /by-tags"
+                        + "(内容安全/内容过滤载体,API_REFERENCE §4.12)。");
+        assertEquals("byTags", m.getName(), "by-tags 端点应映射到 byTags 方法");
+    }
+
+    // ==================================================================
+    // 正常流/边界流:SC-A-13 边界「敏感词命中拦截」+ fail-open 策略
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-4:标题命中敏感词 → 拦截发布,不触达 articleService")
+    void create_标题命中敏感词_拦截发布() throws Exception {
+        AdminArticleController controller = articleControllerWith(false);
+        ArticleService articleService = (ArticleService) getField(controller, "articleService");
+
+        Result<Map<String, Object>> r = callCreate(controller, "标题含敏感词", "<p>正文</p>", "admin");
+
+        assertEquals("文章标题包含敏感信息,发布失败", r.getMessage(),
+                "SC-A-13 边界「敏感词命中拦截」:微信 checkText 命中 → 拦截,不得发布");
+        verify(articleService, never()).create(any(), any());
+    }
+
+    @Test
+    @DisplayName("正常流-5:内容命中敏感词 → 拦截发布(正文敏感,标题先放行)")
+    void create_正文命中敏感词_拦截发布() throws Exception {
+        AdminArticleController controller = articleControllerWith(true, false);
+        ArticleService articleService = (ArticleService) getField(controller, "articleService");
+
+        Result<Map<String, Object>> r = callCreate(controller, "正常标题", "<p>正文含敏感词</p>", "admin");
+
+        assertEquals("文章内容包含敏感信息,发布失败", r.getMessage(),
+                "SC-A-13 边界「敏感词命中拦截」:标题通过检测、正文命中 → 拦截,不得发布");
+        verify(articleService, never()).create(any(), any());
+    }
+
+    @Test
+    @DisplayName("边界流-1:空白/空文本不做敏感检测,直接放行(fail-open 不误伤正常输入)")
+    void checkTextSafe_空白文本_直接放行() throws Exception {
+        AdminArticleController controller = new AdminArticleController();
+        // 不注入 wechatService:空白文本分支不触达检测,若触达会 NPE → 测试红
+        assertTrue(invokeCheckTextSafe(controller, null),
+                "checkTextSafe(null) 应直接放行,不触达微信检测");
+        assertTrue(invokeCheckTextSafe(controller, "  \t  "),
+                "checkTextSafe(全空白) 应直接放行,不触达微信检测");
+    }
+
+    // ==================================================================
+    // 正常流/边界流:ArticleService 审计状态机(SC-A-13 提审→通过/下架 闭环)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-6:提审(submitForReview)草稿 → pending + auditStatus=pending")
+    void submitForReview_草稿_提交审核() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("draft", null);
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.submitForReview(ARTICLE_ID);
+
+        assertEquals("pending", a.getStatus(), "提审后 status 应置 pending");
+        assertEquals("pending", a.getAuditStatus(), "提审后 auditStatus 应置 pending(进入审核队列)");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("边界流-2:提审非草稿 → 抛「仅草稿状态可提交审核」,不写库")
+    void submitForReview_非草稿_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("published", "approved");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.submitForReview(ARTICLE_ID));
+
+        assertEquals("仅草稿状态可提交审核", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("正常流-7:撤回(withdraw)发布中 → withdrawn(SC-A-13「下架」)")
+    void withdraw_发布中_下架() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("published", "approved");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.withdraw(ARTICLE_ID);
+
+        assertEquals("withdrawn", a.getStatus(), "撤回后 status 应置 withdrawn(下架)");
+        verify(articleMapper).updateById(a);
+    }
+
+    @Test
+    @DisplayName("边界流-3:撤回非发布中 → 抛「仅发布中的文章可撤回」,不写库")
+    void withdraw_非发布中_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("draft", null);
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.withdraw(ARTICLE_ID));
+
+        assertEquals("仅发布中的文章可撤回", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    @Test
+    @DisplayName("正常流-8:驳回/撤回后重编(reDraft)→ 回草稿并清空审计字段(审核→驳回→重编闭环)")
+    void reDraft_驳回或撤回_重回草稿并清审计() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article rejected = article("rejected", "rejected");
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(rejected);
+        injectService(service, "articleMapper", articleMapper);
+
+        service.reDraft(ARTICLE_ID);
+
+        assertEquals("draft", rejected.getStatus(), "rejected → draft 可重新编辑");
+        assertNull(rejected.getAuditStatus(), "重编应清空 auditStatus");
+        assertNull(rejected.getAuditReason(), "重编应清空驳回原因");
+        assertNull(rejected.getAuditorId(), "重编应清空审核人");
+        assertNull(rejected.getAuditedAt(), "重编应清空审核时间");
+        verify(articleMapper).updateById(rejected);
+
+        // withdrawn → draft 同路径
+        ArticleService service2 = new ArticleService();
+        ArticleMapper mapper2 = mock(ArticleMapper.class);
+        Article withdrawn = article("withdrawn", "approved");
+        when(mapper2.selectById(ARTICLE_ID)).thenReturn(withdrawn);
+        injectService(service2, "articleMapper", mapper2);
+
+        service2.reDraft(ARTICLE_ID);
+
+        assertEquals("draft", withdrawn.getStatus(), "withdrawn → draft 可重新编辑");
+        verify(mapper2).updateById(withdrawn);
+    }
+
+    @Test
+    @DisplayName("边界流-4:重编非驳回/非撤回状态 → 抛「仅已驳回或已撤回的文章可重新编辑」,不写库")
+    void reDraft_非法状态_拒绝() throws Exception {
+        ArticleService service = new ArticleService();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        Article a = article("pending", "pending"); // 审核中不可直接重编
+        when(articleMapper.selectById(ARTICLE_ID)).thenReturn(a);
+        injectService(service, "articleMapper", articleMapper);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.reDraft(ARTICLE_ID));
+
+        assertEquals("仅已驳回或已撤回的文章可重新编辑", ex.getMessage());
+        verify(articleMapper, never()).updateById(any(Article.class));
+    }
+
+    // ==================================================================
+    // 正常流:SC-A-18 审核队列(review-center/summary 13 类待审聚合)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-9:review-center/summary 应聚合 13 类待审计数并落地各 Tab 键")
+    void reviewCenterSummary_十三类聚合键() throws Exception {
+        ReviewCenterController controller = new ReviewCenterController();
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        when(articleMapper.selectCount(any())).thenReturn(5L);
+        inject(controller, "articleMapper", articleMapper);
+        inject(controller, "productMapper", mock(ProductMapper.class));
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "activityMapper", mock(ActivityMapper.class));
+        inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
+        inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
+        inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
+        inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
+        inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
+        inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
+        inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
+
+        Result<Map<String, Integer>> r = controller.summary();
+
+        assertEquals(200, r.getCode().intValue(), "summary 应成功返回");
+        Map<String, Integer> counts = r.getData();
+        assertNotNull(counts, "summary data 不应为空");
+        Set<String> keys = new HashSet<>(counts.keySet());
+        Set<String> expected = new HashSet<>();
+        for (String k : new String[]{
+                "articlePending", "productPending", "guidePending", "vendorPending",
+                "butlerPending", "packagePending", "activityPending", "activityRegistrationPending",
+                "withdrawPending", "refundPending", "hierarchyChangePending",
+                "nutritionistPending", "knowledgePending"}) {
+            expected.add(k);
+        }
+        assertEquals(expected, keys,
+                "RED[admin.content.security] review-center/summary 应聚合 SC-A-18 契约的 13 类待审键"
+                        + "(文章/商品/规划师/服务商/管家/套餐/活动/报名/提现/退款/层级变更/营养师/知识)。");
+        assertEquals(5, counts.get("articlePending").intValue(),
+                "articlePending 应等于 articles.audit_status=pending 计数");
+    }
+
+    @Test
+    @DisplayName("正常流-10:review-center/summary 无待审数据 → 全部键存在且为 0(空队列口径)")
+    void reviewCenterSummary_空队列_13键全0() throws Exception {
+        ReviewCenterController controller = new ReviewCenterController();
+        // 不 stub 任何 mapper:Mockito 默认计数返回 0 → 空队列
+        inject(controller, "articleMapper", mock(ArticleMapper.class));
+        inject(controller, "productMapper", mock(ProductMapper.class));
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "activityMapper", mock(ActivityMapper.class));
+        inject(controller, "activityRegistrationMapper", mock(ActivityRegistrationMapper.class));
+        inject(controller, "withdrawalRequestMapper", mock(WithdrawalRequestMapper.class));
+        inject(controller, "taskTemplatePackageMapper", mock(TaskTemplatePackageMapper.class));
+        inject(controller, "butlerProfileMapper", mock(ButlerProfileMapper.class));
+        inject(controller, "supplyHierarchyChangeRequestMapper", mock(SupplyHierarchyChangeRequestMapper.class));
+        inject(controller, "pendingRefundMapper", mock(PendingRefundMapper.class));
+        inject(controller, "serviceRoleApplicationMapper", mock(ServiceRoleApplicationMapper.class));
+
+        Result<Map<String, Integer>> r = controller.summary();
+
+        Map<String, Integer> counts = r.getData();
+        assertNotNull(counts, "空队列也应返回 13 键 map");
+        assertEquals(13, counts.size(), "空队列 13 类键应齐全(值全部为 0)");
+        for (Map.Entry<String, Integer> e : counts.entrySet()) {
+            assertEquals(0, e.getValue().intValue(), "空队列 " + e.getKey() + " 应为 0");
+        }
+    }
+
+    // ==================================================================
+    // 正常流/边界流:ContentFilterController /api/content/by-tags
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-11:by-tags 无标签无问题域 → 三组内容为空(不触达 DB)")
+    void byTags_空标签_返回空分组() {
+        ContentFilterController controller = new ContentFilterController();
+
+        @SuppressWarnings("unchecked")
+        Result<Map<String, Object>> r = controller.byTags(new HashMap<>());
+
+        assertEquals(200, r.getCode().intValue(), "空标签应成功返回(游客可访问)");
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "data 不应为空");
+        assertEquals(0, ((java.util.List<?>) data.get("resultTags")).size(), "resultTags 应为空集合");
+        assertEquals(0, ((java.util.List<?>) data.get("articles")).size(), "articles 应为空(无标签不查询)");
+        assertEquals(0, ((java.util.List<?>) data.get("knowledgeBases")).size(), "knowledgeBases 应为空");
+        assertEquals(0, ((java.util.List<?>) data.get("foods")).size(), "foods 应为空");
+    }
+
+    // ==================================================================
+    // 反射辅助
+    // ==================================================================
+
+    private Method findPostMapping(Method[] methods, String path) {
+        for (Method m : methods) {
+            Annotation ann = m.getAnnotation(PostMapping.class);
+            if (ann == null) {
+                continue;
+            }
+            for (String value : ((PostMapping) ann).value()) {
+                if (path.equals(value)) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 构造 AdminArticleController,敏感检测按调用序返回(true=放行 / false=命中拦截)。 */
+    private AdminArticleController articleControllerWith(boolean... safeResults) throws Exception {
+        AdminArticleController controller = new AdminArticleController();
+        inject(controller, "articleService", mock(ArticleService.class));
+        inject(controller, "wechatService", mock(WechatServiceInterface.class));
+        @SuppressWarnings("unchecked")
+        Future<Boolean> future = mock(Future.class);
+        if (safeResults.length == 1) {
+            when(future.get(anyLong(), any(TimeUnit.class))).thenReturn(safeResults[0]);
+        } else if (safeResults.length > 1) {
+            Boolean[] boxed = new Boolean[safeResults.length];
+            for (int i = 0; i < safeResults.length; i++) {
+                boxed[i] = safeResults[i];
+            }
+            when(future.get(anyLong(), any(TimeUnit.class)))
+                    .thenReturn(boxed[0], java.util.Arrays.copyOfRange(boxed, 1, boxed.length));
+        }
+        ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
+        when(taskExecutor.submit(any(Callable.class))).thenReturn(future);
+        inject(controller, "taskExecutor", taskExecutor);
+        return controller;
+    }
+
+    /** 反射调用 create(Map, adminId, role);返回 Result(contract 兼容任意签名变体)。 */
+    @SuppressWarnings("unchecked")
+    private Result<Map<String, Object>> callCreate(AdminArticleController controller,
+            String title, String content, String role) throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("title", title);
+        body.put("content", content);
+        for (Method m : AdminArticleController.class.getDeclaredMethods()) {
+            if (m.getName().equals("create")) {
+                m.setAccessible(true);
+                if (m.getParameterCount() == 3) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID, role);
+                }
+                if (m.getParameterCount() == 2) {
+                    return (Result<Map<String, Object>>) m.invoke(controller, body, ADMIN_ID);
+                }
+            }
+        }
+        throw new IllegalStateException("契约方法 create 不存在");
+    }
+
+    private boolean invokeCheckTextSafe(AdminArticleController controller, String text) throws Exception {
+        Method m = AdminArticleController.class.getDeclaredMethod("checkTextSafe", String.class);
+        m.setAccessible(true);
+        Object r = m.invoke(controller, text);
+        return Boolean.TRUE.equals(r);
+    }
+
+    private Article article(String status, String auditStatus) {
+        Article a = new Article();
+        a.setId(ARTICLE_ID);
+        a.setStatus(status);
+        a.setAuditStatus(auditStatus);
+        a.setTitle("契约测试文章");
+        a.setContent("<p>正文</p>");
+        if ("rejected".equals(status)) {
+            a.setAuditReason("内容涉嫌夸大");
+            a.setAuditorId(7L);
+            a.setAuditedAt(new java.util.Date());
+        }
+        return a;
+    }
+
+    private void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Object getField(Object target, String field) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private void injectService(ArticleService service, String field, Object value) throws Exception {
+        inject(service, field, value);
+    }
+}