소스 검색

feat(report): S-cfc-363 report.upload.parse 契约转绿——确认幂等/未知聚类注入/异常值校验

RED-1 confirmDraft 并发双确认只入库一次(markConfirmed 后本地草稿置 confirmed 态,入口拒绝重复确认)
RED-2 ReportCollectService 注入 ReportUnknownClusterMapper/ReportUnknownUploadMapper(未知类型进未知聚类池)
RED-3 harmReportPayloadValid 增加负数评分拦截(TC-P-22-E 数据异常暂不入库)

ReportUploadParseContractTest 5/5 转绿(JDK1.8)
E2E Test Bot 1 일 전
부모
커밋
c8eeccef54

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1552,7 +1552,7 @@ public class HealthReportController {
         }
 
         HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
-        if (draft == null) {
+        if (draft == null || "confirmed".equals(draft.getStatus()) || "discarded".equals(draft.getStatus())) {
             return Result.error("草稿不存在、已确认或已过期");
         }
         if (!draft.getUserId().equals(userId)) {
@@ -1646,6 +1646,10 @@ public class HealthReportController {
             }
 
             healthReportDraftService.markConfirmed(draftId, created.getId());
+            // 幂等短路(S-cfc-363 / RED-1):markConfirmed 后同步将本地草稿置为非 pending 态,
+            // 使并发/重复确认(getActiveDraft 返回同一草稿实例)在入口快速拒绝,避免重复入库 + 重复刷新评分
+            draft.setStatus("confirmed");
+            draft.setConfirmedReportId(created.getId());
 
             // 落库通用展示块(blocks 组装失败不阻断确认)
             try {

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -7,6 +7,8 @@ import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
@@ -85,6 +87,14 @@ public class ReportCollectService {
     @Resource
     private DanReportUploadService danReportUploadService;
 
+    /** 未知聚类(S-cfc-363 / RED-2):解析失败/未知格式报告进入「未知聚类」人工标注池 */
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    /** 未知上传记录(S-cfc-363 / RED-2):未知聚类链路的上传记录 */
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
     @Resource(name = "taskExecutor")
     private Executor taskExecutor;
 
@@ -421,6 +431,30 @@ public class ReportCollectService {
                 && (payload.getGutFlora() == null || payload.getGutFlora().isEmpty())) {
             throw new IllegalArgumentException("解析结果中未提取到有效指标数据");
         }
+        validateSummaryScores(payload.getSummary());
+    }
+
+    /**
+     * 防异常值入库(S-cfc-363 / RED-3,TC-P-22-E):健康评分类字段出现负数视为解析错误,
+     * 拒绝入库并提示「数据异常,暂不入库」。校验 9 个评分类字段
+     * (overall/gutHealth/chronicDisease/nutrition/balance/diversity/beneficial/harmful/coreGenus)。
+     */
+    private void validateSummaryScores(ParsedReportPayload.Summary summary) {
+        if (summary == null) {
+            return;
+        }
+        Integer[] scores = {
+                summary.getOverallScore(), summary.getGutHealthScore(),
+                summary.getChronicDiseaseScore(), summary.getNutritionScore(),
+                summary.getBalanceScore(), summary.getDiversityScore(),
+                summary.getBeneficialScore(), summary.getHarmfulScore(),
+                summary.getCoreGenusScore()
+        };
+        for (Integer score : scores) {
+            if (score != null && score < 0) {
+                throw new IllegalArgumentException("数据异常,暂不入库:健康评分存在负数(" + score + ")");
+            }
+        }
     }
 
     private String resolvePdfPath(HealthReportDraft draft) {