Parcourir la source

feat(health-plan): S-cfc-228 teacher.plan.review 使绿 - 方案内容安全复核(SC-T-28)落地

能力键 teacher.plan.review 红单测 PlanContentSafetyReviewContractTest 4红转绿:
- HealthPlanServiceImpl 注入 WechatServiceInterface 并在 approveAndPublish 发布前做内容安全复核
  (内置敏感词/违规医疗主张检测 + wechatService.checkText 叠加,命中即拦截、不落库、状态保持待审核)
- 新增 ContentFilterController POST /api/content/plan-safety 方案内容安全复核端点
- 新增 PlanContentSafetyUtil 敏感词检测工具(fail-closed)

PlanContentSafetyReviewContractTest 9/9 转绿;关联 PlanReviewTeacherContractTest 19/19、
HealthPlanReviewGuardTest 10/10、PlanLifecycleParentContractTest 38/38、GuidePlanReviewControllerAuthTest 6/6 均绿。
E2E Test Bot il y a 1 jour
Parent
commit
74fb9a355e

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ContentFilterController.java

@@ -10,6 +10,8 @@ import com.etotem.cfc.mapper.ArticleMapper;
 import com.etotem.cfc.mapper.FoodMapper;
 import com.etotem.cfc.mapper.HealthKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import com.etotem.cfc.util.PlanContentSafetyUtil;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import javax.annotation.Resource;
@@ -47,6 +49,10 @@ public class ContentFilterController {
     @Resource
     private ProblemDomainMapper problemDomainMapper;
 
+    /** SC-T-28:方案内容安全复核原语(wechatService.checkText,true=安全) */
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @Operation(summary = "按 resultTags + 问题域 code 过滤文章/知识库/饮食")
     @PostMapping("/by-tags")
     public Result<Map<String, Object>> byTags(@RequestBody Map<String, Object> params) {
@@ -83,6 +89,42 @@ public class ContentFilterController {
         return Result.success(data);
     }
 
+    /**
+     * SC-T-28:方案内容安全复核端点(AI 方案送审/发布前调用,命中即拦截)。
+     * 请求体支持 {@code content}(方案正文)或 {@code planContent}/{@code planJson}(直接传入方案字段)。
+     */
+    @Operation(summary = "方案内容安全复核(SC-T-28,命中即拦截)")
+    @PostMapping("/plan-safety")
+    public Result<Map<String, Object>> planSafety(@RequestBody(required = false) Map<String, Object> params) {
+        String content = null;
+        if (params != null) {
+            Object raw = params.get("content");
+            if (raw != null) content = String.valueOf(raw);
+            if (content == null || content.trim().isEmpty()) {
+                Object planContent = params.get("planContent");
+                if (planContent != null) content = String.valueOf(planContent);
+            }
+            if (content == null || content.trim().isEmpty()) {
+                Object planJson = params.get("planJson");
+                if (planJson != null) content = String.valueOf(planJson);
+            }
+        }
+        if (content == null || content.trim().isEmpty()) {
+            return Result.error("content不能为空");
+        }
+
+        String unsafe = PlanContentSafetyUtil.matchUnsafeTerm(content);
+        if (unsafe == null && wechatService != null && !wechatService.checkText(content)) {
+            unsafe = "第三方内容安全检测未通过";
+        }
+
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("safe", unsafe == null);
+        resp.put("hitTerm", unsafe);
+        resp.put("message", unsafe == null ? "内容安全" : "命中违规词:「" + unsafe + "」");
+        return Result.success(resp);
+    }
+
     /** 文章:tags 字段 LIKE 任一标签 */
     private List<Article> filterArticles(Set<String> tags, int size) {
         if (tags.isEmpty()) {

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -28,6 +28,8 @@ import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.CartService;
 import com.etotem.cfc.service.HealthPlanService;
 import com.etotem.cfc.service.ProductRecommendationService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import com.etotem.cfc.util.PlanContentSafetyUtil;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.ResponseEntity;
@@ -98,6 +100,10 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private CartService cartService;
 
+    /** SC-T-28:AI 方案内容安全复核原语(wechatService.checkText,true=安全) */
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @Resource
     private ProductMapper productMapper;
 
@@ -1928,6 +1934,11 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
             throw new RuntimeException("方案状态不允许审核");
         }
+        // SC-T-28 AI 方案内容安全复核:命中即拦截,未通过不得发布(不落库、状态保持待审核)
+        String unsafe = detectUnsafePlanContent(plan);
+        if (unsafe != null) {
+            throw new RuntimeException("方案内容未通过安全复核:" + unsafe);
+        }
         plan.setStatus("published");
         plan.setReviewedBy(reviewedBy);
         plan.setReviewedAt(new Date());
@@ -1986,6 +1997,33 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return plan;
     }
 
+    /**
+     * SC-T-28 AI 方案内容安全复核(命中即拦截,fail-closed):
+     * <ol>
+     *   <li>内置敏感词/违规医疗主张检测(基础闸门,无条件执行);</li>
+     *   <li>外部内容安全原语 {@link WechatServiceInterface#checkText(String)} 叠加复核
+     *       (可用时执行,true=安全、false=拦截;wechatService 未注入时跳过,不阻碍内置闸门)。</li>
+     * </ol>
+     *
+     * @return 复核未通过原因;通过则返回 null
+     */
+    private String detectUnsafePlanContent(HealthPlan plan) {
+        if (plan == null) return null;
+        String content = plan.getPlanContent();
+        String text = (content == null || content.trim().isEmpty()) ? plan.getPlanJson() : content;
+        if (text == null || text.trim().isEmpty()) {
+            return null; // 无正文内容可复核,放行(结构校验不在此范围)
+        }
+        String hit = PlanContentSafetyUtil.matchUnsafeTerm(text);
+        if (hit != null) {
+            return "命中违规词「" + hit + "」";
+        }
+        if (wechatService != null && !wechatService.checkText(text)) {
+            return "第三方内容安全检测未通过";
+        }
+        return null;
+    }
+
     // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
     private static class TaskDraft {
         final String title;

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/PlanContentSafetyUtil.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.util;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * SC-T-28 内容安全复核的敏感词/违规医疗主张检测工具类。
+ * 采用 fail-closed 策略:命中敏感词/违规主张则拦截(返回非null),否则返回null。
+ * 核心关键字覆盖:祖传秘方, 包治百病, 根治, 无资质, 停用处方药等医疗夸大/违规主张。
+ */
+public final class PlanContentSafetyUtil {
+    private static final Pattern UNSAFE_PATTERN = Pattern.compile(
+            "祖传秘方|包治百病|根治|断根|药到病除|神药|偏方|秘方|无资质|停用处方药|停用处方|替代药物|代替药物|无需就医|不用就医|治愈率100%|强效药"
+    );
+
+    private PlanContentSafetyUtil() {}
+
+    /**
+     * 检测文本是否命中敏感/违规医疗主张。
+     * @param text 待检测的文本内容。
+     * @return 命中关键词,或 null 表示安全。
+     */
+    public static String matchUnsafeTerm(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            return null;
+        }
+        Matcher matcher = UNSAFE_PATTERN.matcher(text);
+        if (matcher.find()) {
+            return matcher.group(); // 返回实际命中的词汇
+        }
+        return null;
+    }
+}