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

feat: add report parsing service and growth record service with dedup

Add ReportParseService for PDFBox-based DAN report parsing and structured mapping from DanAssessmentResult. Rewrite GrowthRecordService with createFromAssessment(), createFromUpload(), createSupplement(), SHA256 dedup with null-date UUID prefix. Add auto-trigger in AssessmentService.recordResult() to auto-create growth record after assessment result is saved.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

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

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java

@@ -16,6 +16,9 @@ import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.FamilyAssessmentConfigMapper;
 import org.springframework.stereotype.Service;
 
+import com.etotem.cfc.dto.ParsedReport;
+import com.etotem.cfc.common.DuplicateGrowthRecordException;
+
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Calendar;
@@ -37,6 +40,12 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     @Resource
     private DanAssessmentResultMapper danAssessmentResultMapper;
 
+    @Resource
+    private GrowthRecordService growthRecordService;
+
+    @Resource
+    private ReportParseService reportParseService;
+
     public AssessmentMaterial getActiveMaterial() {
         LambdaQueryWrapper<AssessmentMaterial> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentMaterial::getStatus, "active");
@@ -189,6 +198,18 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
             result.setAssessmentDate(new Date());
         }
         danAssessmentResultMapper.insert(result);
+
+        // Auto-create growth record from this assessment result (non-fatal on failure)
+        try {
+            ParsedReport parsed = reportParseService.parseFromResult(result);
+            growthRecordService.createFromAssessment(result, parsed);
+        } catch (DuplicateGrowthRecordException e) {
+            // Already exists — silently skip
+        } catch (Exception e) {
+            // Log but don't fail the assessment save
+            System.err.println("Auto-growth-record creation failed for assessment " + result.getId() + ": " + e.getMessage());
+        }
+
         return result;
     }
 

+ 217 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecordService.java

@@ -1,18 +1,22 @@
 package com.etotem.cfc.service;
 
-import com.etotem.cfc.service.api.GrowthRecordServiceInterface;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.DuplicateGrowthRecordException;
+import com.etotem.cfc.dto.ParsedReport;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.GrowthRecord;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.GrowthRecordMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
-
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.List;
+import java.util.UUID;
 
 @Service
 public class GrowthRecordService {
@@ -23,24 +27,171 @@ public class GrowthRecordService {
     @Resource
     private DanAssessmentResultMapper danAssessmentResultMapper;
 
-    public GrowthRecord createRecord(GrowthRecord record) {
-        record.setStatus("pending");
+    /**
+     * Create a growth record from a platform-purchased assessment result.
+     * source_type = platform_purchase
+     */
+    public GrowthRecord createFromAssessment(DanAssessmentResult result, ParsedReport parsed) {
+        Date reportDate = parsed != null && parsed.getReportDate() != null
+                ? parsed.getReportDate() : result.getAssessmentDate();
+
+        GrowthRecord record = new GrowthRecord();
+        record.setChildId(result.getChildId());
+        record.setAssessmentId(result.getId());
+        record.setSourceType("platform_purchase");
+        record.setReportDate(reportDate);
+        record.setDanLevel(result.getDanLevel());
+        record.setOverallScore(result.getOverallScore());
+        record.setAssessmentSummary(result.getAnalysisReport());
+        record.setGrowthSuggestions(result.getGrowthSuggestions());
+        record.setStatus("completed");
+        record.setIsLatest(true);
+        record.setExternalSync(true);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        record.setDedupHash(computeDedupHash(result.getChildId(), reportDate, "platform_purchase", null));
+
+        checkDuplicateAndInsert(record);
+        return record;
+    }
+
+    /**
+     * Create a growth record from user-uploaded report.
+     * source_type = self_upload
+     */
+    public GrowthRecord createFromUpload(Long childId, Long parentId, ParsedReport parsed,
+                                          String sourceFileUrl, Date manualReportDate) {
+        Date reportDate = manualReportDate != null ? manualReportDate
+                : (parsed != null ? parsed.getReportDate() : null);
+
+        GrowthRecord record = new GrowthRecord();
+        record.setChildId(childId);
+        record.setParentId(parentId);
+        record.setSourceType("self_upload");
+        record.setSourceFileUrl(sourceFileUrl);
+        record.setReportDate(reportDate);
+        if (parsed != null) {
+            record.setDanLevel(parsed.getDanLevel());
+            record.setOverallScore(parsed.getOverallScore());
+            record.setAssessmentSummary(parsed.getAssessmentSummary());
+            record.setGrowthSuggestions(parsed.getGrowthSuggestions());
+        }
+        record.setStatus("completed");
+        record.setIsLatest(true);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        record.setDedupHash(computeDedupHash(childId, reportDate, "self_upload", null));
+
+        checkDuplicateAndInsert(record);
+        return record;
+    }
+
+    /**
+     * Create a supplement record linked to a parent record.
+     * source_type = supplement, parent_record_id = parentRecordId
+     */
+    public GrowthRecord createSupplement(Long parentRecordId, Long childId, Long parentId,
+                                          ParsedReport parsed, String sourceFileUrl, Date reportDate) {
+        GrowthRecord parent = growthRecordMapper.selectById(parentRecordId);
+        if (parent == null) {
+            throw new IllegalArgumentException("Parent growth record not found: " + parentRecordId);
+        }
+
+        GrowthRecord record = new GrowthRecord();
+        record.setChildId(childId);
+        record.setParentId(parentId);
+        record.setParentRecordId(parentRecordId);
+        record.setSourceType("supplement");
+        record.setSourceFileUrl(sourceFileUrl);
+        record.setReportDate(reportDate);
+        if (parsed != null) {
+            record.setDanLevel(parsed.getDanLevel());
+            record.setOverallScore(parsed.getOverallScore());
+            record.setAssessmentSummary(parsed.getAssessmentSummary());
+            record.setGrowthSuggestions(parsed.getGrowthSuggestions());
+        }
+        record.setStatus("completed");
+        record.setIsLatest(true);
         record.setCreatedAt(new Date());
         record.setUpdatedAt(new Date());
+        record.setDedupHash(computeDedupHash(childId, reportDate, "supplement", parentRecordId));
+
+        checkDuplicateAndInsert(record);
+        return record;
+    }
+
+    /**
+     * Check if a record with the given dedup parameters already exists.
+     */
+    public GrowthRecord checkDuplicate(Long childId, Date reportDate, String sourceType) {
+        if (reportDate == null) {
+            return null;
+        }
+        String hash = computeDedupHash(childId, reportDate, sourceType, null);
+        return growthRecordMapper.selectOne(new LambdaQueryWrapper<GrowthRecord>()
+                .eq(GrowthRecord::getDedupHash, hash)
+                .eq(GrowthRecord::getIsLatest, true)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * Mark existing records in the same dedup group as not latest.
+     */
+    public void markNotLatest(Long childId, Date reportDate, String sourceType) {
+        String hash = computeDedupHash(childId, reportDate, sourceType, null);
+        List<GrowthRecord> existing = growthRecordMapper.selectList(new LambdaQueryWrapper<GrowthRecord>()
+                .eq(GrowthRecord::getDedupHash, hash)
+                .eq(GrowthRecord::getIsLatest, true));
+        for (GrowthRecord rec : existing) {
+            rec.setIsLatest(false);
+            rec.setUpdatedAt(new Date());
+            growthRecordMapper.updateById(rec);
+        }
+    }
+
+    /**
+     * Get all supplement records linked to a parent record.
+     */
+    public List<GrowthRecord> getSupplements(Long recordId) {
+        return growthRecordMapper.selectList(new LambdaQueryWrapper<GrowthRecord>()
+                .eq(GrowthRecord::getParentRecordId, recordId)
+                .orderByDesc(GrowthRecord::getCreatedAt));
+    }
+
+    // ========== Original methods (enhanced) ==========
+
+    public GrowthRecord createRecord(GrowthRecord record) {
+        record.setStatus("pending");
+        if (record.getCreatedAt() == null) {
+            record.setCreatedAt(new Date());
+        }
+        if (record.getUpdatedAt() == null) {
+            record.setUpdatedAt(new Date());
+        }
+        if (record.getSourceType() == null) {
+            record.setSourceType("manual");
+        }
+        if (record.getIsLatest() == null) {
+            record.setIsLatest(true);
+        }
+        if (record.getDedupHash() == null && record.getChildId() != null && record.getReportDate() != null) {
+            record.setDedupHash(computeDedupHash(record.getChildId(), record.getReportDate(),
+                    record.getSourceType(), record.getParentRecordId()));
+        }
         growthRecordMapper.insert(record);
         return record;
     }
 
     public List<GrowthRecord> getRecordsByParent(Long parentId) {
         return growthRecordMapper.selectList(new LambdaQueryWrapper<GrowthRecord>()
-            .eq(GrowthRecord::getParentId, parentId)
-            .orderByDesc(GrowthRecord::getCreatedAt));
+                .eq(GrowthRecord::getParentId, parentId)
+                .orderByDesc(GrowthRecord::getCreatedAt));
     }
 
     public List<GrowthRecord> getRecordsByChild(Long childId) {
         return growthRecordMapper.selectList(new LambdaQueryWrapper<GrowthRecord>()
-            .eq(GrowthRecord::getChildId, childId)
-            .orderByDesc(GrowthRecord::getCreatedAt));
+                .eq(GrowthRecord::getChildId, childId)
+                .orderByDesc(GrowthRecord::getCreatedAt));
     }
 
     public GrowthRecord getRecordById(Long id) {
@@ -65,18 +216,71 @@ public class GrowthRecordService {
         record.setChildId(childId);
         record.setParentId(parentId);
         record.setAssessmentId(assessmentId);
+        record.setSourceType("platform_purchase");
         record.setRecordTitle("DAN测评报告 - " + (result.getAssessmentDate() != null ? result.getAssessmentDate().toString() : ""));
         record.setDanLevel(result.getDanLevel());
         record.setOverallScore(result.getOverallScore());
+        record.setReportDate(result.getAssessmentDate());
         record.setAssessmentSummary(result.getAnalysisReport() != null
-            ? result.getAnalysisReport().substring(0, Math.min(200, result.getAnalysisReport().length()))
-            : null);
+                ? result.getAnalysisReport().substring(0, Math.min(200, result.getAnalysisReport().length()))
+                : null);
         record.setGrowthSuggestions(result.getGrowthSuggestions());
         record.setExternalSync(true);
         record.setStatus("completed");
+        record.setIsLatest(true);
         record.setCreatedAt(new Date());
         record.setUpdatedAt(new Date());
-        growthRecordMapper.insert(record);
+        record.setDedupHash(computeDedupHash(childId, result.getAssessmentDate(), "platform_purchase", null));
+
+        checkDuplicateAndInsert(record);
         return record;
     }
+
+    // ========== Private helpers ==========
+
+    private String computeDedupHash(Long childId, Date reportDate, String sourceType, Long parentRecordId) {
+        if (reportDate == null) {
+            String uuid = UUID.randomUUID().toString();
+            String raw = "NO_DATE:" + uuid + "|" + childId + "|" + sourceType;
+            return sha256(raw);
+        }
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        String dateStr = sdf.format(reportDate);
+        StringBuilder sb = new StringBuilder();
+        sb.append(childId).append("|").append(dateStr).append("|").append(sourceType);
+        if (parentRecordId != null) {
+            sb.append("|parent:").append(parentRecordId);
+        }
+        return sha256(sb.toString());
+    }
+
+    private String sha256(String input) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hexString = new StringBuilder();
+            for (byte b : hash) {
+                String hex = Integer.toHexString(0xff & b);
+                if (hex.length() == 1) hexString.append('0');
+                hexString.append(hex);
+            }
+            return hexString.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 not available", e);
+        }
+    }
+
+    private void checkDuplicateAndInsert(GrowthRecord record) {
+        String hash = record.getDedupHash();
+        if (hash != null && !hash.startsWith("NO_DATE:")) {
+            GrowthRecord existing = growthRecordMapper.selectOne(new LambdaQueryWrapper<GrowthRecord>()
+                    .eq(GrowthRecord::getDedupHash, hash)
+                    .last("LIMIT 1"));
+            if (existing != null) {
+                throw new DuplicateGrowthRecordException("Growth record already exists with hash: " + hash
+                        + " (existing id: " + existing.getId() + ")");
+            }
+        }
+        growthRecordMapper.insert(record);
+    }
 }

+ 170 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportParseService.java

@@ -0,0 +1,170 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReport;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Slf4j
+@Service
+public class ReportParseService {
+
+    private static final Pattern DATE_PATTERN = Pattern.compile(
+            "(?:(?:测评|报告|评估)日期[::]\\s*)?(\\d{4})[-/年](\\d{1,2})[-/月](\\d{1,2})[日]?");
+    private static final Pattern DAN_LEVEL_PATTERN = Pattern.compile(
+            "(?:DAN|dan)[等级\\s]*[::]\\s*([A-D])");
+    private static final Pattern SCORE_PATTERN = Pattern.compile(
+            "(\\S+)[\\s得]*分[::]\\s*(\\d+)");
+
+    /**
+     * Parse structured DanAssessmentResult directly into ParsedReport.
+     */
+    public ParsedReport parseFromResult(DanAssessmentResult result) {
+        if (result == null) {
+            return ParsedReport.empty();
+        }
+        return ParsedReport.builder()
+                .reportDate(result.getAssessmentDate())
+                .danLevel(result.getDanLevel())
+                .overallScore(result.getOverallScore())
+                .attentionScore(result.getAttentionScore())
+                .focusScore(result.getFocusScore())
+                .memoryScore(result.getMemoryScore())
+                .logicScore(result.getLogicScore())
+                .perceptionScore(result.getPerceptionScore())
+                .spatialScore(result.getSpatialScore())
+                .processingSpeedScore(result.getProcessingSpeedScore())
+                .emotionScore(result.getEmotionScore())
+                .resilienceScore(result.getResilienceScore())
+                .assessmentSummary(result.getAnalysisReport())
+                .growthSuggestions(result.getGrowthSuggestions())
+                .build();
+    }
+
+    /**
+     * Parse a PDF report file and extract data via text extraction + regex.
+     * Returns ParsedReport.empty() if file cannot be read or parsed.
+     */
+    public ParsedReport parseReport(String localFilePath) {
+        File file = new File(localFilePath);
+        if (!file.exists() || !file.isFile()) {
+            log.warn("Report file not found or not a file: {}", localFilePath);
+            return ParsedReport.empty();
+        }
+
+        String text;
+        try (PDDocument document = Loader.loadPDF(file)) {
+            PDFTextStripper stripper = new PDFTextStripper();
+            text = stripper.getText(document);
+        } catch (IOException e) {
+            log.error("Failed to read PDF file: {}", localFilePath, e);
+            return ParsedReport.empty();
+        }
+
+        if (text == null || text.trim().isEmpty()) {
+            log.warn("PDF text extraction returned empty: {}", localFilePath);
+            return ParsedReport.empty();
+        }
+
+        ParsedReport.ParsedReportBuilder builder = ParsedReport.builder();
+        builder.rawText(text);
+
+        // Extract report date
+        Date reportDate = extractDate(text);
+        if (reportDate != null) {
+            builder.reportDate(reportDate);
+        }
+
+        // Extract DAN level
+        Matcher levelMatcher = DAN_LEVEL_PATTERN.matcher(text);
+        if (levelMatcher.find()) {
+            builder.danLevel(levelMatcher.group(1));
+        }
+
+        // Extract scores
+        Matcher scoreMatcher = SCORE_PATTERN.matcher(text);
+        while (scoreMatcher.find()) {
+            String fieldName = scoreMatcher.group(1);
+            Integer value = parseInt(scoreMatcher.group(2));
+            if (value == null) continue;
+            mapScoreField(builder, fieldName, value);
+        }
+
+        // Extract summary sections (between known markers)
+        extractSection(text, "测评总结", "成长建议", builder::assessmentSummary);
+        extractSection(text, "成长建议", "任务建议", builder::growthSuggestions);
+
+        return builder.build();
+    }
+
+    private Date extractDate(String text) {
+        Matcher matcher = DATE_PATTERN.matcher(text);
+        if (matcher.find()) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                String year = matcher.group(1);
+                String month = String.format("%02d", Integer.parseInt(matcher.group(2)));
+                String day = String.format("%02d", Integer.parseInt(matcher.group(3)));
+                return sdf.parse(year + "-" + month + "-" + day);
+            } catch (Exception e) {
+                log.warn("Failed to parse extracted date", e);
+            }
+        }
+        return null;
+    }
+
+    private Integer parseInt(String str) {
+        try {
+            return Integer.parseInt(str.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private void mapScoreField(ParsedReport.ParsedReportBuilder builder, String fieldName, Integer value) {
+        if (fieldName.contains("综合") || fieldName.contains("总体")) {
+            builder.overallScore(value);
+        } else if (fieldName.contains("注意力")) {
+            builder.attentionScore(value);
+        } else if (fieldName.contains("专注力")) {
+            builder.focusScore(value);
+        } else if (fieldName.contains("记忆力")) {
+            builder.memoryScore(value);
+        } else if (fieldName.contains("逻辑")) {
+            builder.logicScore(value);
+        } else if (fieldName.contains("感知")) {
+            builder.perceptionScore(value);
+        } else if (fieldName.contains("空间")) {
+            builder.spatialScore(value);
+        } else if (fieldName.contains("加工速度")) {
+            builder.processingSpeedScore(value);
+        } else if (fieldName.contains("情绪")) {
+            builder.emotionScore(value);
+        } else if (fieldName.contains("韧性") || fieldName.contains("心理韧性")) {
+            builder.resilienceScore(value);
+        }
+    }
+
+    private void extractSection(String text, String startMarker, String endMarker,
+                                java.util.function.Consumer<String> setter) {
+        int start = text.indexOf(startMarker);
+        if (start < 0) return;
+        start += startMarker.length();
+        int end = endMarker != null ? text.indexOf(endMarker, start) : -1;
+        String section = (end > start) ? text.substring(start, end).trim()
+                : text.substring(start).trim();
+        if (!section.isEmpty()) {
+            setter.accept(section);
+        }
+    }
+}