浏览代码

feat(dan-assessment): 将DAN报告上传重构到独立 dan_report_uploads 表

- 新增 dan_report_uploads 表及实体/Mapper/Service/Controller(Phase 2-3)
- DanAssessmentController 的 /report/parse-preview 和 /report/confirm 委托给 DanReportUploadService(Phase 4-5)
- dan_assessment_results.source_report_id 关联 dan_report_uploads.id
- dan_assessment_results.analysisStatus 新增字段(待规划师确认分析)
- 迁移95: 历史 dan_assessment_results 数据 ETL 回填到 dan_report_uploads
- 迁移93-94: dan_report_uploads 建表 + 扩展字段 source_report_id/analysisStatus
- schema.sql 同步新建表定义
- 合并远程并行迁移(dan_assessment_executions 等表)
Sisyphus 3 月之前
父节点
当前提交
1807643cf2

+ 115 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5288,6 +5288,9 @@ try {
         runMigration90();
         runMigration91();
         runMigration92();
+        runMigration93();
+        runMigration94();
+        runMigration95();
     }
 
     private void runMigration81() {
@@ -5958,4 +5961,116 @@ try {
             log.warn("DAN佣金配置种子数据可能已存在: " + e.getMessage());
         }
     }
+
+    private void runMigration93() {
+        // 迁移 93: 创建 dan_report_uploads 表(DAN PDF报告独立化)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_report_uploads (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+                    "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+                    "uploader_user_id BIGINT NOT NULL COMMENT '上传者用户ID', " +
+                    "dimension VARCHAR(16) NOT NULL COMMENT '维度: mind/wisdom', " +
+                    "report_type VARCHAR(16) COMMENT 'A2/B4', " +
+                    "file_url VARCHAR(500) NOT NULL COMMENT 'PDF文件URL', " +
+                    "file_id VARCHAR(100) COMMENT '媒资文件ID', " +
+                    "file_name VARCHAR(200) COMMENT '原始文件名', " +
+                    "file_size BIGINT COMMENT '文件大小(字节)', " +
+                    "uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "parsed_items JSON COMMENT '解析的指标项', " +
+                    "parsed_extra JSON COMMENT '解析的额外信息', " +
+                    "parsed_summary TEXT COMMENT '解析的总结文本', " +
+                    "parsed_suggestions TEXT COMMENT '解析的建议文本', " +
+                    "parser_version VARCHAR(32) COMMENT '解析器版本', " +
+                    "parse_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/success/failed', " +
+                    "parse_error VARCHAR(500) COMMENT '解析失败原因', " +
+                    "parsed_at DATETIME COMMENT '解析完成时间', " +
+                    "draft_status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/confirmed/discarded', " +
+                    "confirmed_at DATETIME COMMENT '确认时间', " +
+                    "confirmed_by BIGINT COMMENT '确认操作人用户ID', " +
+                    "edited_items JSON COMMENT '编辑后的指标项', " +
+                    "edited_extra JSON COMMENT '编辑后的额外信息', " +
+                    "edited_summary TEXT COMMENT '编辑后的总结', " +
+                    "edited_suggestions TEXT COMMENT '编辑后的建议', " +
+                    "edit_count INT DEFAULT 0 COMMENT '编辑次数', " +
+                    "linked_result_id BIGINT COMMENT '关联 dan_assessment_results.id', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "deleted_at DATETIME COMMENT '软删除时间', " +
+                    "INDEX idx_family_id (family_id), " +
+                    "INDEX idx_member_id (family_member_id), " +
+                    "INDEX idx_draft_status (draft_status), " +
+                    "INDEX idx_dimension (dimension), " +
+                    "INDEX idx_parse_status (parse_status), " +
+                    "INDEX idx_linked_result (linked_result_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评PDF报告独立记录'");
+            log.info("已创建dan_report_uploads表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+    }
+
+    private void runMigration94() {
+        // 迁移 94: dan_assessment_results表添加独立化扩展字段(关联新表)
+        ensureColumn("dan_assessment_results", "source_report_id", "BIGINT DEFAULT NULL COMMENT '关联 dan_report_uploads.id'");
+        ensureColumn("dan_assessment_results", "analysis_status", "VARCHAR(16) DEFAULT 'pending' COMMENT '分析状态: pending/confirmed'");
+        log.info("已添加独立化扩展列到dan_assessment_results表");
+    }
+
+    private void runMigration95() {
+        // 迁移 95: 历史 dan_assessment_results 数据 ETL 回填到 dan_report_uploads
+        // 条件: source='parent_upload' AND source_report_id IS NULL(真正的历史遗留数据)
+        // 注意: 新流程的记录通过 confirmUpload 已正确设置 source_report_id,无需处理
+        try {
+            // Step 1: 插入 dan_report_uploads 记录(来自 dan_assessment_results 的历史数据)
+            jdbcTemplate.execute("INSERT IGNORE INTO dan_report_uploads " +
+                    "(family_id, family_member_id, uploader_user_id, dimension, report_type, " +
+                    "file_url, file_id, draft_status, confirmed_at, confirmed_by, " +
+                    "edited_items, edited_summary, edited_suggestions, linked_result_id, " +
+                    "parsed_status, parse_status, created_at, updated_at) " +
+                    "SELECT " +
+                    "  fm.family_id, " +
+                    "  r.family_member_id, " +
+                    "  r.teacher_id, " +
+                    "  r.dimension, " +
+                    "  r.source, " +
+                    "  COALESCE(r.report_file_id, ''), " +
+                    "  r.report_file_id, " +
+                    "  'confirmed', " +
+                    "  r.created_at, " +
+                    "  r.teacher_id, " +
+                    "  r.structured_analysis, " +
+                    "  r.analysis_report, " +
+                    "  r.growth_suggestions, " +
+                    "  r.id, " +
+                    "  'success', " +
+                    "  'confirmed', " +
+                    "  r.created_at, " +
+                    "  r.updated_at " +
+                    "FROM dan_assessment_results r " +
+                    "LEFT JOIN family_members fm ON fm.id = r.family_member_id " +
+                    "WHERE r.source = 'parent_upload' " +
+                    "  AND r.source_report_id IS NULL " +
+                    "  AND r.draft_status = 'confirmed' " +
+                    "  AND NOT EXISTS (SELECT 1 FROM dan_report_uploads u WHERE u.linked_result_id = r.id)"
+            );
+            log.info("迁移95 Step1: 已将历史 dan_assessment_results 回填到 dan_report_uploads");
+        } catch (Exception e) {
+            log.error("迁移95 Step1 失败: " + e.getMessage());
+        }
+
+        // Step 2: 回填 dan_assessment_results.source_report_id(关联新建的 dan_report_uploads 记录)
+        try {
+            jdbcTemplate.execute("UPDATE dan_assessment_results r " +
+                    "INNER JOIN dan_report_uploads u ON u.linked_result_id = r.id " +
+                    "SET r.source_report_id = u.id " +
+                    "WHERE r.source = 'parent_upload' " +
+                    "  AND r.source_report_id IS NULL " +
+                    "  AND r.draft_status = 'confirmed'"
+            );
+            log.info("迁移95 Step2: 已回填 dan_assessment_results.source_report_id");
+        } catch (Exception e) {
+            log.error("迁移95 Step2 失败: " + e.getMessage());
+        }
+    }
 }

+ 20 - 83
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -39,6 +39,9 @@ public class DanAssessmentController {
     @Resource
     private DimensionScoreService dimensionScoreService;
 
+    @Resource
+    private DanReportUploadService danReportUploadService;
+
     // ===== 测评材料 =====
 
 @Operation(summary = "获取当前测评材料")
@@ -282,102 +285,36 @@ public class DanAssessmentController {
 
     // ===== DAN 报告上传与解析 =====
 
-    @Operation(summary = "上传 DAN 报告并预览解析结果(Phase 1)")
+    @Operation(summary = "上传 DAN 报告并预览解析结果(Phase 1)— 已迁移到 dan_report_uploads 表")
     @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());
-        }
+        Result<Map<String, Object>> r = danReportUploadService.uploadAndParse(userId, memberId, dimension, file);
+        if (r.getCode() == null || r.getCode() != 200 || r.getData() == null) return r;
+        // 兼容旧字段名 draftId → uploadId
+        Object uploadId = r.getData().get("uploadId");
+        r.getData().put("draftId", uploadId);
+        return r;
     }
 
-    @Operation(summary = "确认并保存 DAN 报告(Phase 2)")
+    @Operation(summary = "确认并保存 DAN 报告(Phase 2)— 已迁移到 dan_report_uploads 表")
     @PostMapping("/report/confirm")
-    public Result<String> confirmReport(@RequestBody Map<String, Object> body) {
+    public Result<String> confirmReport(@RequestBody Map<String, Object> body,
+                                         @RequestAttribute("userId") Long userId) {
         Long draftId = body.get("draftId") != null ? Long.valueOf(body.get("draftId").toString()) : null;
+        if (draftId == null) {
+            return Result.error("draftId 不能为空");
+        }
         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);
+        Result<?> r = danReportUploadService.confirmUpload(draftId, userId, childName, assessmentDateStr);
+        if (r.getCode() != null && r.getCode() == 200) {
+            return Result.success("报告已确认并保存");
         }
-        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("报告已确认并保存");
+        return Result.error(r.getCode() == null ? 500 : r.getCode(), r.getMessage());
     }
 
     @Operation(summary = "获取某孩子某维度的 DAN 报告列表")

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.DanReportEditDTO;
+import com.etotem.cfc.dto.DanReportUploadVO;
+import com.etotem.cfc.service.DanReportUploadService;
+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.List;
+import java.util.Map;
+
+@Tag(name = "DAN报告管理(独立化)", description = "DAN PDF报告上传/解析/确认独立流程")
+@RestController
+@RequestMapping("/api/dan-report")
+public class DanReportController {
+
+    @Resource
+    private DanReportUploadService uploadService;
+
+    // Phase 1: 上传并预览 PDF
+    @PostMapping("/upload")
+    public Result<Map<String, Object>> upload(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("dimension") String dimension,
+            @RequestParam("memberId") Long memberId,
+            @RequestAttribute("userId") Long userId) {
+        return uploadService.uploadAndParse(userId, memberId, dimension, file);
+    }
+
+    // 编辑草稿
+    @PostMapping("/{id}/edit")
+    public Result<DanReportUploadVO> edit(@PathVariable("id") Long id,
+                                           @RequestBody DanReportEditDTO edit,
+                                           @RequestAttribute("userId") Long userId) {
+        return uploadService.editDraft(id, userId, edit);
+    }
+
+    // Phase 2: 确认入库
+    @PostMapping("/{id}/confirm")
+    public Result<DanReportUploadVO> confirm(@PathVariable("id") Long id,
+                                              @RequestBody Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        String childName = (String) body.get("childName");
+        String assessmentDate = (String) body.get("assessmentDate");
+        return uploadService.confirmUpload(id, userId, childName, assessmentDate);
+    }
+
+    @PostMapping("/{id}/discard")
+    public Result<Void> discard(@PathVariable("id") Long id,
+                                @RequestAttribute("userId") Long userId) {
+        return uploadService.discardUpload(id, userId);
+    }
+
+    @PostMapping("/{id}/complete-analysis")
+    public Result<Void> completeAnalysis(@PathVariable("id") Long id,
+                                         @RequestAttribute("userId") Long userId) {
+        return uploadService.completeAnalysis(id, userId);
+    }
+
+    @PostMapping("/{id}")
+    public Result<DanReportUploadVO> detail(@PathVariable("id") Long id) {
+        return uploadService.getDetail(id);
+    }
+
+    @PostMapping("/list")
+    public Result<List<DanReportUploadVO>> list(@RequestBody Map<String, Object> body) {
+        Long memberId = body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+        String dimension = (String) body.get("dimension");
+        return uploadService.listByMember(memberId, dimension);
+    }
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportEditDTO.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class DanReportEditDTO {
+    private String itemsJson;
+    private String extraJson;
+    private String summary;
+    private String suggestions;
+}

+ 59 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportUploadVO.java

@@ -0,0 +1,59 @@
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.entity.DanReportUpload;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class DanReportUploadVO {
+    private Long id;
+    private Long familyId;
+    private Long familyMemberId;
+    private Long uploaderUserId;
+    private String dimension;
+    private String reportType;
+    private String fileUrl;
+    private String fileId;
+    private String fileName;
+    private Long fileSize;
+    private Date uploadedAt;
+    private String parserVersion;
+    private String parseStatus;
+    private String parseError;
+    private Date parsedAt;
+    private String draftStatus;
+    private Date confirmedAt;
+    private Long confirmedBy;
+    private Integer editCount;
+    private Long linkedResultId;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static DanReportUploadVO fromEntity(DanReportUpload e) {
+        if (e == null) return null;
+        DanReportUploadVO v = new DanReportUploadVO();
+        v.setId(e.getId());
+        v.setFamilyId(e.getFamilyId());
+        v.setFamilyMemberId(e.getFamilyMemberId());
+        v.setUploaderUserId(e.getUploaderUserId());
+        v.setDimension(e.getDimension());
+        v.setReportType(e.getReportType());
+        v.setFileUrl(e.getFileUrl());
+        v.setFileId(e.getFileId());
+        v.setFileName(e.getFileName());
+        v.setFileSize(e.getFileSize());
+        v.setUploadedAt(e.getUploadedAt());
+        v.setParserVersion(e.getParserVersion());
+        v.setParseStatus(e.getParseStatus());
+        v.setParseError(e.getParseError());
+        v.setParsedAt(e.getParsedAt());
+        v.setDraftStatus(e.getDraftStatus());
+        v.setConfirmedAt(e.getConfirmedAt());
+        v.setConfirmedBy(e.getConfirmedBy());
+        v.setEditCount(e.getEditCount());
+        v.setLinkedResultId(e.getLinkedResultId());
+        v.setCreatedAt(e.getCreatedAt());
+        v.setUpdatedAt(e.getUpdatedAt());
+        return v;
+    }
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanAssessmentResult.java

@@ -132,4 +132,8 @@ public class DanAssessmentResult implements Serializable {
     private String dimension; // mind(心-A2)/wisdom(智-B4)
     private String reportFileId; // 上传文件媒资ID
     private String draftStatus; // pending(待确认)/confirmed(已确认)
+
+    // === 独立化扩展字段(迁移93新增) ===
+    private Long sourceReportId; // 关联 dan_report_uploads.id
+    private String analysisStatus; // pending(待规划师确认)/confirmed(已确认)
 }

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanReportUpload.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.TableField;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("dan_report_uploads")
+public class DanReportUpload implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+    private Long familyMemberId;
+    private Long uploaderUserId;
+    private String dimension;
+    private String reportType;
+
+    private String fileUrl;
+    private String fileId;
+    private String fileName;
+    private Long fileSize;
+    private Date uploadedAt;
+
+    @TableField("parsed_items")
+    private String parsedItems;
+
+    @TableField("parsed_extra")
+    private String parsedExtra;
+
+    @TableField("parsed_summary")
+    private String parsedSummary;
+
+    @TableField("parsed_suggestions")
+    private String parsedSuggestions;
+
+    private String parserVersion;
+    private String parseStatus;
+    private String parseError;
+    private Date parsedAt;
+
+    private String draftStatus;
+    private Date confirmedAt;
+    private Long confirmedBy;
+
+    @TableField("edited_items")
+    private String editedItems;
+
+    @TableField("edited_extra")
+    private String editedExtra;
+
+    @TableField("edited_summary")
+    private String editedSummary;
+
+    @TableField("edited_suggestions")
+    private String editedSuggestions;
+
+    private Integer editCount;
+    private Long linkedResultId;
+
+    private Date createdAt;
+    private Date updatedAt;
+    private Date deletedAt;
+
+    public static final String DRAFT_PENDING = "pending";
+    public static final String DRAFT_CONFIRMED = "confirmed";
+    public static final String DRAFT_DISCARDED = "discarded";
+
+    public static final String PARSE_PENDING = "pending";
+    public static final String PARSE_SUCCESS = "success";
+    public static final String PARSE_FAILED = "failed";
+
+    public static final String DIM_MIND = "mind";
+    public static final String DIM_WISDOM = "wisdom";
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DanReportUploadMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DanReportUpload;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DanReportUploadMapper extends BaseMapper<DanReportUpload> {
+}

+ 270 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -0,0 +1,270 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.DanReportEditDTO;
+import com.etotem.cfc.dto.DanReportUploadVO;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.DanReportUpload;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.DanReportUploadMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class DanReportUploadService {
+
+    private static final Logger log = LoggerFactory.getLogger(DanReportUploadService.class);
+
+    @Resource
+    private DanAssessmentResultMapper resultMapper;
+
+    @Resource
+    private DanReportUploadMapper uploadMapper;
+
+    @Resource
+    private DanReportParseService danReportParseService;
+
+    @Resource
+    private FileStorageService fileStorageService;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // Phase 1: 上传并预览 PDF
+    public Result<Map<String, Object>> uploadAndParse(Long userId, Long memberId,
+                                                       String dimension, MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+        if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.equals(dimension)) {
+            return Result.error("dimension 必须为 mind 或 wisdom");
+        }
+
+        // 1. 保存文件
+        String fileUrl = fileStorageService.upload(file, "dan-reports");
+        if (fileUrl == null) {
+            return Result.error("文件保存失败");
+        }
+
+        // 2. 创建草稿记录
+        DanReportUpload upload = new DanReportUpload();
+        upload.setFamilyMemberId(memberId);
+        upload.setUploaderUserId(userId);
+        upload.setDimension(dimension);
+        upload.setFileUrl(fileUrl);
+        upload.setFileId(fileUrl);  // 同义
+        upload.setFileName(file.getOriginalFilename());
+        upload.setFileSize(file.getSize());
+        upload.setUploadedAt(new Date());
+        upload.setDraftStatus(DanReportUpload.DRAFT_PENDING);
+        upload.setParseStatus(DanReportUpload.PARSE_PENDING);
+        upload.setEditCount(0);
+        upload.setCreatedAt(new Date());
+        upload.setUpdatedAt(new Date());
+        uploadMapper.insert(upload);
+
+        // 3. 同步解析
+        try {
+            DanReportParseService.DanParsedReport parsed =
+                    danReportParseService.parse(file.getInputStream(), dimension);
+            upload.setParserVersion("v1");
+            upload.setParseStatus(DanReportUpload.PARSE_SUCCESS);
+            upload.setParsedItems(objectMapper.writeValueAsString(parsed.getItems()));
+            upload.setParsedExtra(objectMapper.writeValueAsString(parsed.getExtra()));
+            upload.setParsedSummary(parsed.getSummary());
+            upload.setParsedSuggestions(parsed.getSuggestions());
+            upload.setReportType(parsed.getReportType());
+            upload.setParsedAt(new Date());
+            uploadMapper.updateById(upload);
+        } catch (IOException e) {
+            log.error("DAN PDF 解析失败", e);
+            upload.setParseStatus(DanReportUpload.PARSE_FAILED);
+            upload.setParseError(e.getMessage());
+            uploadMapper.updateById(upload);
+            return Result.error("PDF解析失败: " + e.getMessage());
+        } catch (Exception e) {
+            log.error("DAN PDF 解析异常", e);
+            upload.setParseStatus(DanReportUpload.PARSE_FAILED);
+            upload.setParseError(e.getMessage());
+            uploadMapper.updateById(upload);
+            return Result.error("PDF解析异常: " + e.getMessage());
+        }
+
+        // 4. 返回预览
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("uploadId", upload.getId());
+        data.put("dimension", dimension);
+        data.put("fileUrl", fileUrl);
+        data.put("itemsJson", upload.getParsedItems());
+        data.put("extraJson", upload.getParsedExtra());
+        data.put("summary", upload.getParsedSummary());
+        data.put("suggestions", upload.getParsedSuggestions());
+        data.put("message", "解析完成,请确认数据准确性");
+        return Result.success(data);
+    }
+
+    // 编辑草稿
+    public Result<DanReportUploadVO> editDraft(Long uploadId, Long userId, DanReportEditDTO edit) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null || upload.getDeletedAt() != null) {
+            return Result.error("上传记录不存在");
+        }
+        if (!DanReportUpload.DRAFT_PENDING.equals(upload.getDraftStatus())) {
+            return Result.error("只有 pending 状态的记录可以编辑");
+        }
+        if (edit.getItemsJson() != null) upload.setEditedItems(edit.getItemsJson());
+        if (edit.getExtraJson() != null) upload.setEditedExtra(edit.getExtraJson());
+        if (edit.getSummary() != null) upload.setEditedSummary(edit.getSummary());
+        if (edit.getSuggestions() != null) upload.setEditedSuggestions(edit.getSuggestions());
+        upload.setEditCount(upload.getEditCount() == null ? 1 : upload.getEditCount() + 1);
+        upload.setUpdatedAt(new Date());
+        uploadMapper.updateById(upload);
+        return Result.success(DanReportUploadVO.fromEntity(upload));
+    }
+
+    // Phase 2: 确认入库(创建 dan_assessment_results 关联记录)
+    @Transactional(rollbackFor = Exception.class)
+    public Result<DanReportUploadVO> confirmUpload(Long uploadId, Long userId,
+                                                    String childName, String assessmentDateStr) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null || upload.getDeletedAt() != null) {
+            return Result.error("上传记录不存在");
+        }
+        if (!DanReportUpload.DRAFT_PENDING.equals(upload.getDraftStatus())) {
+            return Result.error("此记录已" + upload.getDraftStatus());
+        }
+
+        // 创建数值结果记录
+        DanAssessmentResult result = new DanAssessmentResult();
+        result.setFamilyMemberId(upload.getFamilyMemberId());
+        result.setTeacherId(upload.getUploaderUserId());
+        result.setSource(DanAssessmentResult.SOURCE_PARENT_UPLOAD);
+        result.setSourceReportId(upload.getId());
+        result.setDimension(upload.getDimension());
+        result.setReportFileId(upload.getFileUrl());
+        result.setDraftStatus("confirmed");
+        result.setAnalysisStatus("pending");
+        result.setStatus("completed");
+        result.setAssessmentDate(new Date());
+        result.setChildName(childName);
+        if (assessmentDateStr != null && !assessmentDateStr.isEmpty()) {
+            try {
+                result.setAssessmentDate(new SimpleDateFormat("yyyy-MM-dd").parse(assessmentDateStr));
+            } catch (Exception ignored) {}
+        }
+        // 优先使用 edited_*,否则 parsed_*
+        result.setStructuredAnalysis(upload.getEditedItems() != null
+                ? upload.getEditedItems()
+                : upload.getParsedItems());
+        result.setAnalysisReport(upload.getEditedSummary() != null
+                ? upload.getEditedSummary()
+                : upload.getParsedSummary());
+        result.setGrowthSuggestions(upload.getEditedSuggestions() != null
+                ? upload.getEditedSuggestions()
+                : upload.getParsedSuggestions());
+        result.setCreatedAt(new Date());
+        result.setUpdatedAt(new Date());
+        resultMapper.insert(result);
+
+        // 更新上传记录
+        upload.setDraftStatus(DanReportUpload.DRAFT_CONFIRMED);
+        upload.setConfirmedAt(new Date());
+        upload.setConfirmedBy(userId);
+        upload.setLinkedResultId(result.getId());
+        upload.setUpdatedAt(new Date());
+        uploadMapper.updateById(upload);
+
+        // 刷新五维能量
+        try {
+            dimensionScoreService.refreshFromDanAssessment(
+                    upload.getFamilyMemberId(), result.getId(), upload.getDimension());
+        } catch (Exception ignored) {}
+
+        return Result.success(DanReportUploadVO.fromEntity(upload));
+    }
+
+    // 规划师完成分析
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Void> completeAnalysis(Long uploadId, Long teacherId) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null) {
+            return Result.error("记录不存在");
+        }
+        if (upload.getLinkedResultId() == null) {
+            return Result.error("未关联数值结果");
+        }
+        DanAssessmentResult result = resultMapper.selectById(upload.getLinkedResultId());
+        if (result == null) {
+            return Result.error("数值结果不存在");
+        }
+        if (!"pending".equals(result.getAnalysisStatus())) {
+            return Result.error("已经确认");
+        }
+        result.setAnalysisStatus("confirmed");
+        result.setUpdatedAt(new Date());
+        resultMapper.updateById(result);
+
+        // 二次刷新五维能量
+        try {
+            dimensionScoreService.refreshFromDanAssessment(
+                    upload.getFamilyMemberId(), result.getId(), upload.getDimension());
+        } catch (Exception ignored) {}
+
+        return Result.success();
+    }
+
+    // 列表查询
+    public Result<List<DanReportUploadVO>> listByMember(Long memberId, String dimension) {
+        LambdaQueryWrapper<DanReportUpload> q = new LambdaQueryWrapper<>();
+        q.eq(DanReportUpload::getFamilyMemberId, memberId)
+                .isNull(DanReportUpload::getDeletedAt)
+                .orderByDesc(DanReportUpload::getUploadedAt);
+        if (dimension != null && !dimension.isEmpty()) {
+            q.eq(DanReportUpload::getDimension, dimension);
+        }
+        List<DanReportUpload> list = uploadMapper.selectList(q);
+        java.util.List<DanReportUploadVO> result = new java.util.ArrayList<>();
+        for (DanReportUpload u : list) {
+            result.add(DanReportUploadVO.fromEntity(u));
+        }
+        return Result.success(result);
+    }
+
+    // 丢弃草稿
+    public Result<Void> discardUpload(Long uploadId, Long userId) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null) {
+            return Result.error("记录不存在");
+        }
+        upload.setDraftStatus(DanReportUpload.DRAFT_DISCARDED);
+        upload.setUpdatedAt(new Date());
+        uploadMapper.updateById(upload);
+        return Result.success();
+    }
+
+    // 详情
+    public Result<DanReportUploadVO> getDetail(Long uploadId) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null || upload.getDeletedAt() != null) {
+            return Result.error("记录不存在");
+        }
+        return Result.success(DanReportUploadVO.fromEntity(upload));
+    }
+}

+ 46 - 0
cfc-backend/src/main/resources/schema.sql

@@ -420,6 +420,8 @@ CREATE TABLE IF NOT EXISTS dan_assessment_results (
     dimension VARCHAR(10) DEFAULT 'mind' COMMENT '报告维度: mind(心-A2)/wisdom(智-B4)',
     report_file_id VARCHAR(100) COMMENT '上传文件媒资ID',
     draft_status VARCHAR(10) DEFAULT 'pending' COMMENT '草稿状态: pending(待确认)/confirmed(已确认)',
+    source_report_id BIGINT DEFAULT NULL COMMENT '关联 dan_report_uploads.id(DAN报告独立化扩展)',
+    analysis_status VARCHAR(16) DEFAULT 'pending' COMMENT '分析状态: pending/confirmed',
     INDEX idx_child_id (child_id),
     INDEX idx_teacher_id (teacher_id),
     INDEX idx_assessment_date (assessment_date)
@@ -3969,3 +3971,47 @@ CREATE TABLE IF NOT EXISTS `service_settlements` (
     KEY `idx_provider_id` (`provider_id`),
     KEY `idx_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务结算记录';
+-- =============================================
+-- DAN报告上传独立化(拆分 PDF 报告生命周期与数值结果)
+-- =============================================
+
+-- DAN PDF 报告上传/解析/确认记录表
+CREATE TABLE IF NOT EXISTS dan_report_uploads (
+    id                   BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id            BIGINT       NOT NULL COMMENT '家庭ID',
+    family_member_id     BIGINT       NOT NULL COMMENT '家庭成员ID',
+    uploader_user_id     BIGINT       NOT NULL COMMENT '上传者用户ID',
+    dimension            VARCHAR(16)  NOT NULL COMMENT '维度: mind(心-A2)/wisdom(智-B4)',
+    report_type          VARCHAR(16)  COMMENT 'A2/B4 报告类型(解析后确认)',
+    file_url             VARCHAR(500) NOT NULL COMMENT 'PDF 文件URL',
+    file_id              VARCHAR(100) COMMENT '媒资文件ID',
+    file_name            VARCHAR(200) COMMENT '原始文件名',
+    file_size            BIGINT       COMMENT '文件大小(字节)',
+    uploaded_at          DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+    parsed_items         JSON         COMMENT '解析的指标项 [{code, name, value, category}]',
+    parsed_extra         JSON         COMMENT '解析的额外信息(姓名/性别/年龄/报告日期等)',
+    parsed_summary       TEXT         COMMENT '解析的总结文本',
+    parsed_suggestions   TEXT         COMMENT '解析的建议文本',
+    parser_version       VARCHAR(32)  COMMENT '解析器版本',
+    parse_status         VARCHAR(16)  DEFAULT 'pending' COMMENT 'pending/success/failed',
+    parse_error          VARCHAR(500) COMMENT '解析失败原因',
+    parsed_at            DATETIME     COMMENT '解析完成时间',
+    draft_status         VARCHAR(16)  DEFAULT 'pending' COMMENT 'pending/confirmed/discarded',
+    confirmed_at         DATETIME     COMMENT '确认时间',
+    confirmed_by         BIGINT       COMMENT '确认操作人用户ID',
+    edited_items         JSON         COMMENT '编辑后的指标项(覆盖 parsed_items)',
+    edited_extra         JSON         COMMENT '编辑后的额外信息',
+    edited_summary       TEXT         COMMENT '编辑后的总结',
+    edited_suggestions   TEXT         COMMENT '编辑后的建议',
+    edit_count           INT          DEFAULT 0 COMMENT '编辑次数',
+    linked_result_id     BIGINT       COMMENT '关联 dan_assessment_results.id',
+    created_at           DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    updated_at           DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    deleted_at           DATETIME     COMMENT '软删除时间',
+    INDEX idx_family_id (family_id),
+    INDEX idx_member_id (family_member_id),
+    INDEX idx_draft_status (draft_status),
+    INDEX idx_dimension (dimension),
+    INDEX idx_parse_status (parse_status),
+    INDEX idx_linked_result (linked_result_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评PDF报告上传/解析/确认记录';