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

feat: ReportCollectService 大改 + 五维维度白名单校验(DimensionCodes)

- ReportCollectService: 报告采集流程重构(163行)
- DimensionCodes: 五维维度码白名单校验,防止 JSON 路径注入导致 500
- ActivityService/ProductService/ArticleService/TaskService/ContentRecommendService: 相关适配改动
Xiaogang Liao 1 месяц назад
Родитель
Сommit
d12afc35ac

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -30,6 +30,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -68,11 +69,12 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
                 .eq(Activity::getStatus, "published")
                 .orderByAsc(Activity::getStartTime);
-        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.isEmpty() && DimensionCodes.isValid(dimensionCode)) {
+            String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
             query.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
-                .or().eq(Activity::getDimensionCode, dimensionCode)
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                .or().eq(Activity::getDimensionCode, dim)
             );
         }
         // 游客过滤:只返回公开活动

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -30,6 +30,7 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -98,7 +99,7 @@ public class ArticleService {
             String kw = keyword.trim();
             wrapper.and(w -> w.like(Article::getTitle, kw).or().like(Article::getSummary, kw).or().like(Article::getTags, kw));
         }
-        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty() && DimensionCodes.isValid(dimensionCode)) {
             String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 relatedDimensions
             wrapper.and(w -> w
@@ -131,7 +132,7 @@ public class ArticleService {
                 .orderByDesc(Article::getPublishedAt)
                 .last("LIMIT " + Math.max(size, 50));
 
-        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty() && DimensionCodes.isValid(dimensionCode)) {
             String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 relatedDimensions
             wrapper.and(w -> w

+ 9 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java

@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -260,11 +261,12 @@ private List<RecommendItemVO> recommendProducts(String dimensionCode, Long userI
                 .orderByDesc(Product::getSalesCount)
                 .last("LIMIT 50");
 
-        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty() && DimensionCodes.isValid(dimensionCode)) {
+            String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 domain
             wrapper.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode.trim())
-                .or().eq(Product::getDomain, dimensionCode.trim()));
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                .or().eq(Product::getDomain, dim));
         }
 
         SortUtil.applySort(wrapper);
@@ -364,11 +366,12 @@ private List<RecommendItemVO> recommendProducts(String dimensionCode, Long userI
                 .orderByDesc(Activity::getStartTime)
                 .last("LIMIT 50");
 
-        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty() && DimensionCodes.isValid(dimensionCode)) {
+            String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
             wrapper.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode.trim())
-                .or().eq(Activity::getDimensionCode, dimensionCode.trim()));
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                .or().eq(Activity::getDimensionCode, dim));
         }
 
         SortUtil.applySort(wrapper);

+ 6 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -30,6 +30,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -101,11 +102,13 @@ wrapper.in(Product::getId, productIds)
         }
         // 统一参数名: dimensionCode 优先,兼容旧版 domain
         String dimCode = query.getDimensionCode() != null ? query.getDimensionCode() : query.getDomain();
-        if (dimCode != null && !dimCode.isEmpty()) {
+        // 白名单校验:仅合法五维码可进入 JSON 路径,非法输入(模糊测试/注入探针)跳过维度过滤,避免 Invalid JSON path 500
+        if (dimCode != null && !dimCode.isEmpty() && DimensionCodes.isValid(dimCode)) {
+            String dim = dimCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 domain
             wrapper.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimCode)
-                .or().eq(Product::getDomain, dimCode)
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                .or().eq(Product::getDomain, dim)
             );
         }
         if (query.getCategoryId() != null) {

+ 146 - 17
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.dto.ParsedReportPayload;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.concurrent.Executor;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 /**
  * 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
@@ -137,6 +139,12 @@ public class ReportCollectService {
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
                 method = METHOD_FINGERPRINT;
                 payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
+                // 专用解析脚本缺失/失败时回退 opencode,避免已知类型整单失败
+                if (payload == null) {
+                    log.warn("已知类型专用脚本解析为空,回退 opencode typeId={}", typeId);
+                    method = METHOD_OPENCODE;
+                    payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+                }
             } else {
                 method = METHOD_OPENCODE;
                 payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
@@ -196,30 +204,151 @@ public class ReportCollectService {
         }
     }
 
-    /** 未知类型:交本地 opencode 服务,提示词带 PDF 内容(后端先 pdftotext 提取文本内联),期望返回结构化 JSON */
+    /**
+     * 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
+     * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
+     * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
+     */
     private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
         String pdfText = extractPdfText(pdfPath);
-        String sessionId = opencodeCreateSession();
-        String prompt = "下面是健康检测报告(文件名:" + (fileName != null ? fileName : pdfPath)
-                + ")用 pdftotext 提取出的全文文本。请从中提取健康指标数据,并仅返回一个严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n"
-                + "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\"},\n"
-                + "\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}],\n"
-                + "\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\n"
-                + "\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}],\n"
-                + "\"foods\":[]}\n"
-                + "报告全文文本如下:\n" + pdfText + "\n"
-                + "若无法提取某部分数据返回空数组或 null。";
-        String reply = opencodeSendAndWait(sessionId, prompt);
-        if (reply == null || reply.trim().isEmpty()) {
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        int produced = 0;
+
+        JsonNode sr = opencodeSection(pdfText, fileName,
+                "只提取 summary 和 diseaseRisks 两部分,其余字段不返回",
+                "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"chronicDiseaseScore\":0,\"nutritionScore\":0,\"balanceScore\":0,\"diversityScore\":0,\"beneficialScore\":0,\"harmfulScore\":0,\"coreGenusScore\":0,\"gutAge\":\"\",\"gutType\":\"\",\"shannonIndex\":\"\",\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\",\"age\":0,\"birthday\":\"\"},\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}]}");
+        if (sr != null) {
+            if (sr.has("summary") && !sr.get("summary").isNull()) {
+                payload.setSummary(objectMapper.convertValue(sr.get("summary"), ParsedReportPayload.Summary.class));
+                produced++;
+            }
+            if (sr.has("diseaseRisks") && sr.get("diseaseRisks").isArray()) {
+                payload.setDiseaseRisks(objectMapper.convertValue(sr.get("diseaseRisks"),
+                        new TypeReference<List<ParsedReportPayload.DiseaseRisk>>() {}));
+                produced++;
+            }
+        }
+
+        JsonNode ind = opencodeSection(pdfText, fileName,
+                "只提取 indicators 指标数组,其余字段不返回",
+                "{\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}]}");
+        if (ind != null && ind.has("indicators") && ind.get("indicators").isArray()) {
+            payload.setIndicators(objectMapper.convertValue(ind.get("indicators"),
+                    new TypeReference<List<ParsedReportPayload.Indicator>>() {}));
+            produced++;
+        }
+
+        TypeReference<List<ParsedReportPayload.Flora>> floraType = new TypeReference<List<ParsedReportPayload.Flora>>() {};
+
+        JsonNode floraMain = opencodeSection(pdfText, fileName,
+                "只提取 gutFlora、probioticSpecies、pathogenGenus、pathogenDetection 四部分菌群数据,其余字段不返回",
+                "{\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\"probioticSpecies\":[],\"pathogenGenus\":[],\"pathogenDetection\":[]}",
+                true);
+        if (floraMain != null) {
+            putFlora(payload::setGutFlora, floraMain, "gutFlora", floraType);
+            putFlora(payload::setProbioticSpecies, floraMain, "probioticSpecies", floraType);
+            putFlora(payload::setPathogenGenus, floraMain, "pathogenGenus", floraType);
+            putFlora(payload::setPathogenDetection, floraMain, "pathogenDetection", floraType);
+            if (payload.getGutFlora() != null || payload.getProbioticSpecies() != null
+                    || payload.getPathogenGenus() != null || payload.getPathogenDetection() != null) {
+                produced++;
+            }
+        }
+
+        JsonNode taxHigh = opencodeSection(pdfText, fileName,
+                "只提取 taxonomyClass、taxonomyOrder、taxonomyFamily 三个分类层级占比数据,其余字段不返回",
+                "{\"taxonomyClass\":[],\"taxonomyOrder\":[],\"taxonomyFamily\":[]}",
+                true);
+        if (taxHigh != null) {
+            putFlora(payload::setTaxonomyClass, taxHigh, "taxonomyClass", floraType);
+            putFlora(payload::setTaxonomyOrder, taxHigh, "taxonomyOrder", floraType);
+            putFlora(payload::setTaxonomyFamily, taxHigh, "taxonomyFamily", floraType);
+        }
+
+        JsonNode taxLow = opencodeSection(pdfText, fileName,
+                "只提取 taxonomyGenus、taxonomySpecies 两个分类层级占比数据,其余字段不返回",
+                "{\"taxonomyGenus\":[],\"taxonomySpecies\":[]}",
+                true);
+        if (taxLow != null) {
+            putFlora(payload::setTaxonomyGenus, taxLow, "taxonomyGenus", floraType);
+            putFlora(payload::setTaxonomySpecies, taxLow, "taxonomySpecies", floraType);
+        }
+
+        if (produced == 0
+                && payload.getGutFlora() == null && payload.getTaxonomyClass() == null
+                && payload.getTaxonomyGenus() == null && payload.getTaxonomySpecies() == null) {
             return null;
         }
-        String json = extractJson(reply);
+        return payload;
+    }
+
+    private void putFlora(Consumer<List<ParsedReportPayload.Flora>> setter, JsonNode section, String field,
+                          TypeReference<List<ParsedReportPayload.Flora>> type) {
         try {
-            JsonNode node = objectMapper.readTree(json);
-            return normalizeToPayload(node);
+            if (section.has(field) && section.get(field).isArray()) {
+                setter.accept(objectMapper.convertValue(section.get(field), type));
+            }
+        } catch (Exception e) {
+            log.warn("菌群字段 {} 转换失败,跳过: {}", field, e.getMessage());
+        }
+    }
+
+    private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema) {
+        return opencodeSection(pdfText, fileName, instruction, schema, false);
+    }
+
+    /**
+     * 单轮分块解析:新建 session、发送单个提示词、校验返回为合法 JSON。
+     * 若第一轮输出被截断/非法,重试一次并指示精简输出(去掉空字段、用紧凑格式)。
+     * compactFirst=true 时首轮即要求精简(用于数据量大的菌群块,避免首轮就超预算)。
+     */
+    private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema, boolean compactFirst) {
+        String prompt = buildSectionPrompt(pdfText, fileName, instruction, schema, compactFirst);
+        JsonNode node = sendOnceAndParse(prompt);
+        if (node != null) {
+            return node;
+        }
+        String retryPrompt = buildSectionPrompt(pdfText, fileName, instruction, schema, true);
+        return sendOnceAndParse(retryPrompt);
+    }
+
+    private String buildSectionPrompt(String pdfText, String fileName, String instruction, String schema, boolean compact) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("下面是健康检测报告(文件名:").append(fileName != null ? fileName : "未知")
+                .append(")用 pdftotext 提取出的全文文本。").append(instruction)
+                .append(",仅返回严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n")
+                .append(schema).append('\n');
+        if (compact) {
+            sb.append("要求:1) 省略所有空字段(值为空或 null 的字段不要输出);2) 数值用紧凑格式;3) 每种数组最多返回 40 条(超出的按重要性取前 40 条);4) 不要省略合法数据。\n");
+        }
+        sb.append("报告全文文本如下:\n").append(pdfText).append('\n')
+                .append("若无法提取某部分数据返回空数组或 null。");
+        return sb.toString();
+    }
+
+    private JsonNode sendOnceAndParse(String prompt) {
+        String sessionId = opencodeCreateSession();
+        try {
+            String reply = opencodeSendAndWait(sessionId, prompt);
+            if (reply == null || reply.trim().isEmpty()) {
+                return null;
+            }
+            String json = extractJson(reply);
+            return objectMapper.readTree(json);
         } catch (Exception e) {
-            log.warn("opencode 输出转载荷失败: {}", e.getMessage());
+            log.warn("opencode 分块输出转载荷失败: {}", e.getMessage());
             return null;
+        } finally {
+            deleteSession(sessionId);
+        }
+    }
+
+    private void deleteSession(String sessionId) {
+        try {
+            restTemplate.exchange(opencodeBase + "/session/" + sessionId,
+                    HttpMethod.DELETE, new HttpEntity<>(opencodeHeaders()), Void.class);
+        } catch (Exception e) {
+            log.debug("opencode 删除会话失败 sessionId={}: {}", sessionId, e.getMessage());
         }
     }
 

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.*;
+import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -340,10 +341,11 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
                         .lt(Task::getCreatedAt, endOfDay)))
             .orderByAsc(Task::getDeadline);
 
-        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+        if (dimensionCode != null && !dimensionCode.isEmpty() && DimensionCodes.isValid(dimensionCode)) {
+            String dim = dimensionCode.trim();
             wrapper.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
-                .or().eq(Task::getDimensionCode, dimensionCode)
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                .or().eq(Task::getDimensionCode, dim)
             );
         }
 

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/DimensionCodes.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.util;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 五维维度代码校验工具。
+ *
+ * 后端在将客户端传入的 dimensionCode/domain 拼入 MySQL JSON 路径(JSON_EXTRACT ... '$.xxx')之前,
+ * 必须先做白名单校验,否则恶意/模糊输入(SQL 注入探针、日志注入等)会触发
+ * "Invalid JSON path expression" 数据库异常,导致整个接口 500。
+ *
+ * 合法维度码(与 products.domain / activities.dimension_code / tasks.dimension_code / dimension_weights 键一致):
+ * body(身) / mind(智) / wisdom(富) / action(行) / wealth(心) —— 见 schema.sql 与五维体系定义。
+ */
+public final class DimensionCodes {
+
+    /** 合法五维维度码 */
+    private static final Set<String> VALID = new HashSet<>(Arrays.asList(
+            "body", "mind", "wisdom", "action", "wealth"
+    ));
+
+    private DimensionCodes() {
+    }
+
+    /**
+     * 判断维度码是否合法,只有合法维度码才允许进入 JSON 路径表达式。
+     *
+     * @param dimensionCode 客户端传入的维度码(可含空白)
+     * @return true=合法,false=非法或空
+     */
+    public static boolean isValid(String dimensionCode) {
+        if (dimensionCode == null) {
+            return false;
+        }
+        return VALID.contains(dimensionCode.trim());
+    }
+}