Эх сурвалжийг харах

feat: 活动商品统一管理后续三项任务完成

Xiaogang Liao 2 сар өмнө
parent
commit
03097787b7

+ 26 - 4
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -6097,9 +6097,31 @@ try {
                     "  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());
-        }
+        log.info("迁移95 Step2: 已回填 dan_assessment_results.source_report_id");
+    } catch (Exception e) {
+        log.error("迁移95 Step2 失败: " + e.getMessage());
+    }
+
+    // 迁移96: 创建活动订单评价表
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "order_id BIGINT NOT NULL COMMENT '订单ID', " +
+            "reviewer_id BIGINT NOT NULL COMMENT '评价人ID', " +
+            "rating INT COMMENT '总体评分', " +
+            "review_text TEXT COMMENT '评价内容', " +
+            "review_at DATETIME COMMENT '评价时间', " +
+            "organization_rating INT COMMENT '活动组织评分(0-5)', " +
+            "content_rating INT COMMENT '活动内容评分(0-5)', " +
+            "experience_rating INT COMMENT '活动体验评分(0-5)', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_order_id (order_id), " +
+            "INDEX idx_reviewer_id (reviewer_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表'");
+        log.info("已创建review_orders表");
+    } catch (Exception e) {
+        log.error("迁移96 失败: " + e.getMessage());
     }
 }
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReviewController.java

@@ -0,0 +1,53 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ReviewOrderRequest;
+import com.etotem.cfc.dto.ReviewOrderResponse;
+import com.etotem.cfc.dto.ReviewStatusResponse;
+import com.etotem.cfc.service.ReviewOrderService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/api/review")
+public class ReviewController {
+
+    @Resource
+    private ReviewOrderService reviewOrderService;
+
+    @PostMapping("/order")
+    public Result<String> reviewOrder(@RequestBody ReviewOrderRequest request,
+                                      @RequestAttribute("userId") Long userId) {
+        String result = reviewOrderService.createReview(request, userId);
+        return Result.success(result);
+    }
+
+    @PostMapping("/order/detail")
+    public Result<ReviewOrderResponse> getReviewOrderDetail(@RequestBody java.util.Map<String, Object> params) {
+        Long orderId = params != null && params.get("orderId") != null
+                ? Long.valueOf(params.get("orderId").toString())
+                : null;
+        if (orderId == null) {
+            return Result.error("orderId is required");
+        }
+        ReviewOrderResponse response = reviewOrderService.findByOrder(orderId);
+        return Result.success(response);
+    }
+
+    @PostMapping("/order/status")
+    public Result<ReviewStatusResponse> getReviewStatus(@RequestBody java.util.Map<String, Object> params) {
+        Long orderId = params != null && params.get("orderId") != null
+                ? Long.valueOf(params.get("orderId").toString())
+                : null;
+        if (orderId == null) {
+            return Result.error("orderId is required");
+        }
+        ReviewStatusResponse response = reviewOrderService.getStats(orderId);
+        return Result.success(response);
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReviewOrderRequest.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class ReviewOrderRequest {
+
+    private Long orderId;
+    private Integer rating;
+    private String reviewText;
+    private Integer organizationRating;
+    private Integer contentRating;
+    private Integer experienceRating;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReviewOrderResponse.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class ReviewOrderResponse {
+
+    private Long orderId;
+    private Long reviewerId;
+    private Integer rating;
+    private String reviewText;
+    private String reviewAt;
+    private Integer organizationRating;
+    private Integer contentRating;
+    private Integer experienceRating;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReviewStatusResponse.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class ReviewStatusResponse {
+
+    private Boolean reviewed;
+    private Integer rating;
+    private String reviewText;
+    private String reviewAt;
+    private Integer organizationRating;
+    private Integer contentRating;
+    private Integer experienceRating;
+    private Double organizationAvg;
+    private Double contentAvg;
+    private Double experienceAvg;
+    private Double overallAvg;
+    private Integer totalReviews;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReviewOrder.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("review_orders")
+public class ReviewOrder implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long orderId;
+    private Long reviewerId;
+    private Integer rating;
+    private String reviewText;
+    private Date reviewAt;
+    private Integer organizationRating;
+    private Integer contentRating;
+    private Integer experienceRating;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ReviewOrderMapper.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ReviewOrder;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Map;
+
+@Mapper
+public interface ReviewOrderMapper extends BaseMapper<ReviewOrder> {
+
+    ReviewOrder findByOrderId(@Param("orderId") Long orderId);
+
+    Map<String, Object> getStatsByOrderId(@Param("orderId") Long orderId);
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReviewOrderService.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.etotem.cfc.dto.ReviewOrderRequest;
+import com.etotem.cfc.dto.ReviewOrderResponse;
+import com.etotem.cfc.dto.ReviewStatusResponse;
+import com.etotem.cfc.entity.ReviewOrder;
+
+public interface ReviewOrderService extends IService<ReviewOrder> {
+
+    String createReview(ReviewOrderRequest request, Long reviewerId);
+
+    ReviewOrderResponse findByOrder(Long orderId);
+
+    ReviewStatusResponse getStats(Long orderId);
+}

+ 114 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReviewOrderServiceImpl.java

@@ -0,0 +1,114 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.dto.ReviewOrderRequest;
+import com.etotem.cfc.dto.ReviewOrderResponse;
+import com.etotem.cfc.dto.ReviewStatusResponse;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ReviewOrder;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.ReviewOrderMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class ReviewOrderServiceImpl extends ServiceImpl<ReviewOrderMapper, ReviewOrder> implements ReviewOrderService {
+
+    @Resource
+    private ReviewOrderMapper reviewOrderMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Override
+    @Transactional
+    public String createReview(ReviewOrderRequest request, Long reviewerId) {
+        Long orderId = request.getOrderId();
+        ProductOrder order = productOrderMapper.selectById(orderId);
+        if (order == null) {
+            throw new RuntimeException("Order not found");
+        }
+        if (!"activity".equals(order.getProductType())) {
+            throw new RuntimeException("Only activity orders are reviewable");
+        }
+        boolean completed = "completed".equals(order.getStatus());
+        boolean signedIn = "signed_in".equals(order.getProvisionStatus());
+        if (!completed && !signedIn) {
+            throw new RuntimeException("Order is not eligible for review");
+        }
+
+        ReviewOrder existing = reviewOrderMapper.findByOrderId(orderId);
+        if (existing != null) {
+            throw new RuntimeException("Review already exists");
+        }
+
+        ReviewOrder review = new ReviewOrder();
+        review.setOrderId(orderId);
+        review.setReviewerId(reviewerId);
+        review.setRating(request.getRating());
+        review.setReviewText(request.getReviewText());
+        review.setReviewAt(new Date());
+        review.setOrganizationRating(request.getOrganizationRating());
+        review.setContentRating(request.getContentRating());
+        review.setExperienceRating(request.getExperienceRating());
+        review.setCreatedAt(new Date());
+        review.setUpdatedAt(new Date());
+        reviewOrderMapper.insert(review);
+        return "评价提交成功";
+    }
+
+    @Override
+    public ReviewOrderResponse findByOrder(Long orderId) {
+        ReviewOrder review = reviewOrderMapper.findByOrderId(orderId);
+        if (review == null) {
+            return null;
+        }
+        ReviewOrderResponse response = new ReviewOrderResponse();
+        response.setOrderId(review.getOrderId());
+        response.setReviewerId(review.getReviewerId());
+        response.setRating(review.getRating());
+        response.setReviewText(review.getReviewText());
+        response.setReviewAt(review.getReviewAt() != null ? review.getReviewAt().toString() : null);
+        response.setOrganizationRating(review.getOrganizationRating());
+        response.setContentRating(review.getContentRating());
+        response.setExperienceRating(review.getExperienceRating());
+        return response;
+    }
+
+    @Override
+    public ReviewStatusResponse getStats(Long orderId) {
+        ReviewOrder review = reviewOrderMapper.findByOrderId(orderId);
+        Map<String, Object> stats = reviewOrderMapper.getStatsByOrderId(orderId);
+
+        ReviewStatusResponse response = new ReviewStatusResponse();
+        response.setReviewed(review != null);
+        if (review != null) {
+            response.setRating(review.getRating());
+            response.setReviewText(review.getReviewText());
+            response.setReviewAt(review.getReviewAt() != null ? review.getReviewAt().toString() : null);
+            response.setOrganizationRating(review.getOrganizationRating());
+            response.setContentRating(review.getContentRating());
+            response.setExperienceRating(review.getExperienceRating());
+        }
+
+        if (stats != null && !stats.isEmpty()) {
+            Object overallAvgObj = stats.get("overallAvg");
+            Object organizationAvgObj = stats.get("organizationAvg");
+            Object contentAvgObj = stats.get("contentAvg");
+            Object experienceAvgObj = stats.get("experienceAvg");
+            Object totalReviewsObj = stats.get("totalReviews");
+
+            response.setOverallAvg(overallAvgObj != null ? ((Number) overallAvgObj).doubleValue() : null);
+            response.setOrganizationAvg(organizationAvgObj != null ? ((Number) organizationAvgObj).doubleValue() : null);
+            response.setContentAvg(contentAvgObj != null ? ((Number) contentAvgObj).doubleValue() : null);
+            response.setExperienceAvg(experienceAvgObj != null ? ((Number) experienceAvgObj).doubleValue() : null);
+            response.setTotalReviews(totalReviewsObj != null ? ((Number) totalReviewsObj).intValue() : 0);
+        }
+        return response;
+    }
+}

+ 23 - 0
cfc-backend/src/main/resources/mapper/ReviewOrderMapper.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.etotem.cfc.mapper.ReviewOrderMapper">
+
+    <select id="findByOrderId" resultType="com.etotem.cfc.entity.ReviewOrder">
+        SELECT *
+        FROM review_orders
+        WHERE order_id = #{orderId}
+        LIMIT 1
+    </select>
+
+    <select id="getStatsByOrderId" resultType="java.util.Map">
+        SELECT
+            AVG(rating) AS overallAvg,
+            AVG(organization_rating) AS organizationAvg,
+            AVG(content_rating) AS contentAvg,
+            AVG(experience_rating) AS experienceAvg,
+            COUNT(*) AS totalReviews
+        FROM review_orders
+        WHERE order_id = #{orderId}
+    </select>
+
+</mapper>

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

@@ -4037,3 +4037,19 @@ CREATE TABLE IF NOT EXISTS dan_report_uploads (
     INDEX idx_parse_status (parse_status),
     INDEX idx_linked_result (linked_result_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DAN测评PDF报告上传/解析/确认记录';
+
+CREATE TABLE IF NOT EXISTS review_orders (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    order_id BIGINT NOT NULL COMMENT '订单ID',
+    reviewer_id BIGINT NOT NULL COMMENT '评价人ID',
+    rating INT COMMENT '总体评分',
+    review_text TEXT COMMENT '评价内容',
+    review_at DATETIME COMMENT '评价时间',
+    organization_rating INT COMMENT '活动组织评分(0-5)',
+    content_rating INT COMMENT '活动内容评分(0-5)',
+    experience_rating INT COMMENT '活动体验评分(0-5)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_order_id (order_id),
+    INDEX idx_reviewer_id (reviewer_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表';

+ 226 - 0
omo/plans/migration-python-to-java-report-parsing.md

@@ -0,0 +1,226 @@
+# Python → Java 报告解析迁移计划
+
+## 背景
+
+菌群报告和 DAN 测评报告的 Python 解析脚本(`docs/参考资料/`)是离线数据预处理工具。Java 运行时(`cfc-backend/`)已有部分实现但覆盖不全。
+
+## 现状
+
+| 解析器 | Java 现状 | Python 覆盖 | 差距 |
+|--------|----------|------------|------|
+| 菌群报告 `PdfParseService` | ✅ 868行,PDFBox,完整 | `extract_full_report_v5.py` 1393行 | 持平,Java 已可用于生产 |
+| DAN A2 (mind) | ✅ 已完成 | `extract_a2_data()` | 持平 |
+| DAN B4 (wisdom) | ✅ 已完成 | `extract_b4_data()` | 持平 |
+| DAN A1 儿童认知 | ❌ 未实现 | `extract_a1_data()` | **需移植** |
+| DAN B2 儿童行为 | ❌ 未实现 | `extract_b2_data()` | **需移植** |
+| DAN B3 学习能力 | ❌ 未实现 | `extract_b3_data()` | **需移植** |
+| DAN B5 青春期 | ❌ 未实现 | `extract_b5_data()`(含仪表盘图片分析) | **需移植** |
+| DAN B6 职业发展 | ❌ 未实现 | `extract_b6_data()` | **需移植** |
+| DAN C1 校园版 | ❌ 未实现 | `extract_c1_data()` | **需移植** |
+
+## 总体策略
+
+**菌群报告**:Java 已满足生产需求,不做变动。Python 脚本保留为离线参考。
+
+**DAN 报告**:分3波迁移。每波包含:解析逻辑 → 新增 Controller 端点 → 测试验证。
+
+---
+
+## Wave 1 — 基础类型(A1 + B3)
+
+**目标**:纯文本正则提取,无图片分析,约 140 行 Python → Java
+
+### A1 儿童核心认知发展
+
+Python: `extract_a1_data()` ~43 行
+- 6 个认知维度:感知觉、注意力、记忆力、推理能力、空间能力、加工速度
+- 提取逻辑:正则匹配 3 种 PDF 格式(summary 页百分位、detail 页得分+百分位、fallback)
+- 输出格式:`{维度名_pct, 维度名_score}`
+
+**Java 实现方案**:
+- 在 `DanReportParseService` 中新增 `parseA1Report(List<String> lines, String fullText)` 方法
+- 复用现有的 `DataItem` 和 `DanParsedReport` 结构
+- 维度映射到 `dan_assessment_results` 表的 `attention_score`, `memory_score`, `perception_score`, `spatial_score`, `processing_speed_score`, `logic_score` 字段
+- 新增 `dimension="cognition"` 路由分支(`parseText()` 中)
+
+### B3 核心学习能力
+
+Python: `extract_b3_data()` ~74 行
+- 3 大模块:执行功能(认知灵活性/抑制控制/元认知)、学习动机(内在/外在)、学习策略(深层/表面)
+- 提取逻辑:章节分割 + 正则匹配分数
+
+**Java 实现方案**:
+- 新增 `parseB3Report()` 方法
+- 复用 `findSectionStart()` + `parseScorePairs()` 模式
+- 新增 `dimension="learning"` 路由分支
+
+### Wave 1 改动范围
+
+```
+DanReportParseService.java
+├── parseText(): 新增 "cognition" / "learning" 路由
+├── parseA1Report()        ← 新增,~60 行
+├── parseB3Report()        ← 新增,~80 行
+├── parseScorePairs()      ← 新增辅助方法,复用正则
+└── DataItem / DanParsedReport 结构不变
+
+DanAssessmentController.java
+├── parsePreview(): 放开 dimension 校验(当前只允许 mind/wisdom)
+└── dimension 映射表: cognition→A1, learning→B3
+
+schema.sql / DatabaseInitializer
+└── 无需新增表(dan_assessment_results 已有 JSON 字段 structured_analysis)
+```
+
+---
+
+## Wave 2 — 中等复杂度(B2 + B6 + C1)
+
+### B2 儿童自我与家庭教养
+
+Python: `extract_b2_data()` ~74 行
+- 自我概念 6 维 + 儿童行为 8 维 + 家庭环境 10 维
+- 提取逻辑:章节定位 + 正则匹配
+
+**Java 实现方案**:
+- 新增 `parseB2Report()` 方法
+- 复用 `parseSelfConcept()`(已存在于 B4 解析中)
+- 新增行为、家庭环境的提取方法
+- 新增 `dimension="behavior"` 路由
+
+### B6 职业发展
+
+Python: `extract_b6_data()` ~77 行
+- Holland 兴趣 6 型(RIASEC)+ 多元智能 8 维 + 职业价值观
+- 提取逻辑:表格文本正则匹配
+
+**Java 实现方案**:
+- 新增 `parseB6Report()` 方法
+- 新增 `dimension="career"` 路由
+
+### C1 校园版
+
+Python: `extract_c1_data()` ~91 行
+- 认知 6 维(同 A1)+ 大五人格 5 维(同 A2)+ 自驱力 3 维 + 自我概念 6 维
+- 提取逻辑:复用 A1 和 A2 的维度提取
+
+**Java 实现方案**:
+- 新增 `parseC1Report()` 方法
+- 复用 `parseA1Report()` 的认知维度提取 + A2 的大五人格提取
+- 新增 `dimension="campus"` 路由
+
+### Wave 2 改动范围
+
+```
+DanReportParseService.java
+├── parseText(): 新增 "behavior"/"career"/"campus" 路由
+├── parseB2Report()         ← 新增,~80 行
+├── parseB6Report()         ← 新增,~80 行
+├── parseC1Report()         ← 新增,~60 行(复用现有方法)
+├── parseFamilyEnvironment() ← 新增辅助方法
+├── parseHollandInterest()   ← 新增辅助方法
+└── parseMultipleIntelligence() ← 新增辅助方法
+
+DanAssessmentController.java
+└── 路由映射扩展
+
+schema.sql / DatabaseInitializer
+└── 无需新增表
+```
+
+---
+
+## Wave 3 — 高复杂度(B5 青春期 + B4 增强)
+
+### B5 青春期挑战
+
+Python: `extract_b5_data()` + 罗盘仪表盘图片分析 ~150 行
+- 情绪调节 2 维 + 学业压力 5 维 + 人际关系 4 维 + 社交 5 维 + 睡眠/运动/网络
+- 特殊:B5 报告含 **指南针仪表盘图像**,Python 用 PyMuPDF 提取 PNG + 像素分析
+
+**Java 实现方案**:
+- **文本部分**:新增 `parseB5Report()`,正则提取各维度分数,这覆盖 80% 的数据
+- **图像部分**(指南针仪表盘):
+  1. 用 PDFBox 提取页面图像 `PDImageXObject`
+  2. 用 Java `BufferedImage` 做像素颜色分析(替代 Python 的 PIL 角度测量)
+  3. 或:降级方案——跳过图像分析,仅从文本提取可读分数(文本已包含主要维度的数字评分)
+- 新增 `dimension="adolescent"` 路由
+
+**谨慎意见**:B5 指南针仪表盘是 PNG 嵌入图像,PDFBox 可以提取,但像素级别的指针角度分析在 Java 中更啰嗦(需用 `BufferedImage.getRGB()` 手动实现)。可以考虑:
+- 方案 A:纯 Java 实现完整图像分析(2-3 天工作量)
+- 方案 B:跳过图像分析,从文本提取所有维度分数(文本里已有 80% 的数据),1 天内完成
+- 建议:**先做方案 B**,文本提取覆盖核心数据,图像部分延后
+
+### Wave 3 改动范围
+
+```
+DanReportParseService.java
+├── parseText(): 新增 "adolescent" 路由
+├── parseB5Report()         ← 新增,~120 行(文本部分)
+├── parseB5CompassImage()   ← 可选,降级优先
+└── extractImageFromPDF()   ← 新增辅助(PDFBox→BufferedImage)
+
+DanAssessmentController.java
+└── 路由映射扩展 + 处理 B5 图片分析结果
+
+schema.sql / DatabaseInitializer
+└── 无需新增表
+```
+
+---
+
+## 测试策略
+
+每波完成后验证:
+
+```
+cfc-backend/src/test/java/.../DanReportParseServiceTest.java
+├── testParseA1Report()     — 用真实 A1 PDF 测试 6 维度提取
+├── testParseB3Report()     — 用真实 B3 PDF 测试
+├── testParseA2Report()     — 已有,确认回归
+├── testParseB4Report()     — 已有,确认回归
+├── testParseB2Report()     — Wave 2
+├── testParseB6Report()     — Wave 2
+├── testParseC1Report()     — Wave 2
+└── testParseB5Report()     — Wave 3
+
+HealthReportControllerTest.java
+└── testUploadGutFloraReport() — 回归确认菌群报告不受影响
+```
+
+**测试用 PDF**:`docs/参考资料/dan_reports/` 下已有各类型真实 PDF 样本。
+
+**验证命令**:
+```bash
+cd cfc-backend && mvn clean compile test -Dtest=DanReportParseServiceTest
+```
+
+---
+
+## 不会改动的内容
+
+- 菌群报告的 Python `extract_full_report_v5.py`:保留为离线参考
+- DAN Python 脚本集合 `dan_reports/`:完整保留,不删除
+- `PdfParseService.java`:不做任何改动
+- `HealthReportController.java`:不做任何改动
+- DB schema:**不需要新增表**,`dan_assessment_results.structured_analysis`(JSON 字段)已支持所有 DAN 报告类型的结构化数据存储
+
+## 总工作量估算
+
+| Wave | 新增 Java 行数 | 类型数 | 复杂度 | 估计工时 |
+|------|---------------|--------|--------|---------|
+| Wave 1 (A1+B3) | ~140 | 2 | 低 | 1-2 天 |
+| Wave 2 (B2+B6+C1) | ~220 | 3 | 中 | 2-3 天 |
+| Wave 3 (B5) | ~150 | 1 | 高 | 1-2 天(文本)/ +2天(图像) |
+| **合计** | **~510** | **6** | — | **4-7 天** |
+
+---
+
+## 变更风险矩阵
+
+| Risk | 影响 | 缓解措施 |
+|------|------|---------|
+| Python 正则与 PDF 实际格式强耦合,不同 PDF 版本格式不同 | 提取失败/漏数据 | 测试覆盖多种 PDF 样本;Java 中用多模式回退(Python 已有多模式模式,直接移植) |
+| PDFBox 文本提取顺序和 PyMuPDF 不同 | 正则不匹配 | `parseText()` 中增加行合并预处理;对比 5+ 份 PDF 输出 |
+| B5 图片分析在 PDFBox 中比 PyMuPDF 更复杂 | 开发成本翻倍 | Wave 3 先做文本部分,图片分析延后独立 |
+| `DanAssessmentController` 现有接口调用方依赖 `mind`/`wisdom` | 前端不兼容 | 向后兼容:老 dimension 值保持可用,新值只影响解析路径 |