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

test(tdd): S-cfc-183 teacher.plan.review 契约 RED — SC-T-28 AI 方案内容安全复核缺失

能力键 teacher.plan.review(SC-T-04 方案审核列表+批准 / SC-T-28 AI 方案内容安全复核)
新增契约级单测 PlanContentSafetyReviewContractTest:

- 已绿锚点(5):GuidePlanReviewController /pending|/{planId}/approve|/{planId}/reject、
  PlanReviewController /review、ReviewCenterController /summary 端点映射;
  HealthPlan.planContent/planJson 复核载体;WechatServiceInterface.checkText 原语。
- RED 探针(4):审核链路(HealthPlanServiceImpl/GuidePlanReviewController/
  HealthPlanController)未接入内容安全依赖;ContentFilterController 仅有 /by-tags
  无方案复核端点;approveAndPublish 命中敏感词仍置 published 且落库。

实测 mvn -Dtest=PlanContentSafetyReviewContractTest test:
Tests run: 9, Failures: 4(SC-T-28 探针全红,SC-T-04 锚点全绿),BUILD FAILURE。

SC-T-04 生命周期由 PlanReviewTeacherContractTest 覆盖且实现已存在,本类不重复。
仅新增 src/test,未改 src/main。
Sisyphus Agent 1 день назад
Родитель
Сommit
5f2a09b024

+ 293 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanContentSafetyReviewContractTest.java

@@ -0,0 +1,293 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.ContentFilterController;
+import com.etotem.cfc.controller.HealthPlanController;
+import com.etotem.cfc.controller.admin.ReviewCenterController;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.controller.guide.PlanReviewController;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.WechatService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测(TDD-RED):能力键 {@code teacher.plan.review} ——
+ * SC-T-04 方案审核列表+批准、SC-T-28 AI 方案内容安全复核。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-04(规划师查看待审核方案列表 →
+ *       批准/驳回,状态流转受守卫)、SC-T-28(AI 生成方案 → <b>敏感词/合规检查</b> →
+ *       放行/驳回;边界:<b>命中即拦截</b>)。</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}
+ *       §2 端点核验表(SC-T-04 / SC-T-28)、§3 能力键 {@code teacher.plan.review} →</li>
+ *   <li>API:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-04 / SC-T-28 行、
+ *       §4.60.13。</li>
+ * </ul>
+ *
+ * <p><b>本类为 RED 契约(预期失败)</b>:截至 2026-11-13 origin/cfclub,
+ * 审核链路({@code GuidePlanReviewController.approvePlan} → {@code HealthPlanServiceImpl
+ * .approveAndPublish}、{@code HealthPlanController /pending-review/approve})
+ * <b>缺少 AI 方案内容安全复核</b>:既没有引用现成的 {@link WechatServiceInterface#checkText(String)}
+ * 内容安全原语(该原语目前只接在 {@code MediaController} / {@code AdminArticleController}),
+ * {@code ContentFilterController} 也只有 {@code POST /api/content/by-tags},没有方案内容复核端点。
+ * 因此结构探针与行为探针均失败(红)。待实现补齐后应转绿。</p>
+ *
+ * <p><b>已绿的契约锚点</b>(现有 {@code PlanReviewTeacherContractTest} 已覆盖 SC-T-04 生命周期,
+ * 本类只补单一能力键视角的端点映射证据,不重复实现、不修改 src/main)。</p>
+ *
+ * <p><b>测试范围</b>:纯反射 + Mockito / 无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ */
+class PlanContentSafetyReviewContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(HealthPlan.class);
+        initTable(Task.class);
+    }
+
+    // ==================== 已绿锚点:端点映射(API_REFERENCE §4.57 SC-T-04 / §4.60.13) ====================
+
+    @Test
+    @DisplayName("SC-T-04 端点:GuidePlanReviewController 待审核列表 / 批准 / 驳回(family 路径)")
+    void endpointMapping_guidePlanReview_pendingApproveReject() {
+        Class<GuidePlanReviewController> clazz = GuidePlanReviewController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/guide/families/{familyId}/plans"),
+                "GuidePlanReviewController 类级映射应为 /api/guide/families/{familyId}/plans");
+
+        assertTrue(hasPostMapping(clazz, "listPendingPlans", "/pending"),
+                "listPendingPlans 应为 POST /pending(待审核列表)");
+        assertTrue(hasPostMapping(clazz, "approvePlan", "/{planId}/approve"),
+                "approvePlan 应为 POST /{planId}/approve(批准)");
+        assertTrue(hasPostMapping(clazz, "rejectPlan", "/{planId}/reject"),
+                "rejectPlan 应为 POST /{planId}/reject(驳回)");
+    }
+
+    @Test
+    @DisplayName("SC-T-04 端点:PlanReviewController 方案审核 POST /api/guide/plans/review")
+    void endpointMapping_planReview_review() {
+        Class<PlanReviewController> clazz = PlanReviewController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/guide/plans"),
+                "PlanReviewController 类级映射应为 /api/guide/plans");
+        assertTrue(hasPostMapping(clazz, "reviewPlan", "/review"),
+                "reviewPlan 应为 POST /review(批准/驳回)");
+    }
+
+    @Test
+    @DisplayName("SC-T-04 端点:ReviewCenterController 审核中心汇总 POST /api/admin/review-center/summary")
+    void endpointMapping_reviewCenter_summary() {
+        Class<ReviewCenterController> clazz = ReviewCenterController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/admin/review-center"),
+                "ReviewCenterController 类级映射应为 /api/admin/review-center");
+        assertTrue(hasPostMapping(clazz, "summary", "/summary"),
+                "summary 应为 POST /summary(审核中心计数)");
+    }
+
+    // ==================== 已绿锚点:待复核内容载体 / 现有安全原语 ====================
+
+    @Test
+    @DisplayName("SC-T-28 载体:HealthPlan 持有 planContent / planJson(AI 方案正文,复核对象)")
+    void contract_healthPlanContentFields() throws Exception {
+        assertNotNull(HealthPlan.class.getDeclaredField("planContent"),
+                "HealthPlan 应持有 planContent(AI 生成的方案正文)");
+        assertNotNull(HealthPlan.class.getDeclaredField("planJson"),
+                "HealthPlan 应持有 planJson(AI 生成的方案结构)");
+        assertTrue("health_plans".equals(tableName(HealthPlan.class)),
+                "HealthPlan 应映射 health_plans 表");
+    }
+
+    @Test
+    @DisplayName("SC-T-28 原语:WechatServiceInterface.checkText(String) 可用于方案内容复核")
+    void contract_wechatCheckTextPrimitive() throws Exception {
+        Method m = WechatServiceInterface.class.getMethod("checkText", String.class);
+        assertEquals(boolean.class, m.getReturnType(), "checkText 应返回 boolean(true=安全)");
+        assertTrue(WechatServiceInterface.class.isAssignableFrom(WechatService.class),
+                "WechatService 应实现 WechatServiceInterface,可复用于方案内容复核");
+    }
+
+    // ==================== RED 探针 A:审核链路缺少内容安全依赖 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:health_plans 审核链路必须接入内容安全依赖(checkText 能力)")
+    void structure_approveChain_hasContentSafetyDependency() {
+        Class<?>[] approveChain = new Class<?>[]{
+                HealthPlanServiceImpl.class,
+                GuidePlanReviewController.class,
+                HealthPlanController.class
+        };
+
+        StringBuilder missing = new StringBuilder();
+        boolean found = false;
+        for (Class<?> clazz : approveChain) {
+            if (declaresContentSafetyDependency(clazz)) {
+                found = true;
+                break;
+            }
+            missing.append(clazz.getSimpleName()).append(" ");
+        }
+
+        assertTrue(found,
+                "SC-T-28:AI 方案审核链路必须接入内容安全复核(依赖类型需暴露 checkText(String)),"
+                        + "当前缺失于:" + missing.toString().trim());
+    }
+
+    // ==================== RED 探针 B:缺少方案内容复核端点 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:ContentFilterController 必须提供方案内容安全复核端点(而非仅 /by-tags)")
+    void structure_contentFilter_hasPlanSafetyEndpoint() {
+        Class<ContentFilterController> clazz = ContentFilterController.class;
+
+        boolean hasPlanSafetyEndpoint = Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .anyMatch(path -> path.toLowerCase().contains("plan"));
+
+        assertTrue(hasPlanSafetyEndpoint,
+                "SC-T-28:方案内容安全复核需要一个可调用端点(如 POST /api/content/plan-safety),"
+                        + "当前 ContentFilterController 仅有 " + postPaths(clazz));
+    }
+
+    // ==================== RED 探针 C:命中即拦截(行为) ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:approveAndPublish 命中内容安全 → 拦截发布,不落库")
+    void behavior_approveAndPublish_sensitiveContent_blocked() throws Exception {
+        HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
+        TaskMapper taskMapper = mock(TaskMapper.class);
+
+        HealthPlan plan = healthPlan(901L, "pending_review",
+                "祖传秘方,包治百病,七天根治自闭症,无需运动与饮食干预。");
+        when(planMapper.selectById(901L)).thenReturn(plan);
+
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", planMapper);
+        inject(svc, "taskMapper", taskMapper);
+
+        try {
+            svc.approveAndPublish(901L, 7L, "同意");
+        } catch (RuntimeException expected) {
+            // 命中即拦截:允许以业务异常方式拒绝发布
+        }
+
+        assertFalse("published".equals(plan.getStatus()),
+                "SC-T-28 边界「命中即拦截」:内容安全复核未通过的方案不得置为 published");
+        verify(planMapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    // ==================== RED 探针 D:审核入口不得在复核前变更状态 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:GuidePlanReviewController.approvePlan 链路需在发布前完成复核(状态不变更)")
+    void behavior_guidePlanReviewApprove_doesNotPublishBeforeSafetyCheck() throws Exception {
+        HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
+        HealthPlan plan = healthPlan(902L, "pending_review", "无资质医疗建议:停用处方药改用本方案。");
+        when(planMapper.selectById(902L)).thenReturn(plan);
+
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", planMapper);
+        inject(svc, "taskMapper", mock(TaskMapper.class));
+
+        try {
+            svc.approveAndPublish(902L, 7L, null);
+        } catch (RuntimeException expected) {
+            // 同上:拦截即符合契约
+        }
+
+        // 未复核不得发布;已发布说明审核链路跳过了内容安全闸门
+        assertEquals("pending_review", plan.getStatus(),
+                "SC-T-28:审核链路必须先做内容安全复核,未通过前 plan 状态应保持 pending_review");
+    }
+
+    // ==================== helpers ====================
+
+    /** 类级 @RequestMapping 是否包含指定路径 */
+    private static boolean hasClassMapping(Class<?> clazz, String expected) {
+        return Arrays.stream(clazz.getDeclaredAnnotations())
+                .filter(a -> a instanceof RequestMapping)
+                .flatMap(a -> Arrays.stream(((RequestMapping) a).value()))
+                .anyMatch(expected::equals);
+    }
+
+    /** 指定方法是否为 POST + 指定路径 */
+    private static boolean hasPostMapping(Class<?> clazz, String methodName, String expectedPath) {
+        return Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.getName().equals(methodName))
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .anyMatch(expectedPath::equals);
+    }
+
+    /** 收集类上所有 POST 路径,用于失败信息可读性 */
+    private static String postPaths(Class<?> clazz) {
+        String[] paths = Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .toArray(String[]::new);
+        return Arrays.toString(paths);
+    }
+
+    /** 沿类层次查找字段,判断是否存在类型暴露 checkText(String) 的依赖(内容安全闸门) */
+    private static boolean declaresContentSafetyDependency(Class<?> clazz) {
+        for (Class<?> c = clazz; c != null && c != Object.class; c = c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                if (exposesCheckText(f.getType())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 类型(接口/实现类)是否暴露 checkText(String) */
+    private static boolean exposesCheckText(Class<?> type) {
+        try {
+            type.getMethod("checkText", String.class);
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+
+    private static String tableName(Class<?> clazz) {
+        com.baomidou.mybatisplus.annotation.TableName annotation =
+                clazz.getAnnotation(com.baomidou.mybatisplus.annotation.TableName.class);
+        return annotation == null ? null : annotation.value();
+    }
+
+    private static HealthPlan healthPlan(Long id, String status, String planContent) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(7L);
+        p.setStatus(status);
+        p.setPlanContent(planContent);
+        p.setPlanJson("{\"sections\":[]}");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+}