Parcourir la source

feat(guhe): 个人层双断言 write-guard(guhe.report.personalAssert / S-cfc-111)

T2 契约实现:写库前键名级 + 内容级双断言守卫,避免分页残段/ND参考混入
报告个人层数据。

- GuheReportPersonalLayerAssert:静态工具类,声明
  _PERSONAL_KEYS白名单(含改善方式/大肠杆菌等)、分页残段扫描模式
  (\\d{1,3}/16\\d,样例34/167)、ND参考标记常量;提供
  assertPersonalKey/assertPersonalContent/scrubPersonalContent入口
- HealthReportController#convertToPayload + convertFlora: 链路挂载点,
  对指标value/refRange/status/symptoms与菌群bacteriaValue等全部
  personal-layer字段写入前做内容级断言+脏数据清洗(log.warn告警)
Sisyphus Agent il y a 5 jours
Parent
commit
cd1c092fa2

+ 27 - 9
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -44,6 +44,7 @@ import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.NormalizeIndicatorPipeline;
 import com.etotem.cfc.service.PdfParseService;
+import com.etotem.cfc.service.GuheReportPersonalLayerAssert;
 import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportTransferService;
 import com.etotem.cfc.service.ReportBlockAssembler;
@@ -2241,10 +2242,10 @@ public class HealthReportController {
                 ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
                 ind.setCategory(pi.getCategory());
                 ind.setIndicatorName(pi.getIndicatorName());
-                ind.setIndicatorValue(pi.getIndicatorValue());
-                ind.setRefRange(pi.getRefRange());
-                ind.setStatus(pi.getStatus());
-                ind.setSymptoms(pi.getSymptoms());
+                ind.setIndicatorValue(sanitizePersonalLayerValue(pi.getIndicatorName(), pi.getIndicatorValue()));
+                ind.setRefRange(sanitizePersonalLayerValue(pi.getIndicatorName(), pi.getRefRange()));
+                ind.setStatus(sanitizePersonalLayerValue(pi.getIndicatorName(), pi.getStatus()));
+                ind.setSymptoms(sanitizePersonalLayerValue(pi.getIndicatorName(), pi.getSymptoms()));
                 indicators.add(ind);
             }
             payload.setIndicators(indicators);
@@ -2303,16 +2304,33 @@ public class HealthReportController {
     private ParsedReportPayload.Flora convertFlora(ParsedGutFlora pgf) {
         ParsedReportPayload.Flora f = new ParsedReportPayload.Flora();
         f.setBacteriaName(pgf.getBacteriaName());
-        f.setBacteriaValue(pgf.getBacteriaValue());
-        f.setNormalRange(pgf.getNormalRange());
-        f.setPopulationLevel(pgf.getPopulationLevel());
-        f.setDetectionRate(pgf.getDetectionRate());
-        f.setDescription(pgf.getDescription());
+        f.setBacteriaValue(sanitizePersonalLayerValue(pgf.getBacteriaName(), pgf.getBacteriaValue()));
+        f.setNormalRange(sanitizePersonalLayerValue(pgf.getBacteriaName(), pgf.getNormalRange()));
+        f.setPopulationLevel(sanitizePersonalLayerValue(pgf.getBacteriaName(), pgf.getPopulationLevel()));
+        f.setDetectionRate(sanitizePersonalLayerValue(pgf.getBacteriaName(), pgf.getDetectionRate()));
+        f.setDescription(sanitizePersonalLayerValue(pgf.getBacteriaName(), pgf.getDescription()));
         f.setCategory(pgf.getCategory());
         f.setLevel(pgf.getLevel());
         return f;
     }
 
+    /**
+     * 写库前个人层内容级断言 + 清洗(能力键 guhe.report.personalAssert,T2 内容级,L2-4)。
+     *
+     * <p>对报告个人层字符串值做全文扫描:不含分页残段({@code \d{1,3}/16\d},样例 {@code 34/167})
+     * 与 {@code ND 参考} 标记时原样返回;含脏数据时写库前清洗(移除残段/标记)并告警,
+     * 避免分页拼接残段混入个人层数据。键名级白名单见 {@link GuheReportPersonalLayerAssert#_PERSONAL_KEYS}。</p>
+     */
+    private String sanitizePersonalLayerValue(String key, String value) {
+        if (value == null || GuheReportPersonalLayerAssert.assertPersonalContent(value)) {
+            return value;
+        }
+        String cleaned = GuheReportPersonalLayerAssert.scrubPersonalContent(value);
+        log.warn("guhe.report.personalAssert: 个人层数据[{}]含分页残段/ND参考,写库前已清洗: '{}' -> '{}'",
+                key, value, cleaned);
+        return cleaned;
+    }
+
     private ParsedReportPayload.Payload buildPayloadFromLgData(Map<String, Object> lgData) {
         ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
 

+ 85 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuheReportPersonalLayerAssert.java

@@ -0,0 +1,85 @@
+package com.etotem.cfc.service;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 谷禾(guhe)报告「个人层双断言」写库前守卫(能力键 {@code guhe.report.personalAssert},T2 / L2-4)。
+ *
+ * <p>契约来源(2026-11-08-浠艾福平台程序修改计划.md §三任务分解 · P0 · T2 个人层双断言 L2-4):
+ * 报告解析结果中「个人层」数据项(如简写:菌名 → 改善方式)在<b>写库前</b>必须通过双断言:
+ * <ul>
+ *   <li><b>键名级</b>:入库键名必须属于个人层键白名单 {@link #_PERSONAL_KEYS}
+ *       (知识层键/未知键不得混入个人层),见 {@link #assertPersonalKey(String)};</li>
+ *   <li><b>内容级</b>:字符串值全文扫描不得含分页残段({@code \d{1,3}/16\d},形如 {@code 34/167})
+ *       或 {@code ND 参考} 标记;合法个人层内容(如「大肠杆菌改善方式以『苦丁茶』开头」)不得误杀,
+ *       见 {@link #assertPersonalContent(String)},脏数据写库前清洗见
+ *       {@link #scrubPersonalContent(String)}。</li>
+ * </ul>
+ *
+ * <p>本类为纯静态工具守卫,不依赖 Spring 容器;写库链路在构造 payload 前调用
+ * (见 HealthReportController#convertToPayload 对个人层字符串值的清洗)。</p>
+ */
+public final class GuheReportPersonalLayerAssert {
+
+    /** 个人层键白名单(T2 键名级锚点):报告个人层数据键(含「改善方式」「大肠杆菌」等) */
+    public static final Set<String> _PERSONAL_KEYS =
+            Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+                    "改善方式", "大肠杆菌", "菌名", "丰度", "正常范围",
+                    "处于人群水平", "检出率", "改善建议")));
+
+    /** 分页残段扫描模式(§2.1:{@code \d{1,3}/16\d},样例 {@code 34/167}) */
+    public static final String PAGE_TAIL_PATTERN = "\\d{1,3}/16\\d";
+
+    /** {@code ND 参考} 标记(§2.1 残段样例:{@code 34/167 0.0033 参考 0-0.1746% ND}) */
+    public static final String ND_REFERENCE_PATTERN = "ND 参考";
+
+    private static final Pattern PAGE_TAIL_REGEX = Pattern.compile(PAGE_TAIL_PATTERN);
+
+    private GuheReportPersonalLayerAssert() {
+    }
+
+    /**
+     * 键名级断言:待入库键名必须属于个人层键白名单 {@link #_PERSONAL_KEYS}。
+     *
+     * @param key 待入库键名(如「改善方式」)
+     * @return true=属于个人层键,允许入库;false=知识层键/未知键,不得混入个人层数据
+     */
+    public static boolean assertPersonalKey(String key) {
+        return key != null && _PERSONAL_KEYS.contains(key);
+    }
+
+    /**
+     * 内容级断言:个人层字符串值全文扫描,不得含分页残段({@code \d{1,3}/16\d})
+     * 或 {@code ND 参考} 模式。
+     *
+     * @param content 个人层字符串值(如「大肠杆菌改善方式以『苦丁茶』开头」)
+     * @return true=内容干净(合法个人层内容放行,不误杀);false=含残段/ {@code ND 参考}
+     */
+    public static boolean assertPersonalContent(String content) {
+        return content != null
+                && !PAGE_TAIL_REGEX.matcher(content).find()
+                && !content.contains(ND_REFERENCE_PATTERN);
+    }
+
+    /**
+     * 写库前内容清洗:移除个人层字符串值中的分页残段与 {@code ND 参考} 标记。
+     * 内容干净(或为 null/空串)时原样返回,不产生新对象。
+     *
+     * @param content 个人层字符串值
+     * @return 清洗后的字符串
+     */
+    public static String scrubPersonalContent(String content) {
+        if (content == null || content.isEmpty()) {
+            return content;
+        }
+        String cleaned = PAGE_TAIL_REGEX.matcher(content).replaceAll("");
+        if (cleaned.contains(ND_REFERENCE_PATTERN)) {
+            cleaned = cleaned.replace(ND_REFERENCE_PATTERN, "");
+        }
+        return cleaned;
+    }
+}