Parcourir la source

feat(backend): 报告指纹解析系统 - 类型注册/指纹识别/未知聚类/解析器导入

- DatabaseInitializer 迁移186-190: report_type_registry/report_fingerprint/
  report_unknown_cluster/report_unknown_upload/report_parser_import 五表
- 新增 ReportParserController + ReportParserAdminController
- 新增 5 entity + 5 mapper + 3 service(ReportFingerprintService/
  ReportImportService/ReportParserDispatchService)
- schema.sql 同步 5 张新表定义
Xiaogang Liao il y a 1 mois
Parent
commit
2cb24699fb
23 fichiers modifiés avec 1569 ajouts et 1 suppressions
  1. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 71 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java
  3. 286 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  4. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFingerprint.java
  5. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportParserImport.java
  6. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java
  7. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownCluster.java
  8. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownUpload.java
  9. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportFingerprintMapper.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportParserImportMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportTypeRegistryMapper.java
  12. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportUnknownClusterMapper.java
  13. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportUnknownUploadMapper.java
  14. 178 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java
  15. 265 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportImportService.java
  16. 212 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportParserDispatchService.java
  17. 73 0
      cfc-backend/src/main/resources/schema.sql
  18. 26 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
  19. 63 0
      cfc-langgraph/app/agents/report_parse_agent.py
  20. 33 1
      cfc-langgraph/app/api/report_parse.py
  21. 25 0
      cfc-web/src/router/index.js
  22. 8 0
      cfc-web/src/views/Layout.vue
  23. 37 0
      docs/参考资料/extract_full_report_v5.py

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

@@ -8185,5 +8185,103 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("存量挑战补全participant失败(可能已处理): " + e.getMessage());
 		}
+
+		// ===== 报告指纹检测与解析器调度系统 =====
+
+		// 迁移186: 创建 report_type_registry 表(报告类型注册表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_type_registry (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing', " +
+				"family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他', " +
+				"display_name VARCHAR(100) NOT NULL COMMENT '显示名称', " +
+				"description VARCHAR(500) DEFAULT NULL COMMENT '描述', " +
+				"parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径', " +
+				"min_confidence INT DEFAULT 10 COMMENT '识别阈值', " +
+				"is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态', " +
+				"source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated', " +
+				"review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected', " +
+				"version VARCHAR(20) DEFAULT '1.0' COMMENT '版本', " +
+				"created_by BIGINT DEFAULT NULL COMMENT '创建人ID', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_type_id (type_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表'");
+			log.info("已创建report_type_registry表");
+		} catch (Exception e) {
+			log.warn("创建 report_type_registry 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移187: 创建 report_fingerprint 表(指纹规则)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_fingerprint (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id', " +
+				"pattern VARCHAR(200) NOT NULL COMMENT '文本特征', " +
+				"weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)', " +
+				"is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则', " +
+				"sort_order INT DEFAULT 0 COMMENT '排序', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_fp_type (type_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表'");
+			log.info("已创建report_fingerprint表");
+		} catch (Exception e) {
+			log.warn("创建 report_fingerprint 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移188: 创建 report_unknown_cluster 表(未知报告聚类,自学习用)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_cluster (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)', " +
+				"common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)', " +
+				"llm_template TEXT COMMENT 'LLM解析结果模板JSON', " +
+				"report_count INT DEFAULT 0 COMMENT '累计报告数', " +
+				"status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported', " +
+				"generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_cluster_key (cluster_key)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表'");
+			log.info("已创建report_unknown_cluster表");
+		} catch (Exception e) {
+			log.warn("创建 report_unknown_cluster 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移189: 创建 report_unknown_upload 表(未知报告明细)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_unknown_upload (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id', " +
+				"file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径', " +
+				"file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希', " +
+				"llm_result TEXT COMMENT 'LLM解析结果JSON', " +
+				"uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_unknown_cluster (cluster_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表'");
+			log.info("已创建report_unknown_upload表");
+		} catch (Exception e) {
+			log.warn("创建 report_unknown_upload 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移190: 创建 report_parser_import 表(外部导入记录)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_parser_import (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"package_name VARCHAR(100) NOT NULL COMMENT '导入包名称', " +
+				"package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径', " +
+				"fingerprint_json TEXT COMMENT '指纹规则JSON', " +
+				"parser_script TEXT COMMENT '解析器脚本内容', " +
+				"status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected', " +
+				"imported_by BIGINT DEFAULT NULL COMMENT '导入人ID', " +
+				"reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID', " +
+				"review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表'");
+			log.info("已创建report_parser_import表");
+		} catch (Exception e) {
+			log.warn("创建 report_parser_import 表失败(可能已存在): " + e.getMessage());
+		}
 	}
 }

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java

@@ -0,0 +1,71 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ReportFingerprintService;
+import com.etotem.cfc.service.ReportParserDispatchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/report-parser")
+public class ReportParserController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserController.class);
+
+    @Resource
+    private ReportFingerprintService fingerprintService;
+
+    @Resource
+    private ReportParserDispatchService dispatchService;
+
+    @PostMapping("/upload")
+    public Result<Map<String, Object>> uploadAndParse(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "fileName", required = false) String fileName) {
+        if (file == null || file.isEmpty()) {
+            return Result.error("请选择文件");
+        }
+
+        try {
+            String uploadDir = "/tmp/report_uploads";
+            File dir = new File(uploadDir);
+            if (!dir.exists()) dir.mkdirs();
+
+            String savedName = UUID.randomUUID().toString() + "_" +
+                    (fileName != null ? fileName : file.getOriginalFilename());
+            File savedFile = new File(dir, savedName);
+            file.transferTo(savedFile);
+
+            String pdfPath = savedFile.getAbsolutePath();
+            String fname = file.getOriginalFilename();
+            log.info("报告上传解析: file={}, path={}", fname, pdfPath);
+            return dispatchService.parseReport(pdfPath, fname);
+
+        } catch (Exception e) {
+            log.error("上传解析失败", e);
+            return Result.error("上传解析失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/detect")
+    public Result<Map<String, Object>> detect(@RequestBody Map<String, Object> params) {
+        String filePath = (String) params.get("filePath");
+        String fileName = (String) params.get("fileName");
+        if (filePath == null) {
+            return Result.error("filePath 不能为空");
+        }
+        File file = new File(filePath);
+        if (!file.exists()) {
+            return Result.error("文件不存在: " + filePath);
+        }
+        Map<String, Object> result = fingerprintService.detect(filePath, fileName);
+        return Result.success(result);
+    }
+}

+ 286 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -0,0 +1,286 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.ReportImportService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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 java.util.*;
+
+@RestController
+@RequestMapping("/api/admin/report-parser")
+public class ReportParserAdminController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserAdminController.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Resource
+    private ReportParserImportMapper importMapper;
+
+    @Resource
+    private ReportImportService reportImportService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // ==================== 报告类型管理 ====================
+
+    @PostMapping("/types")
+    public Result<Page<ReportTypeRegistry>> listTypes(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 999;
+        Page<ReportTypeRegistry> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportTypeRegistry> wrapper = new LambdaQueryWrapper<ReportTypeRegistry>()
+                .orderByDesc(ReportTypeRegistry::getId);
+        return Result.success(typeRegistryMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/types/save")
+    public Result<Void> saveType(@RequestBody ReportTypeRegistry type) {
+        if (type.getTypeId() == null || type.getTypeId().isEmpty()) {
+            return Result.error("typeId 不能为空");
+        }
+        if (type.getDisplayName() == null || type.getDisplayName().isEmpty()) {
+            return Result.error("displayName 不能为空");
+        }
+        if (type.getId() != null) {
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.updateById(type);
+        } else {
+            Long exists = typeRegistryMapper.selectCount(
+                    new LambdaQueryWrapper<ReportTypeRegistry>()
+                            .eq(ReportTypeRegistry::getTypeId, type.getTypeId()));
+            if (exists != null && exists > 0) {
+                return Result.error("typeId 已存在");
+            }
+            type.setIsActive(type.getIsActive() != null ? type.getIsActive() : true);
+            type.setSource(type.getSource() != null ? type.getSource() : "prebuilt");
+            type.setReviewStatus(type.getReviewStatus() != null ? type.getReviewStatus() : "approved");
+            type.setVersion(type.getVersion() != null ? type.getVersion() : "1.0");
+            type.setMinConfidence(type.getMinConfidence() != null ? type.getMinConfidence() : 10);
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+        }
+        return Result.success();
+    }
+
+    @PostMapping("/types/toggle")
+    public Result<Void> toggleType(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        Boolean isActive = params.get("isActive") != null ? (Boolean) params.get("isActive") : false;
+        if (typeId == null) return Result.error("typeId 不能为空");
+        ReportTypeRegistry type = typeRegistryMapper.selectOne(
+                new LambdaQueryWrapper<ReportTypeRegistry>().eq(ReportTypeRegistry::getTypeId, typeId));
+        if (type == null) return Result.error("类型不存在");
+        type.setIsActive(isActive);
+        type.setUpdatedAt(new Date());
+        typeRegistryMapper.updateById(type);
+        return Result.success();
+    }
+
+    @PostMapping("/types/delete")
+    public Result<Void> deleteType(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        if (typeId == null) return Result.error("typeId 不能为空");
+        typeRegistryMapper.delete(new LambdaQueryWrapper<ReportTypeRegistry>()
+                .eq(ReportTypeRegistry::getTypeId, typeId));
+        fingerprintMapper.delete(new LambdaQueryWrapper<ReportFingerprint>()
+                .eq(ReportFingerprint::getTypeId, typeId));
+        return Result.success();
+    }
+
+    // ==================== 指纹规则管理 ====================
+
+    @PostMapping("/fingerprints/list")
+    public Result<List<ReportFingerprint>> listFingerprints(@RequestBody Map<String, Object> params) {
+        String typeId = (String) params.get("typeId");
+        if (typeId == null) return Result.error("typeId 不能为空");
+        List<ReportFingerprint> list = fingerprintMapper.selectList(
+                new LambdaQueryWrapper<ReportFingerprint>()
+                        .eq(ReportFingerprint::getTypeId, typeId)
+                        .orderByAsc(ReportFingerprint::getSortOrder));
+        return Result.success(list);
+    }
+
+    @PostMapping("/fingerprints/save")
+    public Result<Void> saveFingerprint(@RequestBody ReportFingerprint fp) {
+        if (fp.getTypeId() == null || fp.getPattern() == null) {
+            return Result.error("typeId 和 pattern 不能为空");
+        }
+        fp.setWeight(fp.getWeight() != null ? fp.getWeight() : 10);
+        fp.setIsExclusion(fp.getIsExclusion() != null ? fp.getIsExclusion() : false);
+        fp.setSortOrder(fp.getSortOrder() != null ? fp.getSortOrder() : 0);
+        fp.setCreatedAt(new Date());
+        fingerprintMapper.insert(fp);
+        return Result.success();
+    }
+
+    @PostMapping("/fingerprints/delete")
+    public Result<Void> deleteFingerprint(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        if (id == null) return Result.error("id 不能为空");
+        fingerprintMapper.deleteById(id);
+        return Result.success();
+    }
+
+    // ==================== 导入管理 ====================
+
+    @PostMapping("/imports")
+    public Result<Page<ReportParserImport>> listImports(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        Page<ReportParserImport> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportParserImport> wrapper = new LambdaQueryWrapper<ReportParserImport>()
+                .orderByDesc(ReportParserImport::getCreatedAt);
+        return Result.success(importMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/imports/import")
+    public Result<ReportParserImport> uploadImport(@RequestBody Map<String, Object> params) {
+        String jsonContent = (String) params.get("jsonContent");
+        String fileName = (String) params.get("fileName");
+        Long userId = params.get("userId") != null ? ((Number) params.get("userId")).longValue() : null;
+        if (jsonContent == null) return Result.error("jsonContent 不能为空");
+        return reportImportService.importPackage(fileName, jsonContent, userId);
+    }
+
+    @PostMapping("/imports/approve")
+    public Result<Map<String, Object>> approveImport(@RequestBody Map<String, Object> params) {
+        Long importId = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        Long reviewerId = params.get("reviewerId") != null ? ((Number) params.get("reviewerId")).longValue() : null;
+        if (importId == null) return Result.error("id 不能为空");
+        return reportImportService.approveImport(importId, reviewerId);
+    }
+
+    @PostMapping("/imports/reject")
+    public Result<Void> rejectImport(@RequestBody Map<String, Object> params) {
+        Long importId = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        Long reviewerId = params.get("reviewerId") != null ? ((Number) params.get("reviewerId")).longValue() : null;
+        String note = (String) params.get("note");
+        if (importId == null) return Result.error("id 不能为空");
+        return reportImportService.rejectImport(importId, reviewerId, note);
+    }
+
+    // ==================== 未知报告聚类 ====================
+
+    @PostMapping("/unknown-clusters")
+    public Result<Page<ReportUnknownCluster>> listClusters(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        String status = (String) params.get("status");
+        Page<ReportUnknownCluster> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<ReportUnknownCluster> wrapper = new LambdaQueryWrapper<ReportUnknownCluster>()
+                .orderByDesc(ReportUnknownCluster::getReportCount);
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(ReportUnknownCluster::getStatus, status);
+        }
+        return Result.success(unknownClusterMapper.selectPage(pageParam, wrapper));
+    }
+
+    @PostMapping("/unknown-uploads")
+    public Result<List<ReportUnknownUpload>> listUploads(@RequestBody Map<String, Object> params) {
+        Long clusterId = params.get("clusterId") != null ? ((Number) params.get("clusterId")).longValue() : null;
+        if (clusterId == null) return Result.error("clusterId 不能为空");
+        List<ReportUnknownUpload> list = unknownUploadMapper.selectList(
+                new LambdaQueryWrapper<ReportUnknownUpload>()
+                        .eq(ReportUnknownUpload::getClusterId, clusterId)
+                        .orderByDesc(ReportUnknownUpload::getUploadedAt));
+        return Result.success(list);
+    }
+
+    // ==================== 自学习生成 ====================
+
+    @PostMapping("/generate-type")
+    public Result<Map<String, Object>> generateType(@RequestBody Map<String, Object> params) {
+        Long clusterId = params.get("clusterId") != null ? ((Number) params.get("clusterId")).longValue() : null;
+        if (clusterId == null) return Result.error("clusterId 不能为空");
+
+        ReportUnknownCluster cluster = unknownClusterMapper.selectById(clusterId);
+        if (cluster == null) return Result.error("聚类不存在");
+        if (!"ready".equals(cluster.getStatus())) {
+            return Result.error("该聚类不可生成,当前状态: " + cluster.getStatus());
+        }
+
+        try {
+            String clusterKey = cluster.getClusterKey();
+            String typeId = "auto_" + clusterKey.toLowerCase().replaceAll("[^a-z0-9]", "_").replaceAll("_+", "_");
+
+            // 创建报告类型
+            ReportTypeRegistry type = new ReportTypeRegistry();
+            type.setTypeId(typeId);
+            type.setFamily("auto_generated");
+            type.setDisplayName("自动生成 - " + typeId);
+            type.setDescription("由自学习系统自动生成,基于 " + cluster.getReportCount() + " 份同类报告");
+            type.setParserPath(typeId + ".py");
+            type.setMinConfidence(10);
+            type.setIsActive(false);
+            type.setSource(ReportTypeRegistry.SOURCE_AUTO_GENERATED);
+            type.setReviewStatus(ReportTypeRegistry.REVIEW_PENDING);
+            type.setVersion("1.0");
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+
+            // 从公共签名生成指纹规则
+            String signatures = cluster.getCommonSignatures();
+            if (signatures != null && !signatures.isEmpty()) {
+                // 提取可能的文本特征作为指纹
+                String[] candidates = signatures.split("[\n\r]+");
+                Set<String> seen = new HashSet<>();
+                int sort = 0;
+                for (String line : candidates) {
+                    String trimmed = line.trim();
+                    if (trimmed.length() >= 4 && trimmed.length() <= 100 && !seen.contains(trimmed)) {
+                        seen.add(trimmed);
+                        ReportFingerprint fp = new ReportFingerprint();
+                        fp.setTypeId(typeId);
+                        fp.setPattern(trimmed);
+                        fp.setWeight(10);
+                        fp.setIsExclusion(false);
+                        fp.setSortOrder(sort++);
+                        fp.setCreatedAt(new Date());
+                        fingerprintMapper.insert(fp);
+                    }
+                }
+            }
+
+            // 更新聚类
+            cluster.setStatus("generated");
+            cluster.setGeneratedTypeId(typeId);
+            cluster.setUpdatedAt(new Date());
+            unknownClusterMapper.updateById(cluster);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("typeId", typeId);
+            result.put("displayName", type.getDisplayName());
+            result.put("fingerprintCount", type.getMinConfidence());
+            result.put("reviewStatus", "pending");
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("自学习生成类型失败 clusterId={}", clusterId, e);
+            return Result.error("生成失败: " + e.getMessage());
+        }
+    }
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFingerprint.java

@@ -0,0 +1,24 @@
+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_fingerprint")
+public class ReportFingerprint implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String typeId;
+    private String pattern;
+    private Integer weight;
+    private Boolean isExclusion;
+    private Integer sortOrder;
+    private Date createdAt;
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportParserImport.java

@@ -0,0 +1,32 @@
+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_parser_import")
+public class ReportParserImport implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String packageName;
+    private String packageFileUrl;
+    private String fingerprintJson;
+    private String parserScript;
+    private String status;
+    private Long importedBy;
+    private Long reviewedBy;
+    private String reviewNote;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String STATUS_PENDING = "pending";
+    public static final String STATUS_APPROVED = "approved";
+    public static final String STATUS_REJECTED = "rejected";
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -0,0 +1,39 @@
+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_type_registry")
+public class ReportTypeRegistry implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String typeId;
+    private String family;
+    private String displayName;
+    private String description;
+    private String parserPath;
+    private Integer minConfidence;
+    private Boolean isActive;
+    private String source;
+    private String reviewStatus;
+    private String version;
+    private Long createdBy;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String SOURCE_PREBUILT = "prebuilt";
+    public static final String SOURCE_IMPORTED = "imported";
+    public static final String SOURCE_AUTO_GENERATED = "auto_generated";
+
+    public static final String REVIEW_APPROVED = "approved";
+    public static final String REVIEW_PENDING = "pending";
+    public static final String REVIEW_REJECTED = "rejected";
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownCluster.java

@@ -0,0 +1,31 @@
+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_unknown_cluster")
+public class ReportUnknownCluster implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String clusterKey;
+    private String commonSignatures;
+    private String llmTemplate;
+    private Integer reportCount;
+    private String status;
+    private String generatedTypeId;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public static final String STATUS_COLLECTING = "collecting";
+    public static final String STATUS_READY = "ready";
+    public static final String STATUS_GENERATED = "generated";
+    public static final String STATUS_IMPORTED = "imported";
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportUnknownUpload.java

@@ -0,0 +1,23 @@
+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_unknown_upload")
+public class ReportUnknownUpload implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long clusterId;
+    private String fileUrl;
+    private String fileHash;
+    private String llmResult;
+    private Date uploadedAt;
+}

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

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

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

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

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

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

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

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

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

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

+ 178 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java

@@ -0,0 +1,178 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ReportFingerprint;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+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 javax.annotation.Resource;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 报告指纹检测服务
+ * 从 DB 加载指纹规则,调 Python 引擎进行检测
+ */
+@Service
+public class ReportFingerprintService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportFingerprintService.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Value("${python.executable:python3}")
+    private String pythonExecutable;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 检测 PDF 文件,返回匹配的报告类型
+     */
+    public Map<String, Object> detect(String pdfPath, String fileName) {
+        List<ReportTypeRegistry> activeTypes = typeRegistryMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getIsActive, true));
+
+        // 构建指纹规则 JSON
+        List<Map<String, Object>> rules = new ArrayList<>();
+        for (ReportTypeRegistry type : activeTypes) {
+            List<ReportFingerprint> fingerprints = fingerprintMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportFingerprint>()
+                            .eq(ReportFingerprint::getTypeId, type.getTypeId())
+                            .orderByAsc(ReportFingerprint::getSortOrder));
+
+            Map<String, Object> rule = new LinkedHashMap<>();
+            rule.put("typeId", type.getTypeId());
+            rule.put("minConfidence", type.getMinConfidence() != null ? type.getMinConfidence() : 10);
+            rule.put("parserPath", type.getParserPath());
+
+            List<Map<String, Object>> fps = new ArrayList<>();
+            for (ReportFingerprint fp : fingerprints) {
+                Map<String, Object> fpm = new LinkedHashMap<>();
+                fpm.put("pattern", fp.getPattern());
+                fpm.put("weight", fp.getWeight() != null ? fp.getWeight() : 10);
+                fpm.put("isExclusion", fp.getIsExclusion() != null && fp.getIsExclusion());
+                fps.add(fpm);
+            }
+            rule.put("fingerprints", fps);
+            rules.add(rule);
+        }
+
+        return callPythonEngine(rules, pdfPath, fileName);
+    }
+
+    /**
+     * 调用 Python 指纹引擎
+     */
+    private Map<String, Object> callPythonEngine(List<Map<String, Object>> rules, String pdfPath, String fileName) {
+        try {
+            // 将规则写入临时文件
+            File rulesFile = File.createTempFile("fp_rules_", ".json");
+            rulesFile.deleteOnExit();
+            objectMapper.writeValue(rulesFile, rules);
+
+            String scriptPath = getScriptPath("fingerprint_engine.py");
+            if (!new File(scriptPath).exists()) {
+                log.warn("指纹引擎脚本不存在: {}", scriptPath);
+                Map<String, Object> fallback = new LinkedHashMap<>();
+                fallback.put("typeId", "unknown");
+                fallback.put("reason", "script_not_found");
+                return fallback;
+            }
+
+            ProcessBuilder pb = new ProcessBuilder(
+                    pythonExecutable, scriptPath,
+                    "--rules", rulesFile.getAbsolutePath(),
+                    "--pdf", pdfPath,
+                    "--filename", fileName != null ? fileName : "");
+
+            pb.redirectErrorStream(true);
+            Process process = pb.start();
+
+            String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+            int exitCode = process.waitFor();
+
+            if (exitCode == 0 && !output.trim().isEmpty()) {
+                return objectMapper.readValue(output.trim(),
+                        new TypeReference<Map<String, Object>>() {});
+            } else {
+                log.warn("指纹引擎退出码={}, 输出={}", exitCode, output);
+                Map<String, Object> fallback = new LinkedHashMap<>();
+                fallback.put("typeId", "unknown");
+                fallback.put("reason", "exit_" + exitCode);
+                return fallback;
+            }
+        } catch (Exception e) {
+            log.error("调用指纹引擎失败", e);
+            Map<String, Object> fallback = new LinkedHashMap<>();
+            fallback.put("typeId", "unknown");
+            fallback.put("reason", e.getMessage());
+            return fallback;
+        }
+    }
+
+    /**
+     * 获取脚本路径,支持多种搜索位置
+     */
+    private String getScriptPath(String scriptName) {
+        String[] searchPaths = {
+            "docs/scripts/" + scriptName,
+            "../docs/scripts/" + scriptName,
+            "/app/cfc/docs/scripts/" + scriptName,
+            scriptName,
+        };
+        for (String path : searchPaths) {
+            if (new File(path).exists()) return path;
+        }
+        return scriptName;
+    }
+
+    /**
+     * 调用 Python 解析器脚本提取结构化数据
+     */
+    public Map<String, Object> runParser(String parserPath, String pdfPath) {
+        try {
+            String scriptPath = getScriptPath(parserPath);
+            if (!new File(scriptPath).exists()) {
+                log.warn("解析器脚本不存在: {}", scriptPath);
+                return null;
+            }
+
+            ProcessBuilder pb = new ProcessBuilder(
+                    pythonExecutable, scriptPath, pdfPath);
+            pb.redirectErrorStream(true);
+            Process process = pb.start();
+
+            String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+            int exitCode = process.waitFor();
+
+            if (exitCode == 0 && !output.trim().isEmpty()) {
+                // 尝试解析输出为 JSON
+                String trimmed = output.trim();
+                if (trimmed.startsWith("{")) {
+                    return objectMapper.readValue(trimmed,
+                            new TypeReference<Map<String, Object>>() {});
+                }
+            }
+            log.warn("解析器退出码={}, 输出前200字符={}", exitCode,
+                    output.length() > 200 ? output.substring(0, 200) : output);
+            return null;
+        } catch (Exception e) {
+            log.error("调用解析器失败", e);
+            return null;
+        }
+    }
+}

+ 265 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportImportService.java

@@ -0,0 +1,265 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportFingerprint;
+import com.etotem.cfc.entity.ReportParserImport;
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
+import com.etotem.cfc.mapper.ReportParserImportMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+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 javax.annotation.Resource;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * 外部报告解析器导入服务
+ * 支持导入指纹规则 + 解析脚本,注册为新报告类型
+ */
+@Service
+public class ReportImportService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportImportService.class);
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportFingerprintMapper fingerprintMapper;
+
+    @Resource
+    private ReportParserImportMapper importMapper;
+
+    @Value("${report.parser.scripts.path:docs/scripts/parsers}")
+    private String scriptsPath;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 解析并导入 JSON 格式的导入包
+     * 返回解析后的导入记录(pending 状态,待人工审核)
+     */
+    public Result<ReportParserImport> importPackage(String fileName, String jsonContent, Long userId) {
+        try {
+            Map<String, Object> pkg = objectMapper.readValue(jsonContent,
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
+            return doImport(fileName, jsonContent, pkg, userId);
+        } catch (Exception e) {
+            log.error("解析导入包JSON失败", e);
+            return Result.error("导入包格式错误: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 解析并导入 JSON 文件
+     */
+    public Result<ReportParserImport> importPackageFile(File jsonFile, Long userId) {
+        try {
+            String jsonContent = new String(Files.readAllBytes(jsonFile.toPath()), StandardCharsets.UTF_8);
+            return importPackage(jsonFile.getName(), jsonContent, userId);
+        } catch (Exception e) {
+            log.error("读取导入包文件失败", e);
+            return Result.error("读取导入包失败: " + e.getMessage());
+        }
+    }
+
+    private Result<ReportParserImport> doImport(String fileName, String jsonContent,
+                                                 Map<String, Object> pkg, Long userId) {
+        // 校验必填字段
+        String packageName = str(pkg.get("packageName"));
+        String typeId = str(pkg.get("typeId"));
+        String displayName = str(pkg.get("displayName"));
+        String parserScript = str(pkg.get("parserScript"));
+
+        if (packageName == null || packageName.isEmpty()) {
+            return Result.error("导入包缺少 packageName");
+        }
+        if (typeId == null || typeId.isEmpty()) {
+            return Result.error("导入包缺少 typeId");
+        }
+        if (displayName == null || displayName.isEmpty()) {
+            return Result.error("导入包缺少 displayName");
+        }
+        if (!isValidTypeId(typeId)) {
+            return Result.error("typeId 格式不正确,只能包含字母、数字、下划线");
+        }
+
+        // 校验 typeId 是否已存在
+        Long exists = typeRegistryMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getTypeId, typeId));
+        if (exists != null && exists > 0) {
+            return Result.error("typeId 已存在: " + typeId);
+        }
+
+        // 校验指纹规则
+        Object fpsObj = pkg.get("fingerprints");
+        List<Map<String, Object>> fingerprints = new ArrayList<>();
+        if (fpsObj instanceof List) {
+            for (Object fp : (List<?>) fpsObj) {
+                if (fp instanceof Map) {
+                    fingerprints.add((Map<String, Object>) fp);
+                }
+            }
+        }
+        if (fingerprints.isEmpty()) {
+            return Result.error("导入包缺少 fingerprints 指纹规则");
+        }
+
+        // 校验指纹字段
+        for (Map<String, Object> fp : fingerprints) {
+            String pattern = str(fp.get("pattern"));
+            if (pattern == null || pattern.isEmpty()) {
+                return Result.error("指纹规则缺少 pattern 文本特征");
+            }
+        }
+
+        // 创建导入记录(pending 状态)
+        ReportParserImport record = new ReportParserImport();
+        record.setPackageName(packageName);
+        record.setPackageFileUrl(fileName);
+        record.setFingerprintJson(jsonContent);
+        record.setParserScript(parserScript);
+        record.setStatus(ReportParserImport.STATUS_PENDING);
+        record.setImportedBy(userId);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        importMapper.insert(record);
+
+        log.info("导入包已记录待审核: packageName={}, typeId={}, importId={}",
+                packageName, typeId, record.getId());
+        return Result.success(record);
+    }
+
+    /**
+     * 审核通过导入:注册报告类型 + 指纹规则 + 部署解析脚本
+     */
+    public Result<Map<String, Object>> approveImport(Long importId, Long reviewerId) {
+        ReportParserImport record = importMapper.selectById(importId);
+        if (record == null) {
+            return Result.error("导入记录不存在");
+        }
+        if (!ReportParserImport.STATUS_PENDING.equals(record.getStatus())) {
+            return Result.error("该记录已" + record.getStatus());
+        }
+
+        try {
+            Map<String, Object> pkg = objectMapper.readValue(record.getFingerprintJson(),
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
+
+            String typeId = str(pkg.get("typeId"));
+            String family = str(pkg.get("family"));
+            String displayName = str(pkg.get("displayName"));
+            String description = str(pkg.get("description"));
+            String parserScript = str(pkg.get("parserScript"));
+
+            // 1. 注册报告类型
+            ReportTypeRegistry type = new ReportTypeRegistry();
+            type.setTypeId(typeId);
+            type.setFamily(family != null ? family : "custom");
+            type.setDisplayName(displayName);
+            type.setDescription(description);
+            type.setParserPath(typeId + ".py");
+            type.setMinConfidence(10);
+            type.setIsActive(true);
+            type.setSource(ReportTypeRegistry.SOURCE_IMPORTED);
+            type.setReviewStatus(ReportTypeRegistry.REVIEW_APPROVED);
+            type.setVersion("1.0");
+            type.setCreatedBy(reviewerId);
+            type.setCreatedAt(new Date());
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.insert(type);
+
+            // 2. 注册指纹规则
+            Object fpsObj = pkg.get("fingerprints");
+            if (fpsObj instanceof List) {
+                int sort = 0;
+                for (Object fp : (List<?>) fpsObj) {
+                    if (fp instanceof Map) {
+                        Map<String, Object> fpm = (Map<String, Object>) fp;
+                        ReportFingerprint fpEnt = new ReportFingerprint();
+                        fpEnt.setTypeId(typeId);
+                        fpEnt.setPattern(str(fpm.get("pattern")));
+                        fpEnt.setWeight(fpm.get("weight") != null
+                                ? ((Number) fpm.get("weight")).intValue() : 10);
+                        fpEnt.setIsExclusion(fpm.get("isExclusion") != null
+                                && Boolean.TRUE.equals(fpm.get("isExclusion")));
+                        fpEnt.setSortOrder(sort++);
+                        fpEnt.setCreatedAt(new Date());
+                        fingerprintMapper.insert(fpEnt);
+                    }
+                }
+            }
+
+            // 3. 部署解析脚本
+            if (parserScript != null && !parserScript.isEmpty()) {
+                deployParserScript(typeId, parserScript);
+            }
+
+            // 4. 更新导入记录
+            record.setStatus(ReportParserImport.STATUS_APPROVED);
+            record.setReviewedBy(reviewerId);
+            record.setUpdatedAt(new Date());
+            importMapper.updateById(record);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("typeId", typeId);
+            result.put("displayName", displayName);
+            result.put("importId", importId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("审核通过导入失败 importId={}", importId, e);
+            return Result.error("导入失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 驳回导入
+     */
+    public Result<Void> rejectImport(Long importId, Long reviewerId, String note) {
+        ReportParserImport record = importMapper.selectById(importId);
+        if (record == null) {
+            return Result.error("导入记录不存在");
+        }
+        if (!ReportParserImport.STATUS_PENDING.equals(record.getStatus())) {
+            return Result.error("该记录已" + record.getStatus());
+        }
+        record.setStatus(ReportParserImport.STATUS_REJECTED);
+        record.setReviewedBy(reviewerId);
+        record.setReviewNote(note);
+        record.setUpdatedAt(new Date());
+        importMapper.updateById(record);
+        return Result.success();
+    }
+
+    private void deployParserScript(String typeId, String scriptContent) {
+        try {
+            File dir = new File(scriptsPath);
+            if (!dir.exists() && !dir.mkdirs()) {
+                log.warn("无法创建解析脚本目录: {}", scriptsPath);
+                return;
+            }
+            File scriptFile = new File(dir, typeId + ".py");
+            Files.write(scriptFile.toPath(), scriptContent.getBytes(StandardCharsets.UTF_8));
+            log.info("解析脚本已部署: {}", scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.error("部署解析脚本失败", e);
+        }
+    }
+
+    private boolean isValidTypeId(String typeId) {
+        return Pattern.matches("^[a-zA-Z0-9_]+$", typeId);
+    }
+
+    private String str(Object o) {
+        return o == null ? null : String.valueOf(o);
+    }
+}

+ 212 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportParserDispatchService.java

@@ -0,0 +1,212 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportUnknownCluster;
+import com.etotem.cfc.entity.ReportUnknownUpload;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
+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 javax.annotation.Resource;
+import java.io.File;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+
+/**
+ * 报告解析调度服务
+ * 1. 指纹检测 → 2a. 专用解析器 / 2b. LLM 兜底
+ */
+@Service
+public class ReportParserDispatchService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParserDispatchService.class);
+
+    @Resource
+    private ReportFingerprintService fingerprintService;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Value("${cfc.langgraph.url:http://localhost:9000}")
+    private String langgraphUrl;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final HttpClient httpClient = HttpClient.newHttpClient();
+
+    private static final int AUTO_LEARN_THRESHOLD = 3;
+
+    /**
+     * 解析报告:指纹检测 → 专用解析器 或 LLM 兜底
+     */
+    public Result<Map<String, Object>> parseReport(String pdfPath, String fileName) {
+        // 1. 指纹检测
+        Map<String, Object> fpResult = fingerprintService.detect(pdfPath, fileName);
+        String typeId = (String) fpResult.get("typeId");
+        log.info("指纹检测结果: typeId={}, score={}", typeId, fpResult.get("score"));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fingerprintResult", fpResult);
+
+        // 2a. 已知类型 → 调解析器
+        if (!"unknown".equals(typeId) && fpResult.get("parserPath") != null) {
+            String parserPath = (String) fpResult.get("parserPath");
+            Map<String, Object> parsed = fingerprintService.runParser(parserPath, pdfPath);
+            result.put("typeId", typeId);
+            result.put("parsedData", parsed);
+            result.put("parseMethod", "dedicated_parser");
+            return Result.success(result);
+        }
+
+        // 2b. Unknown → LLM 兜底
+        try {
+            String llmResult = callLlmFallback(pdfPath);
+            Map<String, Object> llmParsed = objectMapper.readValue(llmResult,
+                    new TypeReference<Map<String, Object>>() {});
+
+            result.put("typeId", "unknown");
+            result.put("parsedData", llmParsed);
+            result.put("parseMethod", "llm_fallback");
+
+            // 记录到聚类表
+            recordUnknown(pdfPath, llmParsed, fileName);
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("LLM 兜底解析失败", e);
+            return Result.error("LLM 解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 调用 cfc-langgraph 的 LLM 解析端点
+     */
+    private String callLlmFallback(String pdfPath) throws Exception {
+        String url = langgraphUrl + "/api/v1/report/parse";
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("file_path", pdfPath);
+        body.put("family_id", null);
+        body.put("user_id", null);
+
+        String json = objectMapper.writeValueAsString(body);
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(url))
+                .header("Content-Type", "application/json")
+                .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
+                .timeout(java.time.Duration.ofSeconds(120))
+                .build();
+
+        HttpResponse<String> response = httpClient.send(request,
+                HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+
+        if (response.statusCode() == 200) {
+            return response.body();
+        }
+        throw new RuntimeException("LLM 返回状态码: " + response.statusCode());
+    }
+
+    /**
+     * 记录未知报告到聚类表(自学习用)
+     */
+    private void recordUnknown(String pdfPath, Map<String, Object> llmResult, String fileName) {
+        try {
+            String fileHash = computeFileHash(pdfPath);
+            // 提取文本特征用于聚类
+            String textFeatures = extractTextFeatures(pdfPath);
+            String clusterKey = computeClusterKey(textFeatures);
+
+            // 查找或创建聚类
+            ReportUnknownCluster cluster = unknownClusterMapper.selectOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportUnknownCluster>()
+                            .eq(ReportUnknownCluster::getClusterKey, clusterKey));
+
+            if (cluster == null) {
+                cluster = new ReportUnknownCluster();
+                cluster.setClusterKey(clusterKey);
+                cluster.setCommonSignatures(textFeatures);
+                cluster.setLlmTemplate(objectMapper.writeValueAsString(llmResult));
+                cluster.setReportCount(1);
+                cluster.setStatus(ReportUnknownCluster.STATUS_COLLECTING);
+                cluster.setCreatedAt(new Date());
+                cluster.setUpdatedAt(new Date());
+                unknownClusterMapper.insert(cluster);
+            } else {
+                cluster.setReportCount(cluster.getReportCount() == null ? 1 : cluster.getReportCount() + 1);
+                cluster.setUpdatedAt(new Date());
+                // 满 AUTO_LEARN_THRESHOLD 份标记 ready
+                if (cluster.getReportCount() >= AUTO_LEARN_THRESHOLD
+                        && ReportUnknownCluster.STATUS_COLLECTING.equals(cluster.getStatus())) {
+                    cluster.setStatus(ReportUnknownCluster.STATUS_READY);
+                }
+                unknownClusterMapper.updateById(cluster);
+            }
+
+            // 记录上传明细
+            ReportUnknownUpload upload = new ReportUnknownUpload();
+            upload.setClusterId(cluster.getId());
+            upload.setFileUrl(pdfPath);
+            upload.setFileHash(fileHash);
+            upload.setLlmResult(objectMapper.writeValueAsString(llmResult));
+            upload.setUploadedAt(new Date());
+            unknownUploadMapper.insert(upload);
+
+            log.info("已记录未知报告: clusterKey={}, count={}", clusterKey, cluster.getReportCount());
+        } catch (Exception e) {
+            log.warn("记录未知报告失败", e);
+        }
+    }
+
+    private String computeFileHash(String filePath) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] data = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(filePath));
+            byte[] hash = md.digest(data);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : hash) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (Exception e) {
+            return String.valueOf(System.currentTimeMillis());
+        }
+    }
+
+    private String computeClusterKey(String textFeatures) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] hash = md.digest(textFeatures.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : hash) sb.append(String.format("%02x", b));
+            return sb.substring(0, 16);
+        } catch (Exception e) {
+            return String.valueOf(textFeatures.hashCode());
+        }
+    }
+
+    private String extractTextFeatures(String pdfPath) {
+        try {
+            try (org.apache.pdfbox.pdmodel.PDDocument document =
+                         org.apache.pdfbox.pdmodel.PDDocument.load(new File(pdfPath))) {
+                org.apache.pdfbox.text.PDFTextStripper stripper = new org.apache.pdfbox.text.PDFTextStripper();
+                String text = stripper.getText(document);
+                // 取前 2000 字符作为特征
+                return text != null && text.length() > 2000 ? text.substring(0, 2000) : text;
+            }
+        } catch (Exception e) {
+            log.warn("提取PDF文本特征失败: {}", e.getMessage());
+            return "";
+        }
+    }
+}

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

@@ -4300,3 +4300,76 @@ CREATE TABLE IF NOT EXISTS diet_record_items (
     source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation',
     INDEX idx_record (record_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表';
+
+-- 报告指纹检测与解析器调度系统
+
+-- 报告类型注册表
+CREATE TABLE IF NOT EXISTS report_type_registry (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    type_id VARCHAR(64) NOT NULL COMMENT '类型标识,如 dan_a1/gut_beijing',
+    family VARCHAR(32) DEFAULT NULL COMMENT '报告家族: dan/gut_flora/其他',
+    display_name VARCHAR(100) NOT NULL COMMENT '显示名称',
+    description VARCHAR(500) DEFAULT NULL COMMENT '描述',
+    parser_path VARCHAR(255) DEFAULT NULL COMMENT 'Python解析器脚本路径',
+    min_confidence INT DEFAULT 10 COMMENT '识别阈值',
+    is_active TINYINT(1) DEFAULT 1 COMMENT '启用状态',
+    source VARCHAR(20) DEFAULT 'prebuilt' COMMENT '来源: prebuilt/imported/auto_generated',
+    review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected',
+    version VARCHAR(20) DEFAULT '1.0' COMMENT '版本',
+    created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_type_id (type_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告类型注册表';
+
+-- 报告指纹规则表
+CREATE TABLE IF NOT EXISTS report_fingerprint (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    type_id VARCHAR(64) NOT NULL COMMENT '关联report_type_registry.type_id',
+    pattern VARCHAR(200) NOT NULL COMMENT '文本特征',
+    weight INT DEFAULT 10 COMMENT '权重(正=加分,负=排除扣分)',
+    is_exclusion TINYINT(1) DEFAULT 0 COMMENT '是否为排除规则',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_fp_type (type_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告指纹规则表';
+
+-- 未知报告聚类表
+CREATE TABLE IF NOT EXISTS report_unknown_cluster (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    cluster_key VARCHAR(64) NOT NULL COMMENT '聚类哈希键(同类报告分组)',
+    common_signatures TEXT COMMENT '公共文本特征JSON(用于生成指纹)',
+    llm_template TEXT COMMENT 'LLM解析结果模板JSON',
+    report_count INT DEFAULT 0 COMMENT '累计报告数',
+    status VARCHAR(20) DEFAULT 'collecting' COMMENT '状态: collecting/ready/generated/imported',
+    generated_type_id VARCHAR(64) DEFAULT NULL COMMENT '生成的类型ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_cluster_key (cluster_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告聚类表';
+
+-- 未知报告上传明细表
+CREATE TABLE IF NOT EXISTS report_unknown_upload (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    cluster_id BIGINT DEFAULT NULL COMMENT '关联report_unknown_cluster.id',
+    file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径',
+    file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希',
+    llm_result TEXT COMMENT 'LLM解析结果JSON',
+    uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_unknown_cluster (cluster_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表';
+
+-- 报告解析器导入记录表
+CREATE TABLE IF NOT EXISTS report_parser_import (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    package_name VARCHAR(100) NOT NULL COMMENT '导入包名称',
+    package_file_url VARCHAR(500) DEFAULT NULL COMMENT '导入包文件路径',
+    fingerprint_json TEXT COMMENT '指纹规则JSON',
+    parser_script TEXT COMMENT '解析器脚本内容',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected',
+    imported_by BIGINT DEFAULT NULL COMMENT '导入人ID',
+    reviewed_by BIGINT DEFAULT NULL COMMENT '审核人ID',
+    review_note VARCHAR(500) DEFAULT NULL COMMENT '审核意见',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告解析器导入记录表';

+ 26 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java

@@ -15,6 +15,7 @@ import org.mockito.MockitoAnnotations;
 import java.util.Date;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
@@ -130,4 +131,29 @@ public class FamilyChallengeServiceTest {
 
         verify(familyChallengeMapper).update(any(), any());
     }
+
+    @Test
+    void getTemplates_shouldReturnAllTemplatesWithParsedRewardPoints() {
+        java.util.List<java.util.Map<String, Object>> templates = service.getTemplates();
+
+        assertEquals(7, templates.size());
+        for (java.util.Map<String, Object> t : templates) {
+            assertTrue(t.get("rewardPoints") instanceof Integer, "rewardPoints 必须可解析为 Integer");
+            assertTrue(t.get("durationDays") instanceof Integer, "durationDays 必须可解析为 Integer");
+            assertTrue(t.get("targetValue") instanceof Integer, "targetValue 必须可解析为 Integer");
+            assertTrue(t.get("targetMode") instanceof String, "targetMode 必须存在");
+        }
+        java.util.Map<String, Object> first = templates.get(0);
+        assertEquals("full_checkin", first.get("type"));
+        assertEquals("全员打卡挑战", first.get("title"));
+        assertEquals(Integer.valueOf(3), first.get("durationDays"));
+        assertEquals(Integer.valueOf(3), first.get("targetValue"));
+        assertEquals(Integer.valueOf(50), first.get("rewardPoints"));
+        assertEquals("all_members", first.get("targetMode"));
+        java.util.Map<String, Object> sports = templates.get(1);
+        assertEquals("sports", sports.get("type"));
+        assertEquals("aggregate", sports.get("targetMode"));
+        assertEquals(Integer.valueOf(300), sports.get("targetValue"));
+        assertEquals(Integer.valueOf(100), sports.get("rewardPoints"));
+    }
 }

+ 63 - 0
cfc-langgraph/app/agents/report_parse_agent.py

@@ -70,6 +70,69 @@ class ReportParseAgent:
         result.pop('_parse_incomplete', None)
         return result
 
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
+        """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
+        try:
+            from PyPDF2 import PdfReader
+            reader = PdfReader(file_path)
+            text = '\n'.join(page.extract_text() or '' for page in reader.pages)
+
+            ctx_str = ""
+            if extra_context:
+                ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
+
+            prompt = f"""你是一个通用报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
+
+报告文本内容:
+{text[:12000]}{ctx_str}
+
+请分析这份报告,推断它的类型和内容,然后按以下JSON Schema返回:
+{{
+    "reportType": "推断的报告类型名称",
+    "reportTypeFamily": "报告家族分类(如: dan/cognitive/gut_flora/health_check/other)",
+    "confidence": "high/medium/low",
+    "summary": {{
+        "personName": "姓名",
+        "reportDate": "报告日期",
+        "reportNumber": "报告编号",
+        "overallScore": "总分(如果有)",
+        "interpretation": "报告整体解读摘要"
+    }},
+    "indicators": [
+        {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
+    ],
+    "sections": [
+        {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
+    ],
+    "textFeatures": ["文本特征1", "文本特征2", ...]
+}}
+
+只返回JSON,不要其他文字。"""
+
+            if self.llm_api_key:
+                import httpx
+                async with httpx.AsyncClient(timeout=120) as client:
+                    resp = await client.post(
+                        f"{settings.llm_base_url}/chat/completions",
+                        json={
+                            "model": settings.llm_model or "gpt-4o",
+                            "messages": [{"role": "user", "content": prompt}],
+                            "temperature": 0.1,
+                        },
+                        headers={"Authorization": f"Bearer {self.llm_api_key}"},
+                    )
+                    resp.raise_for_status()
+                    data = resp.json()
+                    content = data['choices'][0]['message']['content']
+                    content = content.replace('```json', '').replace('```', '').strip()
+                    return json.loads(content)
+            else:
+                logger.warning("LLM 未配置,返回空")
+                return {"reportType": "unknown", "summary": {}, "indicators": [], "sections": []}
+        except Exception as e:
+            logger.error("通用LLM解析失败: %s", e)
+            return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
+
     async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
         """LLM 兜底解析"""
         try:

+ 33 - 1
cfc-langgraph/app/api/report_parse.py

@@ -1,9 +1,10 @@
 from fastapi import APIRouter, HTTPException
 from pydantic import BaseModel
-from typing import Optional
+from typing import Optional, Any
 from app.agents.report_parse_agent import ReportParseAgent
 import logging
 import os
+import json
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/api/v1", tags=["report_parse"])
@@ -43,3 +44,34 @@ async def parse_report(req: ParseRequest):
     except Exception as e:
         logger.error("报告解析失败: %s", e, exc_info=True)
         return ParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+
+
+class GenericParseRequest(BaseModel):
+    file_path: str
+    extra_context: Optional[dict] = None
+
+
+class GenericParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+@router.post("/report/parse-generic", response_model=GenericParseResponse)
+async def parse_report_generic(req: GenericParseRequest):
+    """通用报告 LLM 兜底解析。
+
+    适用于指纹检测未匹配的未知类型报告。
+    直接交给 LLM 提取结构化数据,不经过算法预解析。
+    """
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    logger.info("report_parse_generic: file_path=%s", req.file_path)
+    agent = get_agent()
+    try:
+        result = await agent.parse_generic(req.file_path, req.extra_context)
+        return GenericParseResponse(data=result)
+    except Exception as e:
+        logger.error("通用报告解析失败: %s", e, exc_info=True)
+        return GenericParseResponse(code=500, message=f"解析失败: {str(e)}", data={})

+ 25 - 0
cfc-web/src/router/index.js

@@ -661,6 +661,31 @@ const routes = [
         name: 'BadgeManage',
         component: () => import('@/views/admin/BadgeManage'),
         meta: { title: '勋章管理', perm: 'system:config' }
+      },
+      // ========== 报告指纹解析系统 ==========
+      {
+        path: 'report-types',
+        name: 'ReportTypeManagement',
+        component: () => import('@/views/admin/ReportTypeManagement.vue'),
+        meta: { title: '报告类型管理', perm: 'system:config' }
+      },
+      {
+        path: 'report-parser-import',
+        name: 'ReportParserImport',
+        component: () => import('@/views/admin/ReportParserImport.vue'),
+        meta: { title: '解析器导入管理', perm: 'system:config' }
+      },
+      {
+        path: 'report-unknown-clusters',
+        name: 'ReportUnknownCluster',
+        component: () => import('@/views/admin/ReportUnknownCluster.vue'),
+        meta: { title: '未知报告审核', perm: 'system:config' }
+      },
+      {
+        path: 'report-auto-learn',
+        name: 'ReportAutoLearn',
+        component: () => import('@/views/admin/ReportAutoLearn.vue'),
+        meta: { title: '报告自学习', perm: 'system:config' }
       }
     ]
   }

+ 8 - 0
cfc-web/src/views/Layout.vue

@@ -245,6 +245,14 @@ export default {
             { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-sunny', perm: 'diet:seasonal' },
           ]},
 
+        // ===== 报告解析系统 (admin) =====
+        { title: '报告解析系统', icon: 'el-icon-cpu', perm: 'system:config',
+          children: [
+            { path: '/report-types', label: '报告类型管理', icon: 'el-icon-collection', perm: 'system:config' },
+            { path: '/report-parser-import', label: '解析器导入', icon: 'el-icon-upload2', perm: 'system:config' },
+            { path: '/report-unknown-clusters', label: '未知报告审核', icon: 'el-icon-question', perm: 'system:config' },
+            { path: '/report-auto-learn', label: '报告自学习', icon: 'el-icon-magic-stick', perm: 'system:config' },
+          ]},
 
         { title: '家庭服务', icon: 'el-icon-s-custom', perm: 'service:*',
           children: [

+ 37 - 0
docs/参考资料/extract_full_report_v5.py

@@ -810,6 +810,43 @@ def _parse_taxonomy_levels(reader, full_text, fmt):
             marker2 = f'{level} 名称 丰度%'
             li = section.find(marker2)
             if li == -1:
+                # 尝试压缩格式:纲名称丰度%人群水平%%人检出
+                compressed_marker = f'{level}名称丰度%人群水平%%人检出'
+                cli = section.find(compressed_marker)
+                if cli == -1:
+                    continue
+                # 压缩格式解析:用正则提取数据
+                level_start = cli + len(compressed_marker)
+                # 找下一个层级结束位置
+                level_end = len(section)
+                for next_level in ['目', '科', '属', '种']:
+                    if next_level == level or next_level == level:
+                        continue
+                    ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
+                    if ni != -1 and ni < level_end:
+                        level_end = ni
+                        break
+                level_region = section[level_start:level_end]
+                # 压缩格式正则: [中文名] 拉丁名 丰度% 人群水平% 检出率%
+                # 例如: 梭菌纲 Clostridia48.803%74%98.87%
+                compressed_pattern = re.compile(
+                    r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?'  # 可选中文名
+                    r'([A-Za-z][A-Za-z\s.\-]*?)'  # 拉丁名(可能含空格)
+                    r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)'  # 三连百分比
+                )
+                rows = []
+                for m in compressed_pattern.finditer(level_region):
+                    cn_name = (m.group(1) or '').strip()
+                    latin_name = m.group(2).strip()
+                    pct = m.group(3)
+                    pop_level = m.group(4)
+                    detection = m.group(5)
+                    # 优先用中文名,没有则用拉丁名
+                    name = cn_name if cn_name else latin_name
+                    entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
+                    rows.append(entry)
+                if rows:
+                    results[level_name] = rows
                 continue
 
         # 从标题后开始解析三元组数据