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

feat: DAN报告上传 - Controller端点 + 维度分数更新

新增/report/parse-preview /report/confirm /report/child接口 + refreshFromDanAssessment()方法

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 3 месяцев назад
Родитель
Сommit
8f68977810

+ 155 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -3,11 +3,14 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.service.*;
+import com.etotem.cfc.service.DanReportParseService.DanParsedReport;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -30,6 +33,12 @@ public class DanAssessmentController {
     @Resource
     private com.etotem.cfc.mapper.DanAssessmentResultMapper danAssessmentResultMapper;
 
+    @Resource
+    private DanReportParseService danReportParseService;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
     // ===== 测评材料 =====
 
     @Operation(summary = "获取当前测评材料")
@@ -270,4 +279,150 @@ public class DanAssessmentController {
         data.put("structuredAnalysis", result.getStructuredAnalysis());
         return Result.success(data);
     }
+
+    // ===== DAN 报告上传与解析 =====
+
+    @Operation(summary = "上传 DAN 报告并预览解析结果(Phase 1)")
+    @PostMapping("/report/parse-preview")
+    public Result<Map<String, Object>> parsePreview(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("dimension") String dimension,
+            @RequestParam("memberId") Long memberId,
+            @RequestAttribute("userId") Long userId) {
+        if (file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+        if (!"mind".equals(dimension) && !"wisdom".equals(dimension)) {
+            return Result.error("dimension 必须为 mind 或 wisdom");
+        }
+        try {
+            // 1. 解析 PDF
+            DanParsedReport parsed = danReportParseService.parse(file.getInputStream(), dimension);
+
+            // 2. 创建草稿记录(draft_status = pending)
+            DanAssessmentResult draft = new DanAssessmentResult();
+            draft.setChildName(""); // 前端预览后再填
+            draft.setAssessmentDate(new java.util.Date());
+            draft.setStructuredAnalysis(parsedToJson(parsed));
+            draft.setDimension(dimension);
+            draft.setReportFileId(file.getOriginalFilename());
+            draft.setDraftStatus("pending");
+            draft.setCreatedAt(new java.util.Date());
+            draft.setUpdatedAt(new java.util.Date());
+            danAssessmentResultMapper.insert(draft);
+
+            // 3. 返回预览数据
+            Map<String, Object> data = new java.util.LinkedHashMap<>();
+            data.put("draftId", draft.getId());
+            data.put("dimension", dimension);
+            data.put("summary", parsed.getSummary() != null ? parsed.getSummary()
+                    : (parsed.getItems() != null ? parsed.getItems().size() + " 项数据" : ""));
+            data.put("items", parsed.getItems() != null
+                    ? parsed.getItems().stream().map(item -> {
+                        Map<String, Object> m = new java.util.LinkedHashMap<>();
+                        m.put("code", item.getCode());
+                        m.put("name", item.getName());
+                        m.put("value", item.getValue());
+                        m.put("category", item.getCategory());
+                        return m;
+                    }).collect(java.util.stream.Collectors.toList())
+                    : java.util.Collections.emptyList());
+            data.put("suggestions", parsed.getSuggestions());
+            data.put("message", "解析完成,请确认数据准确性");
+            return Result.success(data);
+        } catch (Exception e) {
+            return Result.error("解析失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "确认并保存 DAN 报告(Phase 2)")
+    @PostMapping("/report/confirm")
+    public Result<String> confirmReport(@RequestBody Map<String, Object> body) {
+        Long draftId = body.get("draftId") != null ? Long.valueOf(body.get("draftId").toString()) : null;
+        String childName = (String) body.get("childName");
+        String assessmentDateStr = (String) body.get("assessmentDate");
+        String dimension = (String) body.get("dimension");
+        String structuredAnalysis = (String) body.get("structuredAnalysis");
+        Long memberId = body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+
+        if (draftId == null || memberId == null) {
+            return Result.error("draftId 和 memberId 不能为空");
+        }
+
+        DanAssessmentResult draft = danAssessmentResultMapper.selectById(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在");
+        }
+
+        // 更新草稿信息
+        if (childName != null && !childName.isEmpty()) {
+            draft.setChildName(childName);
+        }
+        if (assessmentDateStr != null && !assessmentDateStr.isEmpty()) {
+            try {
+                draft.setAssessmentDate(new java.text.SimpleDateFormat("yyyy-MM-dd").parse(assessmentDateStr));
+            } catch (Exception ignored) {}
+        }
+        if (structuredAnalysis != null && !structuredAnalysis.isEmpty()) {
+            draft.setStructuredAnalysis(structuredAnalysis);
+        }
+        if (dimension != null) {
+            draft.setDimension(dimension);
+        }
+        draft.setDraftStatus("confirmed");
+        draft.setUpdatedAt(new java.util.Date());
+        danAssessmentResultMapper.updateById(draft);
+
+        // 更新维度分数
+        dimensionScoreService.refreshFromDanAssessment(memberId, draftId,
+                dimension != null ? dimension : draft.getDimension());
+
+        return Result.success("报告已确认并保存");
+    }
+
+    @Operation(summary = "获取某孩子某维度的 DAN 报告列表")
+    @PostMapping("/report/child")
+    public Result<List<DanAssessmentResult>> getChildDanReports(@RequestBody Map<String, Object> body) {
+        Long memberId = body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+        String dimension = (String) body.get("dimension");
+        if (memberId == null || dimension == null) {
+            return Result.error("memberId 和 dimension 不能为空");
+        }
+        List<DanAssessmentResult> list = danAssessmentResultMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getDimension, dimension)
+                        .eq(DanAssessmentResult::getDraftStatus, "confirmed")
+                        .orderByDesc(DanAssessmentResult::getCreatedAt));
+        return Result.success(list);
+    }
+
+    /**
+     * 将 DanParsedReport 序列化为 JSON 字符串
+     */
+    private String parsedToJson(DanParsedReport parsed) {
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(parsed);
+        } catch (Exception e) {
+            // fallback: 手动构建简单 JSON
+            StringBuilder sb = new StringBuilder();
+            sb.append("{\"reportType\":\"").append(parsed.getReportType()).append("\",");
+            sb.append("\"dimension\":\"").append(parsed.getDimension()).append("\",");
+            sb.append("\"items\":[");
+            if (parsed.getItems() != null) {
+                boolean first = true;
+                for (DanReportParseService.DataItem item : parsed.getItems()) {
+                    if (!first) sb.append(",");
+                    sb.append("{\"code\":\"").append(item.getCode()).append("\",");
+                    sb.append("\"name\":\"").append(item.getName()).append("\",");
+                    sb.append("\"value\":\"").append(item.getValue()).append("\",");
+                    sb.append("\"category\":\"").append(item.getCategory()).append("\"}");
+                    first = false;
+                }
+            }
+            sb.append("],");
+            sb.append("\"summary\":\"").append(parsed.getSummary() != null ? parsed.getSummary().replace("\"", "\\\"") : "").append("\",");
+            sb.append("\"suggestions\":\"").append(parsed.getSuggestions() != null ? parsed.getSuggestions().replace("\"", "\\\"") : "").append("\"}");
+            return sb.toString();
+        }
+    }
 }

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java

@@ -12,4 +12,12 @@ public interface DimensionScoreService {
     List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit);
 
     void refreshFromGutReport(Long memberId, Long reportId);
+
+    /**
+     * 根据 DAN 测评报告更新维度分数
+     * @param memberId 家庭成员ID
+     * @param resultId DanAssessmentResult ID
+     * @param dimension 维度: mind / wisdom
+     */
+    void refreshFromDanAssessment(Long memberId, Long resultId, String dimension);
 }

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -17,7 +17,10 @@ import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.service.DimensionEnergySyncService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
@@ -27,6 +30,7 @@ import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class DimensionScoreServiceImpl implements DimensionScoreService {
 
@@ -49,6 +53,9 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
     @Resource
     private HealthReportService healthReportService;
 
@@ -241,6 +248,88 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         return items;
     }
 
+    @Override
+    @Transactional
+    public void refreshFromDanAssessment(Long memberId, Long resultId, String dimension) {
+        DanAssessmentResult result = danAssessmentResultMapper.selectById(resultId);
+        if (result == null) return;
+
+        // 从 structured_analysis JSON 中提取分数
+        String analysisJson = result.getStructuredAnalysis();
+        if (analysisJson == null || analysisJson.isEmpty()) return;
+
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+            java.util.Map<String, Object> analysis = mapper.readValue(analysisJson,
+                    new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, Object>>() {});
+
+            @SuppressWarnings("unchecked")
+            java.util.List<java.util.Map<String, Object>> items =
+                    (java.util.List<java.util.Map<String, Object>>) analysis.getOrDefault("items", new java.util.ArrayList<>());
+
+            if (items.isEmpty()) return;
+
+            // 按 category 分组求平均,更新对应维度
+            java.util.Map<String, java.util.List<Integer>> categoryScores = new java.util.LinkedHashMap<>();
+            for (java.util.Map<String, Object> item : items) {
+                String category = (String) item.getOrDefault("category", "general");
+                String valueStr = (String) item.get("value");
+                if (valueStr != null) {
+                    try {
+                        int val = Integer.parseInt(valueStr.replaceAll("[^0-9]", ""));
+                        categoryScores.computeIfAbsent(category, k -> new java.util.ArrayList<>()).add(val);
+                    } catch (NumberFormatException ignored) {}
+                }
+            }
+
+            if (categoryScores.isEmpty()) return;
+
+            Date assessDate = result.getAssessmentDate() != null
+                    ? result.getAssessmentDate() : new java.sql.Date(System.currentTimeMillis());
+
+            // Mind 维度映射
+            java.util.Map<String, String> dimMapping = new java.util.LinkedHashMap<>();
+            if ("mind".equals(dimension)) {
+                dimMapping.put("bigFive", "emotion");
+                dimMapping.put("emotion", "emotion");
+                dimMapping.put("social", "emotion");
+                dimMapping.put("ability", "emotion");
+                dimMapping.put("auto", "emotion");
+                dimMapping.put("general", "emotion");
+            } else if ("wisdom".equals(dimension)) {
+                dimMapping.put("selfConcept", "cognitive");
+                dimMapping.put("growthMindset", "cognitive");
+                dimMapping.put("selfDriving", "cognitive");
+                dimMapping.put("auto", "cognitive");
+                dimMapping.put("general", "cognitive");
+            }
+
+            for (java.util.Map.Entry<String, java.util.List<Integer>> entry : categoryScores.entrySet()) {
+                String category = entry.getKey();
+                java.util.List<Integer> scores = entry.getValue();
+                int avg = (int) scores.stream().mapToInt(v -> v).average().orElse(0);
+                String targetDim = dimMapping.getOrDefault(category, "emotion");
+
+                HealthDimensionScore score = new HealthDimensionScore();
+                score.setMemberId(memberId);
+                score.setDimension(targetDim);
+                score.setScore(Math.min(100, Math.max(0, avg)));
+                score.setPercentile(calcPercentile(targetDim, avg));
+                score.setDataSource("REPORT");
+                score.setTier(1);
+                score.setAssessDate(assessDate);
+                java.util.Calendar cal = java.util.Calendar.getInstance();
+                cal.setTime(assessDate);
+                cal.add(java.util.Calendar.MONTH, 6);
+                score.setExpireDate(cal.getTime());
+                score.setRawData("{\"resultId\":" + resultId + ",\"category\":\"" + category + "\"}");
+                scoreService.saveScore(score);
+            }
+        } catch (Exception e) {
+            log.warn("Failed to parse structured_analysis for resultId={}: {}", resultId, e.getMessage());
+        }
+    }
+
     @Override
     @Transactional
     public void refreshFromGutReport(Long memberId, Long reportId) {