# 统一报告管理中心 — 实施计划 > **Plan author:** Sisyphus (orchestrator) > **Date:** 2026-07-28 > **Spec:** [2026-07-28-report-management-design.md](./specs/2026-07-28-report-management-design.md) **Goal:** 新建 `report_summary` 汇总表 + 同步机制 + 统一查询API + 小程序/Web管理端报告管理页面。 **Architecture:** 汇总表 `report_summary` 通过 `source_table + source_id` 关联4类源表;Service 层 `ReportSummaryService` 负责即时同步 + 查询;`@Scheduled` 定时任务兜底;小程序 `pages/profile/report-management.vue` + Web `ReportManagement.vue` 双端覆盖。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + Java 8 + uni-app Vue 2 + Element UI ## Global Constraints - 接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping` - 响应统一 `Result` (code/message/data) - 小程序禁用可选链 `?.`(用 `&&` 替代)、禁用 CSS Grid - Vue 2 Options API,禁止 Composition API - 定时任务使用 Spring `@Scheduled` - 汇总表通过 `UNIQUE KEY uk_source (source_table, source_id)` 去重 - 源表不修改,汇总表只做 `INSERT ... ON DUPLICATE KEY UPDATE` --- ## Task 1: 数据库 — 建表 + 迁移 **Files:** - Create: `cfc-backend/src/main/resources/schema.sql` (append) - Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` **Produces:** `report_summary` 表创建 + 幂等迁移 - [ ] Step 1: 在 `cfc-backend/src/main/resources/schema.sql` 末尾追加 `CREATE TABLE IF NOT EXISTS report_summary` 语句(使用 spec §2.1 的 SQL) - [ ] Step 2: 在 `DatabaseInitializer.runMigrations()` 末尾添加建表迁移,使用 try-catch 包裹(表已存在则忽略): ```java // 创建统一报告汇总表(report_summary) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_summary (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " + "report_type VARCHAR(32) NOT NULL COMMENT '报告类型', " + "source_table VARCHAR(32) NOT NULL COMMENT '来源表', " + "source_id BIGINT NOT NULL COMMENT '来源表记录ID', " + "user_id BIGINT NOT NULL COMMENT '创建者用户ID', " + "family_id BIGINT COMMENT '家庭ID', " + "subject_id BIGINT COMMENT '报告主体ID', " + "subject_name VARCHAR(50) COMMENT '报告主体名称', " + "title VARCHAR(200) COMMENT '报告标题', " + "summary TEXT COMMENT '摘要', " + "file_url VARCHAR(500) COMMENT '文件URL', " + "file_size BIGINT COMMENT '文件大小', " + "file_name VARCHAR(200) COMMENT '文件名', " + "has_file TINYINT DEFAULT 0 COMMENT '是否有文件', " + "overall_score INT COMMENT '综合评分', " + "report_date DATE COMMENT '报告日期', " + "status VARCHAR(20) COMMENT '状态', " + "detail_route VARCHAR(100) COMMENT '详情页路由', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_user_id (user_id), " + "INDEX idx_family_id (family_id), " + "INDEX idx_subject_id (subject_id), " + "INDEX idx_report_type (report_type), " + "INDEX idx_report_date (report_date), " + "INDEX idx_status (status), " + "UNIQUE KEY uk_source (source_table, source_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表'"); log.info("已创建 report_summary 表"); } catch (Exception e) { log.info("report_summary 表已存在,跳过创建"); } ``` - [ ] Step 3: 在 `schema.sql` 末尾(`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表';` 后)添加注释 `-- report_summary 表已由 DatabaseInitializer 迁移创建` - [ ] Step 4: `cd cfc-backend && mvn clean compile` 验证编译通过 --- ## Task 2: 后端 — Entity + Mapper **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ReportSummary.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportSummaryMapper.java` - Create: `cfc-backend/src/main/resources/mapper/ReportSummaryMapper.xml` **Produces:** MyBatis-Plus 实体 + Mapper + XML 映射 - [ ] Step 1: 创建 `ReportSummary.java` — 使用 Lombok `@Data`、`@TableName("report_summary")`、`@TableId(type = IdType.AUTO)`。字段与 spec §2.1 一致: ```java 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("report_summary") public class ReportSummary implements Serializable { @TableId(type = IdType.AUTO) private Long id; private String reportType; private String sourceTable; private Long sourceId; private Long userId; private Long familyId; private Long subjectId; private String subjectName; private String title; private String summary; private String fileUrl; private Long fileSize; private String fileName; private Integer hasFile; private Integer overallScore; private Date reportDate; private String status; private String detailRoute; private Date createdAt; private Date updatedAt; } ``` - [ ] Step 2: 创建 `ReportSummaryMapper.java` — 继承 `BaseMapper`: ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.ReportSummary; public interface ReportSummaryMapper extends BaseMapper { } ``` - [ ] Step 3: 创建 `ReportSummaryMapper.xml` — 参考 `ActivityFeedbackMapper.xml` 的 resultMap 模式(虽然这里字段名与数据库列名驼峰一致,XML 可为空结构): ```xml ``` - [ ] Step 4: `cd cfc-backend && mvn clean compile` 验证编译通过 --- ## Task 3: 后端 — DTO/VO **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/ReportListQueryDTO.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSyncResultDTO.java` **Produces:** 列表查询入参 DTO、列表 VO、同步结果 DTO - [ ] Step 1: 创建 `ReportListQueryDTO.java`: ```java package com.etotem.cfc.dto; import lombok.Data; @Data public class ReportListQueryDTO { private Integer page = 1; private Integer size = 20; private Long userId; private String type; // gut_flora|physical_exam|dan|insurance|fortune|all private String keyword; private Long subjectId; private String startDate; private String endDate; } ``` - [ ] Step 2: 创建 `ReportSummaryVO.java` — 展示用,添加 `reportTypeName`(中文类型名): ```java package com.etotem.cfc.dto; import lombok.Data; import java.util.Date; @Data public class ReportSummaryVO { private Long id; private String reportType; private String reportTypeName; private String sourceTable; private Long sourceId; private String title; private String subjectName; private String summary; private String fileUrl; private Long fileSize; private String fileName; private Boolean hasFile; private Integer overallScore; private String reportDate; private String status; private String detailRoute; private String detailParams; // JSON 字符串,前端用 private Date createdAt; public static String getReportTypeName(String reportType) { if (reportType == null) return "未知"; switch (reportType) { case "gut_flora": return "菌群检测"; case "physical_exam": return "体检报告"; case "dan": return "DAN测评"; case "insurance": return "保单"; case "fortune": return "家庭周报"; default: return reportType; } } } ``` - [ ] Step 3: 创建 `ReportSyncResultDTO.java`: ```java package com.etotem.cfc.dto; import lombok.Data; @Data public class ReportSyncResultDTO { private int newCount; private int updateCount; private int discardedCount; private int errorCount; private String errorMsg; } ``` - [ ] Step 4: `mvn clean compile` 验证 --- ## Task 4: 后端 — ReportSummaryService(核心) **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java` **Consumes:** Task 1 (表存在), Task 2 (Mapper), Task 3 (DTO) **Produces:** `sync*()` 各源表同步方法 + `pageList()` 分页查询 + `getDetail()` + `adminPageList()` + `fullSync()` - [ ] Step 1: 创建 `ReportSummaryService.java`。注入 Mapper: ```java package com.etotem.cfc.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.etotem.cfc.dto.ReportListQueryDTO; import com.etotem.cfc.dto.ReportSummaryVO; import com.etotem.cfc.dto.ReportSyncResultDTO; import com.etotem.cfc.entity.ReportSummary; import com.etotem.cfc.entity.*; import com.etotem.cfc.mapper.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.ArrayList; import java.util.Date; import java.util.List; @Service @Slf4j public class ReportSummaryService { @Resource private ReportSummaryMapper reportSummaryMapper; @Resource private HealthReportMapper healthReportMapper; @Resource private DanReportUploadMapper danReportUploadMapper; @Resource private InsurancePolicyMapper insurancePolicyMapper; @Resource private UserMapper userMapper; @Resource private FamilyMemberMapper familyMemberMapper; } ``` - [ ] Step 2: 实现 `syncFromHealthReport(HealthReport report)` — 按 spec §2.2 映射: ```java public void syncFromHealthReport(HealthReport report) { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "health_reports") .eq(ReportSummary::getSourceId, report.getId()) ); ReportSummary summary = new ReportSummary(); summary.setReportType(report.getReportType()); summary.setSourceTable("health_reports"); summary.setSourceId(report.getId()); summary.setUserId(report.getUserId()); summary.setFamilyId(report.getFamilyId()); summary.setSubjectId(report.getSubjectId()); if (report.getSubjectId() != null) { User subject = userMapper.selectById(report.getSubjectId()); summary.setSubjectName(subject != null ? subject.getNickname() : ""); } String typeLabel = "gut_flora".equals(report.getReportType()) ? "菌群检测报告" : "体检报告"; String name = summary.getSubjectName() != null && !summary.getSubjectName().isEmpty() ? summary.getSubjectName() : "用户"; summary.setTitle(name + "的" + typeLabel); summary.setFileUrl(report.getFileUrl()); summary.setHasFile(report.getFileUrl() != null && !report.getFileUrl().isEmpty() ? 1 : 0); summary.setOverallScore(report.getOverallScore()); summary.setReportDate(report.getReportDate()); summary.setStatus(report.getStatus()); summary.setDetailRoute("gut_flora".equals(report.getReportType()) ? "/pages/health/gut-flora-detail" : "/pages/health/physical-exam-detail"); if (existing != null) { summary.setId(existing.getId()); reportSummaryMapper.updateById(summary); } else { reportSummaryMapper.insert(summary); } } ``` - [ ] Step 3: 实现 `syncFromDanReport(DanReportUpload upload)`: ```java public void syncFromDanReport(DanReportUpload upload) { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "dan_report_uploads") .eq(ReportSummary::getSourceId, upload.getId()) ); ReportSummary summary = new ReportSummary(); summary.setReportType("dan"); summary.setSourceTable("dan_report_uploads"); summary.setSourceId(upload.getId()); summary.setUserId(upload.getUploaderUserId()); summary.setFamilyId(upload.getFamilyId()); summary.setSubjectId(upload.getFamilyMemberId()); FamilyMember member = familyMemberMapper.selectById(upload.getFamilyMemberId()); summary.setSubjectName(member != null ? member.getMemberName() : ""); String dimLabel = "mind".equals(upload.getDimension()) ? "心智" : "智慧"; String name = summary.getSubjectName() != null && !summary.getSubjectName().isEmpty() ? summary.getSubjectName() : "成员"; summary.setTitle(name + "的" + dimLabel + "测评"); summary.setFileUrl(upload.getFileUrl()); summary.setHasFile(upload.getFileUrl() != null && !upload.getFileUrl().isEmpty() ? 1 : 0); // 从 dan_assessment_results 关联查 overall_score if (upload.getLinkedResultId() != null) { DanAssessmentResult result = danAssessmentResultMapper.selectById(upload.getLinkedResultId()); if (result != null) summary.setOverallScore(result.getOverallScore()); } summary.setReportDate(upload.getUploadedAt()); String status = upload.getDraftStatus(); if ("confirmed".equals(status)) status = "confirmed"; else if ("pending".equals(status)) status = "active"; else if ("discarded".equals(status)) status = "discarded"; else status = "active"; summary.setStatus(status); summary.setDetailRoute("/pages/dan-assessment/report-upload"); if (existing != null) { summary.setId(existing.getId()); reportSummaryMapper.updateById(summary); } else { reportSummaryMapper.insert(summary); } } ``` - [ ] Step 4: 实现 `syncFromInsurancePolicy(InsurancePolicy policy)`: ```java public void syncFromInsurancePolicy(InsurancePolicy policy) { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "insurance_policies") .eq(ReportSummary::getSourceId, policy.getId()) ); ReportSummary summary = new ReportSummary(); summary.setReportType("insurance"); summary.setSourceTable("insurance_policies"); summary.setSourceId(policy.getId()); summary.setUserId(policy.getUserId()); summary.setSubjectId(policy.getChildId()); summary.setSubjectName(policy.getInsuredPerson()); summary.setTitle(policy.getPolicyName()); String company = policy.getInsuranceCompany() != null ? policy.getInsuranceCompany() : "未知公司"; String type = policy.getPolicyType() != null ? policy.getPolicyType() : "保险"; Integer sum = policy.getSumInsured() != null ? policy.getSumInsured() : 0; summary.setSummary(company + " · " + type + " · 保额 ¥" + sum / 100); summary.setHasFile(0); summary.setReportDate(policy.getStartDate()); summary.setStatus(policy.getStatus()); summary.setDetailRoute("/pages/wealth-sub/insurance-list"); if (existing != null) { summary.setId(existing.getId()); reportSummaryMapper.updateById(summary); } else { reportSummaryMapper.insert(summary); } } ``` - [ ] Step 5: 实现 `syncFromFamilyFortuneReport(FamilyFortuneReport report)`: ```java public void syncFromFamilyFortuneReport(FamilyFortuneReport report) { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "family_fortune_report") .eq(ReportSummary::getSourceId, report.getId()) ); ReportSummary summary = new ReportSummary(); summary.setReportType("fortune"); summary.setSourceTable("family_fortune_report"); summary.setSourceId(report.getId()); summary.setFamilyId(Long.valueOf(report.getFamilyId())); FamilyMember member = familyMemberMapper.selectOne( new LambdaQueryWrapper() .eq(FamilyMember::getFamilyId, report.getFamilyId()) .eq(FamilyMember::getRole, "parent") .last("LIMIT 1") ); summary.setUserId(member != null ? member.getUserId() : null); String element = report.getElement() != null ? report.getElement() : "天盘"; summary.setTitle(element + "天盘周报"); summary.setSummary(report.getLuckyDirection() != null ? report.getLuckyDirection() : ""); summary.setFileUrl(report.getPdfPath()); summary.setFileSize(report.getFileSize()); summary.setHasFile(report.getPdfPath() != null && !report.getPdfPath().isEmpty() ? 1 : 0); summary.setReportDate(report.getCreatedAt()); summary.setStatus("active"); summary.setDetailRoute("/pages/tianpan/index"); if (existing != null) { summary.setId(existing.getId()); reportSummaryMapper.updateById(summary); } else { reportSummaryMapper.insert(summary); } } ``` - [ ] Step 6: 实现 `markDiscarded(String sourceTable, Long sourceId)` — 源表删除时调用: ```java public void markDiscarded(String sourceTable, Long sourceId) { reportSummaryMapper.update( null, new LambdaUpdateWrapper() .eq(ReportSummary::getSourceTable, sourceTable) .eq(ReportSummary::getSourceId, sourceId) .set(ReportSummary::getStatus, "discarded") ); } ``` - [ ] Step 7: 实现 `pageList(ReportListQueryDTO query)` — 小程序端分页查询,过滤当前用户 + 家庭: ```java public Page pageList(ReportListQueryDTO query) { Page page = new Page<>(query.getPage(), query.getSize()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ReportSummary::getUserId, query.getUserId()); wrapper.ne(ReportSummary::getStatus, "discarded"); if ("all".equals(query.getType()) || query.getType() == null || query.getType().isEmpty()) { // 不限类型 } else { wrapper.eq(ReportSummary::getReportType, query.getType()); } if (query.getKeyword() != null && !query.getKeyword().isEmpty()) { wrapper.and(w -> w .like(ReportSummary::getTitle, query.getKeyword()) .or().like(ReportSummary::getSubjectName, query.getKeyword()) ); } if (query.getSubjectId() != null) { wrapper.eq(ReportSummary::getSubjectId, query.getSubjectId()); } wrapper.orderByDesc(ReportSummary::getReportDate); reportSummaryMapper.selectPage(page, wrapper); Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); List voList = new ArrayList<>(); for (ReportSummary s : page.getRecords()) { ReportSummaryVO vo = new ReportSummaryVO(); vo.setId(s.getId()); vo.setReportType(s.getReportType()); vo.setReportTypeName(ReportSummaryVO.getReportTypeName(s.getReportType())); vo.setTitle(s.getTitle()); vo.setSubjectName(s.getSubjectName()); vo.setSummary(s.getSummary()); vo.setFileUrl(s.getFileUrl()); vo.setFileSize(s.getFileSize()); vo.setFileName(s.getFileName()); vo.setHasFile(s.getHasFile() != null && s.getHasFile() == 1); vo.setOverallScore(s.getOverallScore()); vo.setReportDate(s.getReportDate() != null ? s.getReportDate().toString() : ""); vo.setStatus(s.getStatus()); vo.setDetailRoute(s.getDetailRoute()); vo.setCreatedAt(s.getCreatedAt()); voList.add(vo); } voPage.setRecords(voList); return voPage; } ``` - [ ] Step 8: 实现 `getDetail(Long summaryId)` — 单条详情: ```java public ReportSummaryVO getDetail(Long summaryId) { ReportSummary s = reportSummaryMapper.selectById(summaryId); if (s == null) return null; ReportSummaryVO vo = new ReportSummaryVO(); vo.setId(s.getId()); vo.setReportType(s.getReportType()); vo.setReportTypeName(ReportSummaryVO.getReportTypeName(s.getReportType())); vo.setTitle(s.getTitle()); vo.setSubjectName(s.getSubjectName()); vo.setSummary(s.getSummary()); vo.setFileUrl(s.getFileUrl()); vo.setFileSize(s.getFileSize()); vo.setFileName(s.getFileName()); vo.setHasFile(s.getHasFile() != null && s.getHasFile() == 1); vo.setOverallScore(s.getOverallScore()); vo.setReportDate(s.getReportDate() != null ? s.getReportDate().toString() : ""); vo.setStatus(s.getStatus()); vo.setDetailRoute(s.getDetailRoute()); vo.setCreatedAt(s.getCreatedAt()); return vo; } ``` - [ ] Step 9: 实现 `adminPageList(ReportListQueryDTO query)` — 管理端,不限 userId: ```java public Page adminPageList(ReportListQueryDTO query) { Page page = new Page<>(query.getPage(), query.getSize()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.ne(ReportSummary::getStatus, "discarded"); if ("all".equals(query.getType()) || query.getType() == null || query.getType().isEmpty()) { // 不限 } else { wrapper.eq(ReportSummary::getReportType, query.getType()); } if (query.getKeyword() != null && !query.getKeyword().isEmpty()) { wrapper.and(w -> w .like(ReportSummary::getTitle, query.getKeyword()) .or().like(ReportSummary::getSubjectName, query.getKeyword()) .or().like(ReportSummary::getSummary, query.getKeyword()) ); } if (query.getStartDate() != null && !query.getStartDate().isEmpty()) { wrapper.ge(ReportSummary::getReportDate, query.getStartDate()); } if (query.getEndDate() != null && !query.getEndDate().isEmpty()) { wrapper.le(ReportSummary::getReportDate, query.getEndDate()); } wrapper.orderByDesc(ReportSummary::getReportDate); reportSummaryMapper.selectPage(page, wrapper); Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); List voList = new ArrayList<>(); for (ReportSummary rs : page.getRecords()) { ReportSummaryVO vo = new ReportSummaryVO(); vo.setId(rs.getId()); vo.setReportType(rs.getReportType()); vo.setReportTypeName(ReportSummaryVO.getReportTypeName(rs.getReportType())); vo.setTitle(rs.getTitle()); vo.setSubjectName(rs.getSubjectName()); vo.setSummary(rs.getSummary()); vo.setHasFile(rs.getHasFile() != null && rs.getHasFile() == 1); vo.setOverallScore(rs.getOverallScore()); vo.setReportDate(rs.getReportDate() != null ? rs.getReportDate().toString() : ""); vo.setStatus(rs.getStatus()); vo.setCreatedAt(rs.getCreatedAt()); voList.add(vo); } voPage.setRecords(voList); return voPage; } ``` - [ ] Step 10: 实现 `fullSync()` — 全量校准,供定时任务和手动触发调用: ```java public ReportSyncResultDTO fullSync() { ReportSyncResultDTO result = new ReportSyncResultDTO(); result.setNewCount(0); result.setUpdateCount(0); result.setDiscardedCount(0); result.setErrorCount(0); try { // 1. 同步 health_reports List healthReports = healthReportMapper.selectList(new LambdaQueryWrapper()); List existingHealth = reportSummaryMapper.selectList( new LambdaQueryWrapper().eq(ReportSummary::getSourceTable, "health_reports")); for (HealthReport hr : healthReports) { try { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "health_reports") .eq(ReportSummary::getSourceId, hr.getId())); if (existing == null) { syncFromHealthReport(hr); result.setNewCount(result.getNewCount() + 1); } else { syncFromHealthReport(hr); result.setUpdateCount(result.getUpdateCount() + 1); } } catch (Exception e) { result.setErrorCount(result.getErrorCount() + 1); log.error("同步 health_reports 记录失败 id={}", hr.getId(), e); } } // 标记已删除的 for (ReportSummary es : existingHealth) { if (healthReportMapper.selectById(es.getSourceId()) == null) { markDiscarded("health_reports", es.getSourceId()); result.setDiscardedCount(result.getDiscardedCount() + 1); } } } catch (Exception e) { log.error("全量同步 health_reports 失败", e); result.setErrorCount(result.getErrorCount() + 1); } // 2. 同步 dan_report_uploads try { List danUploads = danReportUploadMapper.selectList(new LambdaQueryWrapper()); List existingDan = reportSummaryMapper.selectList( new LambdaQueryWrapper().eq(ReportSummary::getSourceTable, "dan_report_uploads")); for (DanReportUpload du : danUploads) { try { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "dan_report_uploads") .eq(ReportSummary::getSourceId, du.getId())); if (existing == null) { syncFromDanReport(du); result.setNewCount(result.getNewCount() + 1); } else { syncFromDanReport(du); result.setUpdateCount(result.getUpdateCount() + 1); } } catch (Exception e) { result.setErrorCount(result.getErrorCount() + 1); log.error("同步 dan_report_uploads 失败 id={}", du.getId(), e); } } for (ReportSummary es : existingDan) { if (danReportUploadMapper.selectById(es.getSourceId()) == null) { markDiscarded("dan_report_uploads", es.getSourceId()); result.setDiscardedCount(result.getDiscardedCount() + 1); } } } catch (Exception e) { log.error("全量同步 dan_report_uploads 失败", e); resultsetErrorCount(result.getErrorCount() + 1); } // 3. 同步 insurance_policies try { List policies = insurancePolicyMapper.selectList(new LambdaQueryWrapper()); List existingIns = reportSummaryMapper.selectList( new LambdaQueryWrapper().eq(ReportSummary::getSourceTable, "insurance_policies")); for (InsurancePolicy p : policies) { try { ReportSummary existing = reportSummaryMapper.selectOne( new LambdaQueryWrapper() .eq(ReportSummary::getSourceTable, "insurance_policies") .eq(ReportSummary::getSourceId, p.getId())); if (existing == null) { syncFromInsurancePolicy(p); result.setNewCount(result.getNewCount() + 1); } else { syncFromInsurancePolicy(p); result.setUpdateCount(result.getUpdateCount() + 1); } } catch (Exception e) { result.setErrorCount(result.getErrorCount() + 1); log.error("同步 insurance_policies 失败 id={}", p.getId(), e); } } for (ReportSummary es : existingIns) { if (insurancePolicyMapper.selectById(es.getSourceId()) == null) { markDiscarded("insurance_policies", es.getSourceId()); result.setDiscardedCount(result.getDiscardedCount() + 1); } } } catch (Exception e) { log.error("全量同步 insurance_policies 失败", e); result.setErrorCount(result.getErrorCount() + 1); } // 4. 同步 family_fortune_report(无标准 Mapper,用 JdbcTemplate 查询) try { // 注意:FamilyFortuneReport 无 Mapper,使用 jdbcTemplate 查询 String sql = "SELECT id, family_id, element, lucky_direction, pdf_path, file_size, created_at FROM family_fortune_report"; // 实际实现需用 JdbcTemplate 查询并调用 syncFromFamilyFortuneReport // 此步骤在 task 实现时补充 JdbcTemplate 注入和完整实现 } catch (Exception e) { log.error("全量同步 family_fortune_report 失败", e); result.setErrorCount(result.getErrorCount() + 1); } log.info("全量同步完成: new={}, update={}, discarded={}, error={}", result.getNewCount(), result.getUpdateCount(), result.getDiscardedCount(), result.getErrorCount()); return result; } ``` > ⚠️ `fullSync()` 中 `family_fortune_report` 的同步需额外注入 `JdbcTemplate`(因该表无 MyBatis Mapper)。实现时添加 `@Resource private JdbcTemplate jdbcTemplate;` 并用 `jdbcTemplate.query()` 查询。 - [ ] Step 11: `mvn clean compile` 验证编译通过 --- ## Task 5: 后端 — 注入 sync 调用到各源 Service **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.java` - Find: FamilyFortuneReport 相关 Service(搜索 `family_fortune_report` 写入逻辑) **Consumes:** Task 4 (ReportSummaryService) **Produces:** 源表 CRUD 末尾自动同步到汇总表 - [ ] Step 1: 在 `HealthReportService` 中注入 `ReportSummaryService`: ```java @Resource private ReportSummaryService reportSummaryService; ``` 在 `createReport()` 方法末尾(`healthReportMapper.insert(report)` 之后)添加: ```java try { reportSummaryService.syncFromHealthReport(report); } catch (Exception e) { log.warn("同步报告摘要失败 reportId={}", report.getId(), e); } ``` > ⚠️ 需确认 `HealthReportService` 的 `createReport` 方法是否在保存后能拿到 report 的 id(MyBatis-Plus insert 后 id 会自动回填)。 - [ ] Step 2: 在 `DanReportUploadService.confirmUpload()` 方法末尾(确认保存后)添加: ```java try { reportSummaryService.syncFromDanReport(upload); } catch (Exception e) { log.warn("同步DAN报告摘要失败 uploadId={}", uploadId, e); } ``` 在 `DanReportUploadService` 也需注入 `ReportSummaryService`。 - [ ] Step 3: 在 `InsurancePolicyService.createPolicy()` 末尾添加: ```java try { reportSummaryService.syncFromInsurancePolicy(policy); } catch (Exception e) { log.warn("同步保单摘要失败 policyId={}", policy.getId(), e); } ``` 在 `InsurancePolicyService.updatePolicy()` 末尾也添加同样的 sync 调用。 在 `InsurancePolicyService.deletePolicy()` 中,将物理删除改为标记废弃(可选,保持物理删除但同步标记): ```java try { reportSummaryService.markDiscarded("insurance_policies", id); } catch (Exception e) { log.warn("标记保单废弃失败 policyId={}", id, e); } ``` 注入 `@Resource private ReportSummaryService reportSummaryService;` - [ ] Step 4: 查找 `family_fortune_report` 表的写入逻辑(搜索 `family_fortune_report` 或 `FamilyFortuneReport` 的 INSERT/UPDATE),在写入后调用 `reportSummaryService.syncFromFamilyFortuneReport(report)`。 - [ ] Step 5: `mvn clean compile` 验证 --- ## Task 6: 后端 — ReportSummaryController(小程序端 API) **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/ReportSummaryController.java` **Consumes:** Task 4 (ReportSummaryService) **Produces:** `POST /api/report-summary/list`, `POST /api/report-summary/detail/{id}`, `POST /api/report-summary/download/{id}` - [ ] Step 1: 创建 `ReportSummaryController.java`: ```java package com.etotem.cfc.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.ReportListQueryDTO; import com.etotem.cfc.dto.ReportSummaryVO; import com.etotem.cfc.service.ReportSummaryService; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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; import javax.servlet.http.HttpServletRequest; @Slf4j @RestController @RequestMapping("/api/report-summary") public class ReportSummaryController { @Resource private ReportSummaryService reportSummaryService; @PostMapping("/list") public Result list(@RequestBody ReportListQueryDTO query, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); if (userId == null) return Result.error("用户未登录"); query.setUserId(userId); Page page = reportSummaryService.pageList(query); return Result.success(page); } @PostMapping("/detail/{id}") public Result detail(@PathVariable Long id, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); if (userId == null) return Result.error("用户未登录"); ReportSummaryVO vo = reportSummaryService.getDetail(id); if (vo == null) return Result.error("报告不存在"); return Result.success(vo); } @PostMapping("/download/{id}") public Result download(@PathVariable Long id, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); if (userId == null) return Result.error("用户未登录"); ReportSummaryVO vo = reportSummaryService.getDetail(id); if (vo == null) return Result.error("报告不存在"); if (!vo.getHasFile()) return Result.error("该报告无文件可下载"); Object data = new Object() { public String getFileUrl() { return vo.getFileUrl(); } public String getFileName() { return vo.getFileName(); } public Long getFileSize() { return vo.getFileSize(); } }; return Result.success(data); } } ``` - [ ] Step 2: `mvn clean compile` 验证 --- ## Task 7: 后端 — Admin Controller + Sync Task **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportSummaryAdminController.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/task/ReportSummarySyncTask.java` **Consumes:** Task 4 (ReportSummaryService) **Produces:** 管理端 API + 定时任务 - [ ] Step 1: 创建 `ReportSummaryAdminController.java`: ```java package com.etotem.cfc.controller.admin; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.ReportListQueryDTO; import com.etotem.cfc.dto.ReportSummaryVO; import com.etotem.cfc.dto.ReportSyncResultDTO; import com.etotem.cfc.service.ReportSummaryService; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; 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; @Slf4j @RestController @RequestMapping("/api/admin/report-summary") public class ReportSummaryAdminController { @Resource private ReportSummaryService reportSummaryService; @PostMapping("/list") public Result adminList(@RequestBody ReportListQueryDTO query) { Page page = reportSummaryService.adminPageList(query); return Result.success(page); } @PostMapping("/sync") public Result sync() { ReportSyncResultDTO result = reportSummaryService.fullSync(); String msg = "同步完成: 新增" + result.getNewCount() + "条, 更新" + result.getUpdateCount() + "条, 废弃" + result.getDiscardedCount() + "条, 错误" + result.getErrorCount() + "条"; return Result.success(msg, result); } } ``` - [ ] Step 2: 创建 `ReportSummarySyncTask.java` — 参考 `OrderTimeoutCancelTask.java` 的 `@Scheduled` 模式: ```java package com.etotem.cfc.task; import com.etotem.cfc.dto.ReportSyncResultDTO; import com.etotem.cfc.service.ReportSummaryService; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.Resource; @Slf4j @Component public class ReportSummarySyncTask { @Resource private ReportSummaryService reportSummaryService; /** * 每日凌晨 2:00 全量校准汇总表 */ @Scheduled(cron = "0 0 2 * * ?") public void scheduledSync() { log.info("开始定时同步报告汇总表..."); try { ReportSyncResultDTO result = reportSummaryService.fullSync(); log.info("定时同步完成: new={}, update={}, discarded={}, error={}", result.getNewCount(), result.getUpdateCount(), result.getDiscardedCount(), result.getErrorCount()); } catch (Exception e) { log.error("定时同步失败", e); } } } ``` - [ ] Step 3: `mvn clean compile` 验证 --- ## Task 8: 小程序前端 — ProfileMenu 入口 **Files:** - Modify: `cfc-frontend/pages/profile/components/ProfileMenu.vue` **Produces:** 在「个人」分组添加「📋 报告管理」菜单项 - [ ] Step 1: 在 `ProfileMenu.vue` 的「个人」分组(`menu-group` 内)添加菜单项。参考现有 `goToDailyTasks` 模式: 在 `pages/profile/components/ProfileMenu.vue` 的「个人」分组的 `` 之前(第 22 行 `` 之后、第 23 行 `` 之前),添加: ```vue 📋 报告管理 › ``` > 位置:在 `goToOnboarding` 的 `` 之后,``(个人分组结束)之前。即第 21 行 `` 之后。 - [ ] Step 2: 在 `ProfileMenu.vue` 的 methods 中添加导航方法: ```js goToReportManagement() { uni.navigateTo({ url: '/pages/profile/report-management' }) } ``` - [ ] Step 3: 确认修改正确,无语法错误 --- ## Task 9: 小程序前端 — report-management.vue 主页面 **Files:** - Create: `cfc-frontend/pages/profile/report-management.vue` - Modify: `cfc-frontend/utils/api.js` (添加 API 函数) - Modify: `cfc-frontend/pages.json` (注册新页面) **Produces:** 报告管理页面(类型Tab + 搜索 + 卡片列表 + 分页) - [ ] Step 1: 在 `utils/api.js` 末尾添加 API 函数: ```js export const getReportSummaryList = (params) => request('/api/report-summary/list', 'POST', params) export const getReportSummaryDetail = (id) => request('/api/report-summary/detail/' + id, 'POST', {}) export const downloadReportSummary = (id) => request('/api/report-summary/download/' + id, 'POST', {}) ``` - [ ] Step 2: 在 `pages.json` 中注册新页面。找到 `pages/profile` 所在的 subPackage(搜索 `"root": "pages/profile"`),在其 `pages` 数组中添加: ```json { "path": "report-management", "style": { "navigationBarTitleText": "报告管理" } }, { "path": "report-detail", "style": { "navigationBarTitleText": "报告详情" } } ``` - [ ] Step 3: 创建 `pages/profile/report-management.vue`: ```vue ``` - [ ] Step 4: 编译验证(`npm run build:mp-weixin` 在 `cfc-frontend/` 目录) --- ## Task 10: 小程序前端 — report-detail.vue 统一详情视图 **Files:** - Create: `cfc-frontend/pages/profile/report-detail.vue` **Consumes:** Task 9 (API 函数) **Produces:** 统一报告详情页(有文件显示预览/下载,无文件显示结构化数据,支持分享) - [ ] Step 1: 在 `utils/api.js` 中确保 `getReportSummaryDetail` 和 `downloadReportSummary` 已定义(Task 9 Step 1 已添加) - [ ] Step 2: 创建 `pages/profile/report-detail.vue`: ```vue ``` - [ ] Step 3: `npm run build:mp-weixin` 编译验证 --- ## Task 11: Web管理端 — ReportManagement.vue **Files:** - Create: `cfc-web/src/views/admin/ReportManagement.vue` - Modify: `cfc-web/src/views/Layout.vue` (菜单入口) - Modify: `cfc-web/src/router/index.js` (路由) **Produces:** 管理端报告管理页面(表格 + 筛选 + 分页) - [ ] Step 1: 创建 `cfc-web/src/views/admin/ReportManagement.vue`: ```vue ``` - [ ] Step 2: 在 `cfc-web/src/router/index.js` 中添加路由: ```js { path: 'report-management', name: 'ReportManagement', component: () => import('@/views/admin/ReportManagement.vue'), meta: { title: '报告管理', perm: 'report:management' } } ``` - [ ] Step 3: 在 `cfc-web/src/views/Layout.vue` 左侧菜单中添加菜单项,参考现有菜单结构(搜索 `health-reports` 找到分组位置),添加: ```js // 在 menuItems 数组中找到合适位置(如"健康"或"系统管理"分组),添加: { path: '/admin/report-management', title: '报告管理', icon: 'el-icon-document', perm: 'report:management' } ``` > ⚠️ 需确认 `Layout.vue` 中菜单数据的具体结构(可能是一个数组或对象),根据现有模式适配。 - [ ] Step 4: `cd cfc-web && npm run serve` 验证 --- ## 最终验证 - [ ] `cd cfc-backend && mvn clean compile` — 后端编译通过 - [ ] 检查所有 4 个源 Service 的 sync 调用已正确注入 - [ ] 小程序端:从「我的」页点击「报告管理」→ 看到报告列表 → 点击卡片 → 看到详情 - [ ] Web 管理端:从菜单点击「报告管理」→ 看到表格 → 点击「同步数据」→ 看到同步结果 - [ ] `fullSync()` 能正确处理已删除的源记录(标记为 discarded) - [ ] 小程序禁用 `?.` 可选链、禁用 CSS Grid — 全部使用 `&&` 和 flexbox --- ## 文件清单汇总 ### 后端 (cfc-backend) | 文件 | 操作 | Task | |------|------|------| | `entity/ReportSummary.java` | 新建 | 2 | | `mapper/ReportSummaryMapper.java` | 新建 | 2 | | `mapper/ReportSummaryMapper.xml` | 新建 | 2 | | `dto/ReportSummaryVO.java` | 新建 | 3 | | `dto/ReportListQueryDTO.java` | 新建 | 3 | | `dto/ReportSyncResultDTO.java` | 新建 | 3 | | `service/ReportSummaryService.java` | 新建 | 4 | | `controller/ReportSummaryController.java` | 新建 | 6 | | `controller/admin/ReportSummaryAdminController.java` | 新建 | 7 | | `task/ReportSummarySyncTask.java` | 新建 | 7 | | `config/DatabaseInitializer.java` | 修改 | 1 | | `resources/schema.sql` | 修改 | 1 | | `service/HealthReportService.java` | 修改 | 5 | | `service/DanReportUploadService.java` | 修改 | 5 | | `service/InsurancePolicyService.java` | 修改 | 5 | ### 小程序 (cfc-frontend) | 文件 | 操作 | Task | |------|------|------| | `pages/profile/report-management.vue` | 新建 | 9 | | `pages/profile/report-detail.vue` | 新建 | 10 | | `pages/profile/components/ProfileMenu.vue` | 修改 | 8 | | `utils/api.js` | 修改 | 9 | | `pages.json` | 修改 | 9 | ### Web管理端 (cfc-web) | 文件 | 操作 | Task | |------|------|------| | `src/views/admin/ReportManagement.vue` | 新建 | 11 | | `src/views/Layout.vue` | 修改 | 11 | | `src/router/index.js` | 修改 | 11 |