Browse Source

fix(health): 修复报告方案链路与报告详情折叠展示

- RestTemplateConfig 读超时 30s→180s,避免 LangGraph 报告解析超时回退本地拼接(5776)
- HealthPlanServiceImpl 修复任务行正则,编号行任务可正常生成
- report-detail 移除饮食推荐,肠道菌群/疾病风险/益生菌种/分类/病原菌改为折叠展示
- cfc-langgraph 增强 PDF 报告解析(营养/药敏/氨基酸/症状字段)、rag 检索与向量化
- schema.sql 补充新表;DatabaseInitializer/AIService/Layout 相关调整
Xiaogang Liao 2 months ago
parent
commit
52e9f39f8c

+ 223 - 264
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2963,6 +2963,8 @@ log.info("已添加template_id列到tasks表");
         seedHealthNorms();
         runLateMigrations();
         migration102();
+        // 迁移226-247: 原seedFullRegionData内的DDL迁移,确保不因省市区数据已存在而跳过
+        runFullRegionDataMigrations();
         seedFullRegionData();
 
         }
@@ -3918,14 +3920,6 @@ try {
 
         ensureColumn("health_reports", "subject_id", "BIGINT COMMENT '报告主体用户ID(关联users表,为null表示未绑定)'");
 
-        // ---------- 健康指标表 新增symptoms字段 ----------
-        try {
-            jdbcTemplate.execute("ALTER TABLE health_indicators ADD COLUMN symptoms TEXT COMMENT '过量/缺乏相关症状描述'");
-            log.info("已为health_indicators表添加symptoms字段");
-        } catch (Exception e) {
-            log.warn("为health_indicators表添加symptoms字段可能已存在: {}", e.getMessage());
-        }
-
         log.info("成长规划师模块、财富维度及健康检测报告数据库表初始化完成");
 
         // 健康打卡表
@@ -3949,31 +3943,6 @@ try {
         }
 
         // 迁移31: 创建联系人表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contacts (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
-                "name VARCHAR(100) NOT NULL COMMENT '姓名', " +
-                "phone VARCHAR(20) COMMENT '电话', " +
-                "avatar VARCHAR(500) COMMENT '头像', " +
-                "relationship_type VARCHAR(20) COMMENT '关系类型: family/friend/partner/colleague/other', " +
-                "known_since DATETIME COMMENT '认识时间', " +
-                "bio VARCHAR(500) COMMENT '简介', " +
-                "birthday DATE COMMENT '生日', " +
-                "intimacy_level INT DEFAULT 0 COMMENT '亲密度(0-100)', " +
-                "contact_source VARCHAR(20) DEFAULT 'manual' COMMENT '来源: phone/manual', " +
-                "tags VARCHAR(500) COMMENT '标签(逗号分隔)', " +
-                "last_contact_at DATETIME COMMENT '最近联系时间', " +
-                "contact_count INT DEFAULT 0 COMMENT '联系次数', " +
-                "notes TEXT COMMENT '备注', " +
-                "created_at DATETIME, " +
-                "updated_at DATETIME, " +
-                "INDEX idx_user_id (user_id)" +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表'");
-            log.info("已创建contacts联系表");
-        } catch (Exception e) {
-            log.warn("创建contacts表可能已存在: {}", e.getMessage());
-        }
 
         // 创建文章阅读记录表
         try {
@@ -3993,110 +3962,7 @@ try {
             log.warn("创建article_reading_records表可能已存在: {}", e.getMessage());
         }
 
-        // 迁移30: activities表添加visibility和visible_to字段
-        try {
-            jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN visibility VARCHAR(20) DEFAULT 'all' COMMENT '可见性: all/member'");
-            log.info("已添加visibility列到activities表");
-        } catch (Exception e) {
-            // 列已存在,忽略错误
-        }
-
-        try {
-            jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN visible_to VARCHAR(255) COMMENT '可见角色列表(逗号分隔)'");
-            log.info("已添加visible_to列到activities表");
-        } catch (Exception e) {
-            // 列已存在,忽略错误
-        }
-
-        // 创建维度配置表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_config (" +
-                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
-                "dimension_code VARCHAR(50) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
-                "dimension_name VARCHAR(100) NOT NULL COMMENT '维度名称', " +
-                "dimension_type VARCHAR(50) NOT NULL DEFAULT 'five' COMMENT '类型: five(五维)/action(行动)/other', " +
-                "parent_id BIGINT COMMENT '父级ID,无父级为NULL', " +
-                "sort INT DEFAULT 0 COMMENT '排序', " +
-                "enabled TINYINT(1) DEFAULT 1 COMMENT '启用: 0禁用 1启用', " +
-                "dify_dataset_id VARCHAR(100) COMMENT 'Dify Dataset ID,不填则不同步', " +
-                "remark VARCHAR(500) 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("已创建product_dimension_config表");
-        } catch (Exception e) {
-            log.warn("创建product_dimension_config表可能已存在: {}", e.getMessage());
-        }
-
-        // 创建知识库表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base (" +
-                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
-                "title VARCHAR(200) NOT NULL COMMENT '知识标题', " +
-                "content TEXT NOT NULL COMMENT '知识内容', " +
-                "sort INT DEFAULT 0 COMMENT '排序', " +
-                "status TINYINT(1) DEFAULT 1 COMMENT '状态: 0禁用 1启用', " +
-                "remark VARCHAR(500) 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("已创建dan_knowledge_base表");
-        } catch (Exception e) {
-            log.warn("创建dan_knowledge_base表可能已存在: {}", e.getMessage());
-        }
-
-        // 创建标签表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_tag (" +
-                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
-                "tag_name VARCHAR(100) NOT NULL UNIQUE COMMENT '标签名', " +
-                "tag_type VARCHAR(50) DEFAULT 'custom' COMMENT '标签类型: custom/auto', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP " +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表'");
-            log.info("已创建dan_knowledge_tag表");
-        } catch (Exception e) {
-            log.warn("创建dan_knowledge_tag表可能已存在: {}", e.getMessage());
-        }
-
-        // 创建知识-维度关联表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base_dimension (" +
-                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
-                "knowledge_id BIGINT NOT NULL COMMENT '知识库ID', " +
-                "dimension_id BIGINT NOT NULL COMMENT '维度配置ID', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                "UNIQUE KEY uk_knowledge_dimension (knowledge_id, dimension_id) " +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-维度关联表'");
-            log.info("已创建dan_knowledge_base_dimension表");
-
-            // 添加外键约束(单独执行避免CREATE TABLE时依赖表不存在)
-            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_dimension ADD CONSTRAINT fk_kd_knowledge FOREIGN KEY (knowledge_id) REFERENCES dan_knowledge_base(id) ON DELETE CASCADE");
-            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_dimension ADD CONSTRAINT fk_kd_dimension FOREIGN KEY (dimension_id) REFERENCES product_dimension_config(id) ON DELETE CASCADE");
-            log.info("已添加dan_knowledge_base_dimension外键约束");
-        } catch (Exception e) {
-            log.warn("创建dan_knowledge_base_dimension表可能已存在: {}", e.getMessage());
-        }
-
-        // 创建知识-标签关联表
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base_tag (" +
-                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
-                "knowledge_id BIGINT NOT NULL, " +
-                "tag_id BIGINT NOT NULL, " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                "UNIQUE KEY uk_knowledge_tag (knowledge_id, tag_id) " +
-            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-标签关联表'");
-            log.info("已创建dan_knowledge_base_tag表");
-
-            // 添加外键约束
-            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_tag ADD CONSTRAINT fk_kt_knowledge FOREIGN KEY (knowledge_id) REFERENCES dan_knowledge_base(id) ON DELETE CASCADE");
-            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_tag ADD CONSTRAINT fk_kt_tag FOREIGN KEY (tag_id) REFERENCES dan_knowledge_tag(id) ON DELETE CASCADE");
-            log.info("已添加dan_knowledge_base_tag外键约束");
-        } catch (Exception e) {
-            log.warn("创建dan_knowledge_base_tag表可能已存在: {}", e.getMessage());
-        }
-
-        // 迁移32: products表添加配送/类目/销售字段(商品编辑表单对齐)
+                // 迁移32: products表添加配送/类目/销售字段(商品编辑表单对齐)
         ensureColumn("products", "delivery_method", "TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可'");
         ensureColumn("products", "category_id", "BIGINT COMMENT '商品类目ID'");
         ensureColumn("products", "distribution_system_id", "BIGINT COMMENT '销售体系ID'");
@@ -4269,11 +4135,6 @@ try {
             }
         }
 
-        // 迁移: foods表添加营养字段
-        ensureColumn("foods", "energy_kj", "DECIMAL(8,2) COMMENT '能量(kJ/100g)'");
-        ensureColumn("foods", "starch", "DECIMAL(8,2) COMMENT '淀粉(g/100g)'");
-        ensureColumn("foods", "cholesterol", "DECIMAL(8,2) COMMENT '胆固醇(mg/100g)'");
-
         // 初始化成长任务种子数据
         try {
             Integer count = jdbcTemplate.queryForObject(
@@ -4525,20 +4386,6 @@ try {
         } catch (Exception e) {
             log.warn("创建 tianpan_member_annual_energy 表可能已存在: {}", e.getMessage());
         }
-
-        // 迁移 81: family_member_attributes 添加西方星座字段
-        try {
-            jdbcTemplate.execute("ALTER TABLE family_member_attributes ADD COLUMN western_zodiac VARCHAR(20) COMMENT '西方星座代码'");
-            log.info("已添加 western_zodiac 列到 family_member_attributes 表");
-        } catch (Exception e) {
-            log.warn("添加 western_zodiac 列可能已存在: {}", e.getMessage());
-        }
-        try {
-            jdbcTemplate.execute("ALTER TABLE family_member_attributes ADD COLUMN western_zodiac_name VARCHAR(50) COMMENT '西方星座名称'");
-            log.info("已添加 western_zodiac_name 列到 family_member_attributes 表");
-        } catch (Exception e) {
-            log.warn("添加 western_zodiac_name 列可能已存在: {}", e.getMessage());
-        }
     }
 
     private void insertSysConfigSeed(String key, String value, String desc) {
@@ -8586,107 +8433,7 @@ private void runMigration100() {
 		// 迁移225: articles 添加 growth_category 列(成长分类筛选,Article 实体已含字段,生产库缺列导致 Unknown column 'growth_category')
 		ensureColumn("articles", "growth_category", "VARCHAR(50) DEFAULT NULL COMMENT '成长分类'");
 	}
-	private void seedFullRegionData() {
-		try {
-			Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM streets WHERE level = 1", Integer.class);
-			if (count != null && count >= 20) {
-				log.info("全国省市区数据已存在({}个省份),跳过导入", count);
-				return;
-			}
-		} catch (Exception e) {
-			log.warn("检查省份数据失败,跳过全量导入: {}", e.getMessage());
-			return;
-		}
-
-		org.springframework.core.io.Resource resource = resourceLoader.getResource("classpath:region_data.json");
-		if (!resource.exists()) {
-			log.warn("region_data.json 不存在,跳过全量省市区数据导入");
-			return;
-		}
-
-		try {
-			String content = new String(StreamUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
-			com.alibaba.fastjson.JSONArray provinces = com.alibaba.fastjson.JSON.parseArray(content);
-
-			for (int pi = 0; pi < provinces.size(); pi++) {
-				com.alibaba.fastjson.JSONObject prov = provinces.getJSONObject(pi);
-				String provName = prov.getString("name");
-				String provCode = prov.getString("code");
-
-				java.sql.Connection conn = jdbcTemplate.getDataSource().getConnection();
-				java.sql.PreparedStatement ps = conn.prepareStatement(
-					"INSERT INTO streets (province, province_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, 1, NULL, ?)",
-					java.sql.Statement.RETURN_GENERATED_KEYS
-				);
-				ps.setString(1, provName);
-				ps.setString(2, provCode);
-				ps.setString(3, provName);
-				ps.setInt(4, pi + 1);
-				ps.executeUpdate();
-				java.sql.ResultSet rs = ps.getGeneratedKeys();
-				long provId = 0;
-				if (rs.next()) provId = rs.getLong(1);
-				rs.close();
-				ps.close();
-
-				com.alibaba.fastjson.JSONArray cities = prov.getJSONArray("cities");
-				if (cities == null) continue;
-
-				for (int ci = 0; ci < cities.size(); ci++) {
-					com.alibaba.fastjson.JSONObject city = cities.getJSONObject(ci);
-					String cityName = city.getString("name");
-					String cityCode = city.getString("code");
-
-					java.sql.PreparedStatement ps2 = conn.prepareStatement(
-						"INSERT INTO streets (province, province_code, city, city_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, ?, ?, 2, ?, ?)",
-						java.sql.Statement.RETURN_GENERATED_KEYS
-					);
-					ps2.setString(1, provName);
-					ps2.setString(2, provCode);
-					ps2.setString(3, cityName);
-					ps2.setString(4, cityCode);
-					ps2.setString(5, provName + cityName);
-					ps2.setLong(6, provId);
-					ps2.setInt(7, ci + 1);
-					ps2.executeUpdate();
-					java.sql.ResultSet rs2 = ps2.getGeneratedKeys();
-					long cityId = 0;
-					if (rs2.next()) cityId = rs2.getLong(1);
-					rs2.close();
-					ps2.close();
-
-					com.alibaba.fastjson.JSONArray districts = city.getJSONArray("districts");
-					if (districts == null) continue;
-
-					for (int di = 0; di < districts.size(); di++) {
-						com.alibaba.fastjson.JSONObject dist = districts.getJSONObject(di);
-						String distName = dist.getString("name");
-						String distCode = dist.getString("code");
-
-						java.sql.PreparedStatement ps3 = conn.prepareStatement(
-							"INSERT INTO streets (province, province_code, city, city_code, district, district_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, 3, ?, ?)",
-							java.sql.Statement.RETURN_GENERATED_KEYS
-						);
-						ps3.setString(1, provName);
-						ps3.setString(2, provCode);
-						ps3.setString(3, cityName);
-						ps3.setString(4, cityCode);
-						ps3.setString(5, distName);
-						ps3.setString(6, distCode);
-						ps3.setString(7, provName + cityName + distName);
-						ps3.setLong(8, cityId);
-						ps3.setInt(9, di + 1);
-						ps3.executeUpdate();
-						ps3.close();
-					}
-				}
-				conn.close();
-			}
-			log.info("全国省市区数据导入完成:{}省/{}市/{}区", provinces.size(), 0, 0);
-		} catch (Exception e) {
-			log.warn("导入全国省市区数据失败: {}", e.getMessage());
-		}
-
+	private void runFullRegionDataMigrations() {
 		// 迁移226: 创建 family_platform_balance 表 + family_platform_balance_log 表 + products.points_multiplier 列
 		try {
 			jdbcTemplate.execute(
@@ -9113,6 +8860,225 @@ private void runMigration100() {
 		ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
 		ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'");
 
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contacts (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                "name VARCHAR(100) NOT NULL COMMENT '姓名', " +
+                "phone VARCHAR(20) COMMENT '电话', " +
+                "avatar VARCHAR(500) COMMENT '头像', " +
+                "relationship_type VARCHAR(20) COMMENT '关系类型: family/friend/partner/colleague/other', " +
+                "known_since DATETIME COMMENT '认识时间', " +
+                "bio VARCHAR(500) COMMENT '简介', " +
+                "birthday DATE COMMENT '生日', " +
+                "intimacy_level INT DEFAULT 0 COMMENT '亲密度(0-100)', " +
+                "contact_source VARCHAR(20) DEFAULT 'manual' COMMENT '来源: phone/manual', " +
+                "tags VARCHAR(500) COMMENT '标签(逗号分隔)', " +
+                "last_contact_at DATETIME COMMENT '最近联系时间', " +
+                "contact_count INT DEFAULT 0 COMMENT '联系次数', " +
+                "notes TEXT COMMENT '备注', " +
+                "created_at DATETIME, " +
+                "updated_at DATETIME, " +
+                "INDEX idx_user_id (user_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表'");
+            log.info("已创建contacts联系表");
+        } catch (Exception e) {
+            log.warn("创建contacts表可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_config (" +
+                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
+                "dimension_code VARCHAR(50) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
+                "dimension_name VARCHAR(100) NOT NULL COMMENT '维度名称', " +
+                "dimension_type VARCHAR(50) NOT NULL DEFAULT 'five' COMMENT '类型: five(五维)/action(行动)/other', " +
+                "parent_id BIGINT COMMENT '父级ID,无父级为NULL', " +
+                "sort INT DEFAULT 0 COMMENT '排序', " +
+                "enabled TINYINT(1) DEFAULT 1 COMMENT '启用: 0禁用 1启用', " +
+                "dify_dataset_id VARCHAR(100) COMMENT 'Dify Dataset ID,不填则不同步', " +
+                "remark VARCHAR(500) 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("已创建product_dimension_config表");
+        } catch (Exception e) {
+            log.warn("创建product_dimension_config表可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base (" +
+                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
+                "title VARCHAR(200) NOT NULL COMMENT '知识标题', " +
+                "content TEXT NOT NULL COMMENT '知识内容', " +
+                "sort INT DEFAULT 0 COMMENT '排序', " +
+                "status TINYINT(1) DEFAULT 1 COMMENT '状态: 0禁用 1启用', " +
+                "remark VARCHAR(500) 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("已创建dan_knowledge_base表");
+        } catch (Exception e) {
+            log.warn("创建dan_knowledge_base表可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_tag (" +
+                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
+                "tag_name VARCHAR(100) NOT NULL UNIQUE COMMENT '标签名', " +
+                "tag_type VARCHAR(50) DEFAULT 'custom' COMMENT '标签类型: custom/auto', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP " +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表'");
+            log.info("已创建dan_knowledge_tag表");
+        } catch (Exception e) {
+            log.warn("创建dan_knowledge_tag表可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base_dimension (" +
+                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
+                "knowledge_id BIGINT NOT NULL COMMENT '知识库ID', " +
+                "dimension_id BIGINT NOT NULL COMMENT '维度配置ID', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "UNIQUE KEY uk_knowledge_dimension (knowledge_id, dimension_id) " +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-维度关联表'");
+            log.info("已创建dan_knowledge_base_dimension表");
+
+            // 添加外键约束(单独执行避免CREATE TABLE时依赖表不存在)
+            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_dimension ADD CONSTRAINT fk_kd_knowledge FOREIGN KEY (knowledge_id) REFERENCES dan_knowledge_base(id) ON DELETE CASCADE");
+            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_dimension ADD CONSTRAINT fk_kd_dimension FOREIGN KEY (dimension_id) REFERENCES product_dimension_config(id) ON DELETE CASCADE");
+            log.info("已添加dan_knowledge_base_dimension外键约束");
+        } catch (Exception e) {
+            log.warn("创建dan_knowledge_base_dimension表可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS dan_knowledge_base_tag (" +
+                "id BIGINT PRIMARY KEY AUTO_INCREMENT, " +
+                "knowledge_id BIGINT NOT NULL, " +
+                "tag_id BIGINT NOT NULL, " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "UNIQUE KEY uk_knowledge_tag (knowledge_id, tag_id) " +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-标签关联表'");
+            log.info("已创建dan_knowledge_base_tag表");
+
+            // 添加外键约束
+            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_tag ADD CONSTRAINT fk_kt_knowledge FOREIGN KEY (knowledge_id) REFERENCES dan_knowledge_base(id) ON DELETE CASCADE");
+            jdbcTemplate.execute("ALTER TABLE dan_knowledge_base_tag ADD CONSTRAINT fk_kt_tag FOREIGN KEY (tag_id) REFERENCES dan_knowledge_tag(id) ON DELETE CASCADE");
+            log.info("已添加dan_knowledge_base_tag外键约束");
+        } catch (Exception e) {
+            log.warn("创建dan_knowledge_base_tag表可能已存在: {}", e.getMessage());
+        }
+		// 迁移222b: health_reports 添加 shannon_index, payload_json 列(原迁移222仅测试环境执行、迁移242在菜单种子方法内,生产库均不执行)
+		ensureColumn("health_reports", "shannon_index", "VARCHAR(32) DEFAULT NULL COMMENT '香农指数'");
+		ensureColumn("health_reports", "payload_json", "LONGTEXT COMMENT '报告完整载荷快照'");
+		// 迁移222c: foods 表添加营养字段(原位于initGuideModuleTables仅测试环境执行,foods建表在initFoodTables始终执行)
+		ensureColumn("foods", "energy_kj", "DECIMAL(8,2) COMMENT '能量(kJ/100g)'");
+		ensureColumn("foods", "starch", "DECIMAL(8,2) COMMENT '淀粉(g/100g)'");
+		ensureColumn("foods", "cholesterol", "DECIMAL(8,2) COMMENT '胆固醇(mg/100g)'");
+		// 迁移222d: 原initGuideModuleTables内的生产增量列,schema.sql未覆盖,生产库缺失
+		ensureColumn("health_indicators", "symptoms", "TEXT COMMENT '过量/缺乏相关症状描述'");
+		ensureColumn("activities", "visibility", "VARCHAR(20) DEFAULT 'all' COMMENT '可见性: all/member'");
+		ensureColumn("activities", "visible_to", "VARCHAR(255) COMMENT '可见角色列表(逗号分隔)'");
+		ensureColumn("family_member_attributes", "western_zodiac", "VARCHAR(20) COMMENT '西方星座代码'");
+		ensureColumn("family_member_attributes", "western_zodiac_name", "VARCHAR(50) COMMENT '西方星座名称'");
+	}
+
+	private void seedFullRegionData() {
+		try {
+			Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM streets WHERE level = 1", Integer.class);
+			if (count != null && count >= 20) {
+				log.info("全国省市区数据已存在({}个省份),跳过导入", count);
+				return;
+			}
+		} catch (Exception e) {
+			log.warn("检查省份数据失败,跳过全量导入: {}", e.getMessage());
+			return;
+		}
+
+		org.springframework.core.io.Resource resource = resourceLoader.getResource("classpath:region_data.json");
+		if (!resource.exists()) {
+			log.warn("region_data.json 不存在,跳过全量省市区数据导入");
+			return;
+		}
+
+		try {
+			String content = new String(StreamUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
+			com.alibaba.fastjson.JSONArray provinces = com.alibaba.fastjson.JSON.parseArray(content);
+
+			for (int pi = 0; pi < provinces.size(); pi++) {
+				com.alibaba.fastjson.JSONObject prov = provinces.getJSONObject(pi);
+				String provName = prov.getString("name");
+				String provCode = prov.getString("code");
+
+				java.sql.Connection conn = jdbcTemplate.getDataSource().getConnection();
+				java.sql.PreparedStatement ps = conn.prepareStatement(
+					"INSERT INTO streets (province, province_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, 1, NULL, ?)",
+					java.sql.Statement.RETURN_GENERATED_KEYS
+				);
+				ps.setString(1, provName);
+				ps.setString(2, provCode);
+				ps.setString(3, provName);
+				ps.setInt(4, pi + 1);
+				ps.executeUpdate();
+				java.sql.ResultSet rs = ps.getGeneratedKeys();
+				long provId = 0;
+				if (rs.next()) provId = rs.getLong(1);
+				rs.close();
+				ps.close();
+
+				com.alibaba.fastjson.JSONArray cities = prov.getJSONArray("cities");
+				if (cities == null) continue;
+
+				for (int ci = 0; ci < cities.size(); ci++) {
+					com.alibaba.fastjson.JSONObject city = cities.getJSONObject(ci);
+					String cityName = city.getString("name");
+					String cityCode = city.getString("code");
+
+					java.sql.PreparedStatement ps2 = conn.prepareStatement(
+						"INSERT INTO streets (province, province_code, city, city_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, ?, ?, 2, ?, ?)",
+						java.sql.Statement.RETURN_GENERATED_KEYS
+					);
+					ps2.setString(1, provName);
+					ps2.setString(2, provCode);
+					ps2.setString(3, cityName);
+					ps2.setString(4, cityCode);
+					ps2.setString(5, provName + cityName);
+					ps2.setLong(6, provId);
+					ps2.setInt(7, ci + 1);
+					ps2.executeUpdate();
+					java.sql.ResultSet rs2 = ps2.getGeneratedKeys();
+					long cityId = 0;
+					if (rs2.next()) cityId = rs2.getLong(1);
+					rs2.close();
+					ps2.close();
+
+					com.alibaba.fastjson.JSONArray districts = city.getJSONArray("districts");
+					if (districts == null) continue;
+
+					for (int di = 0; di < districts.size(); di++) {
+						com.alibaba.fastjson.JSONObject dist = districts.getJSONObject(di);
+						String distName = dist.getString("name");
+						String distCode = dist.getString("code");
+
+						java.sql.PreparedStatement ps3 = conn.prepareStatement(
+							"INSERT INTO streets (province, province_code, city, city_code, district, district_code, full_name, level, parent_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, 3, ?, ?)",
+							java.sql.Statement.RETURN_GENERATED_KEYS
+						);
+						ps3.setString(1, provName);
+						ps3.setString(2, provCode);
+						ps3.setString(3, cityName);
+						ps3.setString(4, cityCode);
+						ps3.setString(5, distName);
+						ps3.setString(6, distCode);
+						ps3.setString(7, provName + cityName + distName);
+						ps3.setLong(8, cityId);
+						ps3.setInt(9, di + 1);
+						ps3.executeUpdate();
+						ps3.close();
+					}
+				}
+				conn.close();
+			}
+			log.info("全国省市区数据导入完成:{}省/{}市/{}区", provinces.size(), 0, 0);
+		} catch (Exception e) {
+			log.warn("导入全国省市区数据失败: {}", e.getMessage());
+		}
+
 	}
 
 	/**
@@ -9257,12 +9223,5 @@ private void runMigration100() {
 			Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
 			keyToId.put(m[1], id);
 		}
-		// 迁移242: health_reports 表添加 payload_json 字段(报告完整载荷快照)
-		try {
-			jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN payload_json LONGTEXT DEFAULT NULL COMMENT '报告完整载荷快照'");
-			log.info("已添加payload_json列到health_reports表");
-		} catch (Exception ex) {
-			log.warn("添加payload_json列失败: {}", ex.getMessage());
-		}
 	}
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/RestTemplateConfig.java

@@ -14,7 +14,7 @@ public class RestTemplateConfig {
     public RestTemplate restTemplate(RestTemplateBuilder builder) {
         return builder
                 .setConnectTimeout(Duration.ofSeconds(30))
-                .setReadTimeout(Duration.ofSeconds(30))
+                .setReadTimeout(Duration.ofSeconds(180))
                 .build();
     }
 }

+ 9 - 41
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -80,55 +80,23 @@ public class AIService {
     }
 
     /**
-     * 发送聊天消息: 优先 Python LangGraph, 失败回退 Dify
+     * 发送聊天消息: 统一走 Python LangGraph(不含 Dify 备选)
      */
     public Map<String, Object> sendMessage(String query, String userId,
                                            String conversationId,
                                            Map<String, Object> inputs) {
         Long uid = Long.valueOf(userId);
 
-        // 1. 尝试 Python LangGraph
         Map<String, Object> pythonResult = aiGateway.chat(query, uid, conversationId, inputs);
-        if (pythonResult != null) {
-            log.debug("LangGraph chat 成功: userId={}", userId);
-            mirrorConversation(uid,
-                (String) pythonResult.getOrDefault("conversationId", conversationId),
-                "family", query, (String) pythonResult.get("answer"), inputs);
-            return pythonResult;
+        if (pythonResult == null) {
+            log.warn("LangGraph 不可用且无备选: userId={}", userId);
+            return Collections.emptyMap();
         }
-
-        // 2. Fallback: Dify
-        log.info("LangGraph 不可用, fallback to Dify: userId={}", userId);
-        return sendMessageToDify(query, userId, conversationId, inputs);
-    }
-
-    /**
-     * 原有 Dify 发送逻辑 (提取为独立方法)
-     */
-    private Map<String, Object> sendMessageToDify(String query, String userId,
-                                                   String conversationId,
-                                                   Map<String, Object> inputs) {
-        String url = difyBaseUrl + "/chat-messages";
-        Map<String, Object> body = new LinkedHashMap<>();
-        body.put("query", query);
-        body.put("user", userId);
-        body.put("response_mode", "blocking");
-        body.put("conversation_id", conversationId != null ? conversationId : "");
-        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
-        body.put("auto_generate_name", true);
-
-        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
-        ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
-
-        Map<String, Object> result = new LinkedHashMap<>();
-        if (resp.getBody() != null) {
-            result.put("answer", resp.getBody().get("answer"));
-            result.put("conversationId", resp.getBody().get("conversation_id"));
-            mirrorConversation(Long.valueOf(userId),
-                (String) resp.getBody().get("conversation_id"),
-                "family", query, (String) resp.getBody().get("answer"), inputs);
-        }
-        return result;
+        log.debug("LangGraph chat 成功: userId={}", userId);
+        mirrorConversation(uid,
+            (String) pythonResult.getOrDefault("conversationId", conversationId),
+            "family", query, (String) pythonResult.get("answer"), inputs);
+        return pythonResult;
     }
 
     private void mirrorConversation(Long userId, String difyConversationId,

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -36,8 +36,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     private String langgraphBaseUrl;
 
     // 匹配方案文本中的任务行(与前端 parseTasksFromPlan 保持一致)
+    // 注意:字符类 [.、)\]] 后不能再有多余的 ],否则数字编号行(如 "1. 腹部B超")永远无法匹配
     private static final Pattern TASK_PATTERN = Pattern.compile(
-        "^(?:\\d+[\\.、\\)\\]]]|[-*•]|\\[.+\\]|【任务\\d+】)[\\s ]*([^\\n]+)"
+        "^(?:\\d+[\\.、\\)\\]]|[-*•]|\\[.+\\]|【任务\\d+】)[\\s ]*([^\\n]+)"
     );
 
     @Override

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

@@ -4890,3 +4890,78 @@ CREATE TABLE IF NOT EXISTS product_bundle_items (
     INDEX idx_bundle (bundle_product_id),
     INDEX idx_child (child_product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成明细';
+
+-- 联系人表(cfclub 联系人管理)
+CREATE TABLE IF NOT EXISTS contacts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    name VARCHAR(100) NOT NULL COMMENT '姓名',
+    phone VARCHAR(20) COMMENT '电话',
+    avatar VARCHAR(500) COMMENT '头像',
+    relationship_type VARCHAR(20) COMMENT '关系类型: family/friend/partner/colleague/other',
+    known_since DATETIME COMMENT '认识时间',
+    bio VARCHAR(500) COMMENT '简介',
+    birthday DATE COMMENT '生日',
+    intimacy_level INT DEFAULT 0 COMMENT '亲密度(0-100)',
+    contact_source VARCHAR(20) DEFAULT 'manual' COMMENT '来源: phone/manual',
+    tags VARCHAR(500) COMMENT '标签(逗号分隔)',
+    last_contact_at DATETIME COMMENT '最近联系时间',
+    contact_count INT DEFAULT 0 COMMENT '联系次数',
+    notes TEXT COMMENT '备注',
+    created_at DATETIME,
+    updated_at DATETIME,
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';
+
+-- 维度配置表(cfclub 五维能量体系)
+CREATE TABLE IF NOT EXISTS product_dimension_config (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    dimension_code VARCHAR(50) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth',
+    dimension_name VARCHAR(100) NOT NULL COMMENT '维度名称',
+    dimension_type VARCHAR(50) NOT NULL DEFAULT 'five' COMMENT '类型: five(五维)/action(行动)/other',
+    parent_id BIGINT COMMENT '父级ID,无父级为NULL',
+    sort INT DEFAULT 0 COMMENT '排序',
+    enabled TINYINT(1) DEFAULT 1 COMMENT '启用: 0禁用 1启用',
+    dify_dataset_id VARCHAR(100) COMMENT 'Dify Dataset ID,不填则不同步',
+    remark VARCHAR(500) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度配置表';
+
+-- 知识库表
+CREATE TABLE IF NOT EXISTS dan_knowledge_base (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    title VARCHAR(200) NOT NULL COMMENT '知识标题',
+    content TEXT NOT NULL COMMENT '知识内容',
+    sort INT DEFAULT 0 COMMENT '排序',
+    status TINYINT(1) DEFAULT 1 COMMENT '状态: 0禁用 1启用',
+    remark VARCHAR(500) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库表';
+
+-- 标签表
+CREATE TABLE IF NOT EXISTS dan_knowledge_tag (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    tag_name VARCHAR(100) NOT NULL UNIQUE COMMENT '标签名',
+    tag_type VARCHAR(50) DEFAULT 'custom' COMMENT '标签类型: custom/auto',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表';
+
+-- 知识-维度关联表
+CREATE TABLE IF NOT EXISTS dan_knowledge_base_dimension (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    knowledge_id BIGINT NOT NULL COMMENT '知识库ID',
+    dimension_id BIGINT NOT NULL COMMENT '维度配置ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_knowledge_dimension (knowledge_id, dimension_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-维度关联表';
+
+-- 知识-标签关联表
+CREATE TABLE IF NOT EXISTS dan_knowledge_base_tag (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    knowledge_id BIGINT NOT NULL,
+    tag_id BIGINT NOT NULL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_knowledge_tag (knowledge_id, tag_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-标签关联表';

+ 132 - 117
cfc-frontend/pages/health/report-detail.vue

@@ -213,108 +213,124 @@
 
           <!-- 肠道菌群 -->
           <view class="card" v-if="payload.gutFlora && payload.gutFlora.length">
-            <view class="card-title">肠道菌群 ({{ payload.gutFlora.length }})</view>
-            <view class="flora-item" v-for="(flora, idx) in payload.gutFlora" :key="idx">
-              <input class="flora-name" v-model="payload.gutFlora[idx].name" />
-              <input class="flora-abundance" type="digit" v-model="payload.gutFlora[idx].abundance" />
-              <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
-                {{ statusText(flora.status) }}
+            <view class="card-title collapsible-title" @click="toggleCollapse('gutFlora')">
+              <text>肠道菌群 ({{ payload.gutFlora.length }})</text>
+              <text class="collapse-arrow" :class="{ 'arrow-open': collapseState.gutFlora }">›</text>
+            </view>
+            <view v-if="collapseState.gutFlora">
+              <view class="flora-item" v-for="(flora, idx) in payload.gutFlora" :key="idx">
+                <input class="flora-name" v-model="payload.gutFlora[idx].name" />
+                <input class="flora-abundance" type="digit" v-model="payload.gutFlora[idx].abundance" />
+                <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
+                  {{ statusText(flora.status) }}
+                </view>
               </view>
             </view>
           </view>
 
           <!-- 疾病风险 -->
           <view class="card" v-if="payload.diseaseRisks && payload.diseaseRisks.length">
-            <view class="card-title">疾病风险 ({{ payload.diseaseRisks.length }})</view>
-            <view class="risk-item" v-for="(risk, idx) in payload.diseaseRisks" :key="idx">
-              <input class="risk-name" v-model="payload.diseaseRisks[idx].diseaseName" />
-              <view class="risk-level" :class="'risk-' + (risk.riskLevel || 'low')">
-                {{ riskLevelText(risk.riskLevel) }}
-              </view>
+            <view class="card-title collapsible-title" @click="toggleCollapse('diseaseRisk')">
+              <text>疾病风险 ({{ payload.diseaseRisks.length }})</text>
+              <text class="collapse-arrow" :class="{ 'arrow-open': collapseState.diseaseRisk }">›</text>
             </view>
-          </view>
-
-          <!-- 饮食推荐 -->
-          <view class="card" v-if="payload.foods && payload.foods.length">
-            <view class="card-title">饮食推荐 ({{ payload.foods.length }})</view>
-            <view class="food-item" v-for="(food, idx) in payload.foods" :key="idx">
-              <input class="food-name" v-model="payload.foods[idx].foodName" />
-              <input class="food-category" v-model="payload.foods[idx].category" />
+            <view v-if="collapseState.diseaseRisk">
+              <view class="risk-item" v-for="(risk, idx) in payload.diseaseRisks" :key="idx">
+                <input class="risk-name" v-model="payload.diseaseRisks[idx].diseaseName" />
+                <view class="risk-level" :class="'risk-' + (risk.riskLevel || 'low')">
+                  {{ riskLevelText(risk.riskLevel) }}
+                </view>
+              </view>
             </view>
           </view>
 
           <!-- 益生菌种 -->
           <view class="card" v-if="payload.probioticSpecies && payload.probioticSpecies.length">
-            <view class="card-title">益生菌种 ({{ payload.probioticSpecies.length }})</view>
-            <view class="flora-item" v-for="(flora, idx) in payload.probioticSpecies" :key="idx">
-              <input class="flora-name" v-model="payload.probioticSpecies[idx].name" />
-              <input class="flora-abundance" type="digit" v-model="payload.probioticSpecies[idx].abundance" />
-              <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
-                {{ statusText(flora.status) }}
+            <view class="card-title collapsible-title" @click="toggleCollapse('probiotic')">
+              <text>益生菌种 ({{ payload.probioticSpecies.length }})</text>
+              <text class="collapse-arrow" :class="{ 'arrow-open': collapseState.probiotic }">›</text>
+            </view>
+            <view v-if="collapseState.probiotic">
+              <view class="flora-item" v-for="(flora, idx) in payload.probioticSpecies" :key="idx">
+                <input class="flora-name" v-model="payload.probioticSpecies[idx].name" />
+                <input class="flora-abundance" type="digit" v-model="payload.probioticSpecies[idx].abundance" />
+                <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
+                  {{ statusText(flora.status) }}
+                </view>
               </view>
             </view>
           </view>
 
           <!-- Taxonomy 分类(纲/目/科/属/种) -->
           <view class="card" v-if="hasTaxonomyData()">
-            <view class="card-title">菌群分类组成 ({{ taxonomyTotalCount }})</view>
-            <view class="taxonomy-group" v-if="payload.taxonomyClass && payload.taxonomyClass.length">
-              <text class="taxonomy-group-label">菌纲 (Class)</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.taxonomyClass" :key="getTaxonomyKey('c', idx)">
-                <input class="flora-name" v-model="payload.taxonomyClass[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.taxonomyClass[idx].abundance" />
-              </view>
+            <view class="card-title collapsible-title" @click="toggleCollapse('taxonomy')">
+              <text>菌群分类组成 ({{ taxonomyTotalCount }})</text>
+              <text class="collapse-arrow" :class="{ 'arrow-open': collapseState.taxonomy }">›</text>
             </view>
-            <view class="taxonomy-group" v-if="payload.taxonomyOrder && payload.taxonomyOrder.length">
-              <text class="taxonomy-group-label">菌目 (Order)</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.taxonomyOrder" :key="getTaxonomyKey('o', idx)">
-                <input class="flora-name" v-model="payload.taxonomyOrder[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.taxonomyOrder[idx].abundance" />
+            <view v-if="collapseState.taxonomy">
+              <view class="taxonomy-group" v-if="payload.taxonomyClass && payload.taxonomyClass.length">
+                <text class="taxonomy-group-label">菌纲 (Class)</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.taxonomyClass" :key="getTaxonomyKey('c', idx)">
+                  <input class="flora-name" v-model="payload.taxonomyClass[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.taxonomyClass[idx].abundance" />
+                </view>
               </view>
-            </view>
-            <view class="taxonomy-group" v-if="payload.taxonomyFamily && payload.taxonomyFamily.length">
-              <text class="taxonomy-group-label">菌科 (Family)</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.taxonomyFamily" :key="getTaxonomyKey('f', idx)">
-                <input class="flora-name" v-model="payload.taxonomyFamily[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.taxonomyFamily[idx].abundance" />
+              <view class="taxonomy-group" v-if="payload.taxonomyOrder && payload.taxonomyOrder.length">
+                <text class="taxonomy-group-label">菌目 (Order)</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.taxonomyOrder" :key="getTaxonomyKey('o', idx)">
+                  <input class="flora-name" v-model="payload.taxonomyOrder[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.taxonomyOrder[idx].abundance" />
+                </view>
               </view>
-            </view>
-            <view class="taxonomy-group" v-if="payload.taxonomyGenus && payload.taxonomyGenus.length">
-              <text class="taxonomy-group-label">菌属 (Genus)</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.taxonomyGenus" :key="getTaxonomyKey('g', idx)">
-                <input class="flora-name" v-model="payload.taxonomyGenus[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.taxonomyGenus[idx].abundance" />
+              <view class="taxonomy-group" v-if="payload.taxonomyFamily && payload.taxonomyFamily.length">
+                <text class="taxonomy-group-label">菌科 (Family)</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.taxonomyFamily" :key="getTaxonomyKey('f', idx)">
+                  <input class="flora-name" v-model="payload.taxonomyFamily[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.taxonomyFamily[idx].abundance" />
+                </view>
               </view>
-            </view>
-            <view class="taxonomy-group" v-if="payload.taxonomySpecies && payload.taxonomySpecies.length">
-              <text class="taxonomy-group-label">菌种 (Species)</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.taxonomySpecies" :key="getTaxonomyKey('s', idx)">
-                <input class="flora-name" v-model="payload.taxonomySpecies[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.taxonomySpecies[idx].abundance" />
+              <view class="taxonomy-group" v-if="payload.taxonomyGenus && payload.taxonomyGenus.length">
+                <text class="taxonomy-group-label">菌属 (Genus)</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.taxonomyGenus" :key="getTaxonomyKey('g', idx)">
+                  <input class="flora-name" v-model="payload.taxonomyGenus[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.taxonomyGenus[idx].abundance" />
+                </view>
+              </view>
+              <view class="taxonomy-group" v-if="payload.taxonomySpecies && payload.taxonomySpecies.length">
+                <text class="taxonomy-group-label">菌种 (Species)</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.taxonomySpecies" :key="getTaxonomyKey('s', idx)">
+                  <input class="flora-name" v-model="payload.taxonomySpecies[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.taxonomySpecies[idx].abundance" />
+                </view>
               </view>
             </view>
           </view>
 
           <!-- 病原菌 -->
           <view class="card" v-if="hasPathogenData()">
-            <view class="card-title">病原菌 ({{ pathogenTotalCount }})</view>
-            <view class="pathogen-group" v-if="payload.pathogenGenus && payload.pathogenGenus.length">
-              <text class="pathogen-group-label">病原菌属</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.pathogenGenus" :key="getTaxonomyKey('pg', idx)">
-                <input class="flora-name" v-model="payload.pathogenGenus[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.pathogenGenus[idx].abundance" />
-                <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
-                  {{ statusText(flora.status) }}
+            <view class="card-title collapsible-title" @click="toggleCollapse('pathogen')">
+              <text>病原菌 ({{ pathogenTotalCount }})</text>
+              <text class="collapse-arrow" :class="{ 'arrow-open': collapseState.pathogen }">›</text>
+            </view>
+            <view v-if="collapseState.pathogen">
+              <view class="pathogen-group" v-if="payload.pathogenGenus && payload.pathogenGenus.length">
+                <text class="pathogen-group-label">病原菌属</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.pathogenGenus" :key="getTaxonomyKey('pg', idx)">
+                  <input class="flora-name" v-model="payload.pathogenGenus[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.pathogenGenus[idx].abundance" />
+                  <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
+                    {{ statusText(flora.status) }}
+                  </view>
                 </view>
               </view>
-            </view>
-            <view class="pathogen-group" v-if="payload.pathogenDetection && payload.pathogenDetection.length">
-              <text class="pathogen-group-label">病原菌检出</text>
-              <view class="flora-item" v-for="(flora, idx) in payload.pathogenDetection" :key="getTaxonomyKey('pd', idx)">
-                <input class="flora-name" v-model="payload.pathogenDetection[idx].name" />
-                <input class="flora-abundance" type="digit" v-model="payload.pathogenDetection[idx].abundance" />
-                <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
-                  {{ statusText(flora.status) }}
+              <view class="pathogen-group" v-if="payload.pathogenDetection && payload.pathogenDetection.length">
+                <text class="pathogen-group-label">病原菌检出</text>
+                <view class="flora-item" v-for="(flora, idx) in payload.pathogenDetection" :key="getTaxonomyKey('pd', idx)">
+                  <input class="flora-name" v-model="payload.pathogenDetection[idx].name" />
+                  <input class="flora-abundance" type="digit" v-model="payload.pathogenDetection[idx].abundance" />
+                  <view class="status-tag" :class="'status-' + _cssClass(flora.status)">
+                    {{ statusText(flora.status) }}
+                  </view>
                 </view>
               </view>
             </view>
@@ -423,6 +439,7 @@ export default {
       creatingMember: false,
       createdMemberId: null,
       groupExpandList: [],
+      collapseState: { gutFlora: false, diseaseRisk: false, probiotic: false, taxonomy: false, pathogen: false },
       showHelpPopup: false,
       helpData: {}
     }
@@ -572,11 +589,12 @@ export default {
             self.payload = Object.assign({}, self.defaultPayload(), res.data.payload)
           }
           // 后端无 blocks 时从 payload 自动组装
-          if (backendBlocks.length > 0) {
-            self.blocks = backendBlocks
+          var cleanBlocks = self.stripFoodBlocks(backendBlocks)
+          if (cleanBlocks.length > 0) {
+            self.blocks = cleanBlocks
             self.backendBlocksExist = true
           } else if (self.hasPayloadData) {
-            self.blocks = self.assembleBlocksFromPayload()
+            self.blocks = self.stripFoodBlocks(self.assembleBlocksFromPayload())
             self.backendBlocksExist = false
           } else {
             self.blocks = []
@@ -727,6 +745,9 @@ export default {
     toggleGroup(idx) {
       this.groupExpandList[idx] = !this.groupExpandList[idx]
     },
+    toggleCollapse(key) {
+      this.collapseState[key] = !this.collapseState[key]
+    },
     toggleAllGroups(open) {
       var groups = this.indicatorGroups
       var list = []
@@ -894,6 +915,18 @@ export default {
         uni.showToast({ title: '保存异常', icon: 'none' })
       })
     },
+    /** 从 blocks 中过滤掉"食物推荐/饮食推荐"块(报告详情不展示饮食推荐) */
+    stripFoodBlocks: function(blocks) {
+      var list = blocks || []
+      var result = []
+      for (var i = 0; i < list.length; i++) {
+        var b = list[i]
+        var t = (b && b.title) || ''
+        if (t.indexOf('食物推荐') >= 0 || t.indexOf('饮食推荐') >= 0) continue
+        result.push(b)
+      }
+      return result
+    },
     /** 从 payload 数据自动组装展示块(后端无 blocks 时使用) */
     assembleBlocksFromPayload: function() {
       var blocks = []
@@ -1012,22 +1045,7 @@ export default {
         })
       }
 
-      // 5. 食物推荐列表
-      var foods = p.foods || []
-      if (foods.length > 0) {
-        var foodItems = []
-        for (var fdi = 0; fdi < foods.length; fdi++) {
-          var fd = foods[fdi]
-          foodItems.push({ name: fd.foodName || fd.name || '', category: fd.category || '', score: fd.score != null ? fd.score : '' })
-        }
-        blocks.push({
-          type: 'list', title: '食物推荐',
-          columns: [{ key: 'name', label: '食材' }, { key: 'category', label: '类别' }, { key: 'score', label: '推荐指数' }],
-          items: foodItems
-        })
-      }
-
-      // 6. 益生菌种列表
+      // 5. 益生菌种列表
       var ps = p.probioticSpecies || []
       if (ps.length > 0) {
         var psItems = []
@@ -1047,7 +1065,7 @@ export default {
         })
       }
 
-      // 7. 分类学列表(纲/目/科/属/种)
+      // 6. 分类学列表(纲/目/科/属/种)
       var taxLabels = [
         { key: 'taxonomyClass', label: '菌纲 (Class)' },
         { key: 'taxonomyOrder', label: '菌目 (Order)' },
@@ -1168,6 +1186,28 @@ export default {
 }
 .expand-all-btn:active { opacity: 0.7; }
 
+/* 卡片折叠标题(肠道菌群/疾病风险/益生菌种/分类组成/病原菌) */
+.card-title.collapsible-title {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  user-select: none;
+}
+.card-title.collapsible-title:active { opacity: 0.7; }
+.collapse-arrow {
+  font-size: 30rpx;
+  color: #94A3B8;
+  margin-left: 12rpx;
+  transform: rotate(0deg);
+  transition: transform 0.2s ease;
+  flex-shrink: 0;
+}
+.collapse-arrow.arrow-open {
+  transform: rotate(90deg);
+}
+
 /* ===== 鎸囨爣鍒嗙粍鎶樺彔 ===== */
 .indicator-group {
   border: 2rpx solid #E8EDF2;
@@ -1616,31 +1656,6 @@ export default {
 
 .food-item {
   display: flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 12rpx;
-  padding: 12rpx 16rpx;
-  background: #F8FAFB;
-  border-radius: 10rpx;
-  margin-bottom: 8rpx;
-}
-.food-name {
-  flex: 1;
-  font-size: 26rpx;
-  color: #333;
-  padding: 4rpx 8rpx;
-  background: #fff;
-  border-radius: 6rpx;
-}
-.food-category {
-  width: 160rpx;
-  font-size: 24rpx;
-  color: #64748B;
-  text-align: right;
-  padding: 4rpx 8rpx;
-  background: #fff;
-  border-radius: 6rpx;
-}
 
 .action-row {
   display: flex;

+ 4 - 4
cfc-langgraph/Dockerfile

@@ -6,9 +6,9 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 
 WORKDIR /app
 
-COPY requirements.txt /tmp/requirements.txt
-RUN pip install --no-cache-dir -r /tmp/requirements.txt && \
-    rm /tmp/requirements.txt
+# 使用本地 .venv 的 site-packages(已验证包含 gunicorn 26.0.0 / fastapi 0.139.2,
+# 避免构建时从 pypi 重新下载导致版本漂移或网络失败)
+COPY .venv/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
 
 COPY app/ app/
 COPY src/ src/
@@ -26,7 +26,7 @@ EXPOSE 9000
 CMD ["python3", "-m", "gunicorn", "app.main:app", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--bind", "0.0.0.0:9000", \
-     "--workers", "2", \
+     "--workers", "1", \
      "--timeout", "300", \
      "--keep-alive", "10", \
      "--access-logfile", "-", \

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

@@ -24,6 +24,34 @@ _CN_TO_EN_OVERVIEW: dict[str, str] = {
     '核心菌属': 'coreGenusScore',
 }
 
+# 指标分区 → Java 消费端 indicators 的 category(与 Java PdfParseService 兜底路径保持一致)
+_INDICATOR_SECTION_CATEGORY: list[tuple[str, str]] = [
+    ('nutrition', '主要营养评估'),
+    ('amino_acids', '氨基酸评估'),
+    ('vitamins', '维生素评估'),
+    ('trace_elements', '微量元素评估'),
+    ('抗生素风险评估', '抗生素耐药'),
+    ('肠道屏障及代谢物', '肠道屏障功能'),
+    ('短链脂肪酸', '短链脂肪酸'),
+    ('神经递质及激素', '神经递质与激素'),
+]
+
+# 菌群检出详细列表分组 → Java 消费端键
+_FLORA_GROUP_TO_KEY: dict[str, str] = {
+    '核心菌属': 'gut_flora',
+    '益生菌': 'probiotic_species',
+    '菌纲构成': 'taxonomy_class',
+    '菌目构成': 'taxonomy_order',
+    '菌科构成': 'taxonomy_family',
+    '菌属构成': 'taxonomy_genus',
+    '菌种构成': 'taxonomy_species',
+    '病原菌属': 'pathogen_genus',
+    '病原菌检出': 'pathogen_detection',
+}
+
+# Java 端无独立字段、需并入 gut_flora(带 category 区分)的分组
+_FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
+
 
 class ReportParseAgent:
     """报告解析 Agent"""
@@ -41,6 +69,109 @@ class ReportParseAgent:
             if _cn in overview and _en not in overview:
                 overview[_en] = overview[_cn]
 
+    @staticmethod
+    def _normalize_for_java(result: dict) -> None:
+        """将算法解析结果归一化为 Java 消费端 snake_case 键。
+
+        保留中文键供调试/LLM 兜底;仅当构建出非空列表时才覆盖 result 对应键,
+        避免覆盖 LLM 兜底直接产出的 indicators/flora/foods。
+        """
+        # 1. indicators:合并各指标分区(含主要消化道致病菌特殊键名)
+        indicators = []
+        for key, category in _INDICATOR_SECTION_CATEGORY:
+            items = result.get(key)
+            if not isinstance(items, list):
+                continue
+            for it in items:
+                if not isinstance(it, dict) or not it.get('name'):
+                    continue
+                indicators.append({
+                    'category': category,
+                    'indicatorName': it['name'],
+                    'indicatorValue': it.get('value', ''),
+                    'unit': '',
+                    'refRange': it.get('refRange', ''),
+                    'status': it.get('status', ''),
+                    'symptoms': '',
+                })
+        for it in result.get('主要消化道致病菌') or []:
+            if not isinstance(it, dict):
+                continue
+            name = it.get('name') or it.get('致病菌')
+            if name:
+                indicators.append({
+                    'category': '主要消化道致病菌',
+                    'indicatorName': name,
+                    'indicatorValue': it.get('value') or it.get('丰度', ''),
+                    'unit': '',
+                    'refRange': '',
+                    'status': it.get('status') or it.get('评估', ''),
+                    'symptoms': '',
+                })
+        if indicators:
+            result['indicators'] = indicators
+
+        # 2. 菌群分组 → snake_case 键(条目字段中文 → 英文)
+        flora = result.get('菌群检出详细列表')
+        if isinstance(flora, dict):
+            for group, en_key in _FLORA_GROUP_TO_KEY.items():
+                converted = []
+                for it in flora.get(group) or []:
+                    if not isinstance(it, dict) or not it.get('名称'):
+                        continue
+                    converted.append({
+                        'name': it['名称'],
+                        'value': it.get('丰度%', ''),
+                        'normal_range': it.get('正常范围%', ''),
+                        'population_level': it.get('人群水平%', ''),
+                        'detection_rate': it.get('检出率%', ''),
+                        'description': it.get('说明', ''),
+                        'category': group,
+                        'level': it.get('水平', ''),
+                    })
+                if converted:
+                    result[en_key] = converted
+            extra = []
+            for group in _FLORA_EXTRA_GROUPS:
+                for it in flora.get(group) or []:
+                    if not isinstance(it, dict) or not it.get('名称'):
+                        continue
+                    extra.append({
+                        'name': it['名称'],
+                        'value': it.get('丰度%', ''),
+                        'normal_range': it.get('正常范围%', ''),
+                        'population_level': it.get('人群水平%', ''),
+                        'detection_rate': it.get('检出率%', ''),
+                        'description': it.get('说明', ''),
+                        'category': group,
+                        'level': it.get('水平', ''),
+                    })
+            if extra:
+                result['gut_flora'] = (result.get('gut_flora') or []) + extra
+
+        # 3. foods:个体化食物推荐表 → snake_case
+        food_table = result.get('个体化食物推荐表')
+        rows = food_table.get('数据') if isinstance(food_table, dict) else food_table
+        foods = []
+        if isinstance(rows, list):
+            for it in rows:
+                if not isinstance(it, dict) or not it.get('名称'):
+                    continue
+                foods.append({
+                    'name': it['名称'],
+                    'category': it.get('分类', ''),
+                    'score': it.get('推荐指数'),
+                    'energy_kj': it.get('能量KJ'),
+                    'protein': it.get('蛋白g'),
+                    'fat': it.get('脂肪g'),
+                    'carbs': it.get('碳水化合物g'),
+                    'starch': it.get('淀粉g'),
+                    'fiber': it.get('总膳食纤维g'),
+                    'cholesterol': it.get('胆固醇mg'),
+                })
+        if foods:
+            result['foods'] = foods
+
     async def parse(self, file_path: str) -> dict:
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         # 1. 算法解析
@@ -50,6 +181,8 @@ class ReportParseAgent:
 
         # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
         self._normalize_overview_keys(result)
+        # 1.6 归一化指标/菌群/食物为 Java 消费端 snake_case 键
+        self._normalize_for_java(result)
 
         # 2. 如果解析不完整,LLM 兜底
         if result.get('_parse_incomplete') or not result.get('disease_risks'):
@@ -65,6 +198,8 @@ class ReportParseAgent:
                     for k, v in llm_result['overview'].items():
                         if k not in result.get('overview', {}):
                             result.setdefault('overview', {})[k] = v
+                # 重新归一化:LLM 补齐的分区也要并入 indicators
+                self._normalize_for_java(result)
 
         # 清理内部标记
         result.pop('_parse_incomplete', None)

+ 2 - 6
cfc-langgraph/app/graphs/chat_graph.py

@@ -57,12 +57,8 @@ def create_chat_graph():
     )
     
     # 初始化嵌入模型用于记忆存储
-    from langchain_openai import OpenAIEmbeddings
-    embeddings = OpenAIEmbeddings(
-        model=settings.embedding_model,
-        api_key=settings.effective_embedding_api_key,
-        base_url=settings.effective_embedding_base_url,
-    )
+    from app.rag.embeddings import get_embeddings
+    embeddings = get_embeddings()
     memory_mgr = MemoryManager(embeddings)
 
     llm_with_tools = llm.bind_tools([

+ 3 - 6
cfc-langgraph/app/graphs/health_butler_graph.py

@@ -1,7 +1,7 @@
 from typing import TypedDict, Optional, Literal
 from langgraph.graph import StateGraph, START, END
 from langgraph.checkpoint.memory import MemorySaver
-from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_openai import ChatOpenAI
 from langchain_core.messages import SystemMessage, HumanMessage
 from app.tools.java_client import JavaClient
 from app.tools.product_tools import (
@@ -61,11 +61,8 @@ def create_health_butler_graph():
         temperature=settings.llm_temperature,
     )
 
-    embeddings = OpenAIEmbeddings(
-        model=settings.embedding_model,
-        api_key=settings.effective_embedding_api_key,
-        base_url=settings.effective_embedding_base_url,
-    )
+    from app.rag.embeddings import get_embeddings
+    embeddings = get_embeddings()
     memory_mgr = MemoryManager(embeddings)
 
     java = JavaClient()

+ 3 - 6
cfc-langgraph/app/graphs/health_coach_graph.py

@@ -5,7 +5,7 @@ Uses RAG retrieval from health knowledge base.
 from typing import TypedDict
 from langgraph.graph import StateGraph, START, END
 from langgraph.checkpoint.memory import MemorySaver
-from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_openai import ChatOpenAI
 from langchain_core.messages import SystemMessage, HumanMessage
 from app.rag.retriever import RagRetriever
 from app.memory.store import MemoryManager
@@ -66,11 +66,8 @@ def create_health_coach_graph():
     )
 
     # 嵌入和检索
-    embeddings = OpenAIEmbeddings(
-        model=settings.embedding_model,
-        api_key=settings.effective_embedding_api_key,
-        base_url=settings.effective_embedding_base_url,
-    )
+    from app.rag.embeddings import get_embeddings
+    embeddings = get_embeddings()
     retriever = RagRetriever(collection_name="health_knowledge")
     memory_mgr = MemoryManager(embeddings)
 

+ 1 - 1
cfc-langgraph/app/main.py

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from fastapi.middleware.base import BaseHTTPMiddleware
+from starlette.middleware.base import BaseHTTPMiddleware
 from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs
 from app import monitoring
 from src.app import router as questionnaire_router

+ 0 - 1
cfc-langgraph/app/memory/store.py

@@ -47,7 +47,6 @@ class MemoryManager:
             texts=[full_text],
             metadatas=[metadata],
         )
-        self.memory_vectorstore.persist()
         logger.info("已保存对话到向量记忆:conv=%s, user=%s", conversation_id, user_id)
 
     async def recall(self, user_id: int, query: str, k: int = 3) -> list[str]:

+ 531 - 39
cfc-langgraph/app/parsers/pdf_parser.py

@@ -23,6 +23,23 @@ RADICAL_MAP = {
     '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
     # 氏 radical U+2F52 → U+6C0F
     '\u2f52': '氏',
+    # 以下为本项目报告 PDF 中实际出现的部首字形(U+2F00-Kangxi / U+2E80-CJK Radicals)
+    '\u2f00': '一', '\u2f04': '乙', '\u2f06': '二', '\u2f08': '人',
+    '\u2f09': '儿', '\u2f0a': '入', '\u2f0f': '几', '\u2f12': '力',
+    '\u2f17': '十', '\u2f1c': '又', '\u2f1d': '口', '\u2f20': '士',
+    '\u2f25': '女', '\u2f2b': '尸', '\u2f2f': '工', '\u2f34': '广',
+    '\u2f38': '弓', '\u2f3f': '手', '\u2f40': '支', '\u2f45': '方',
+    '\u2f47': '日', '\u2f49': '月', '\u2f4c': '止', '\u2f51': '毛',
+    '\u2f53': '气', '\u2f5a': '片', '\u2f5b': '牙', '\u2f5d': '犬',
+    '\u2f70': '示', '\u2f74': '立', '\u2f79': '网', '\u2f7d': '而',
+    '\u2f83': '自', '\u2f84': '至', '\u2f86': '舌', '\u2f8a': '色',
+    '\u2f8d': '虫', '\u2f8e': '血', '\u2f8f': '行', '\u2f94': '言',
+    '\u2f9b': '走', '\u2f9c': '足', '\u2f9f': '辛', '\u2fa5': '里',
+    '\u2fae': '非', '\u2fb0': '革', '\u2fb3': '音', '\u2fb8': '首',
+    '\u2fbc': '高', '\u2fcf': '鼠', '\u2fd0': '鼻',
+    '\u2e9f': '母', '\u2ec5': '见', '\u2ec6': '角', '\u2ed3': '长',
+    '\u2ed4': '门', '\u2ed8': '青', '\u2ed9': '韦', '\u2edb': '风',
+    '\u2ee3': '骨', '\u2eec': '齐',
 }
 
 KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
@@ -86,16 +103,23 @@ def norm(s):
     return ''.join(RADICAL_MAP.get(c, c) for c in s)
 
 
-def detect_format(lines):
-    """检测 triplet / inline 格式"""
-    text = '\n'.join(lines)
-    has_triplet = '指标范围' in text and '疾病风险评估' in text
-    for line in lines:
-        if len(line) > 15 and re.search(r'[\u4e00-\u9fff]+[\d.]+[\u4e00-\u9fff/]+', line):
-            for known in KNOWN_DISEASE_RISKS:
-                if known in line:
+def detect_format(pages_text):
+    """检测 triplet / inline 格式(v5 detect_format 移植,页面级判定)
+
+    仅当同一页同时含'疾病风险评估'+'指标范围'且存在内嵌数字的行时才算 inline,
+    否则按 triplet 处理(标准模板报告为 triplet,报告类 PDF 为 inline)。
+    """
+    for pt in pages_text:
+        t = norm(pt)
+        if '疾病风险评估' in t and '指标范围' in t:
+            for line in t.split('\n'):
+                ls = line.strip()
+                if not ls:
+                    continue
+                if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', ls):
                     return 'inline'
-    return 'triplet' if has_triplet else 'inline'
+            return 'triplet'
+    return 'triplet'
 
 
 def extract_text(file_path):
@@ -156,8 +180,393 @@ def parse_triplet_until(lines, stop_markers):
     return results
 
 
+# ==========================================
+# 完整分区提取(移植自 extract_full_report_v5.py,修复分区错位)
+# 原问题:疾病风险 stop 标记缺'营养状况评估'导致吞并营养/氨基酸;
+#        维生素/微量元素标题在数据后(Case B)导致为空;屏障/SCFA/神经递质/病原菌/抗生素缺失
+# ==========================================
+
+def _extract_all_triplet(all_lines):
+    """按'指标范围'数据页标记分段提取(v5 extract_all_triplet 移植)
+
+    数据页索引约定(v5 实证):
+      data_pages[0] = 疾病风险页('疾病风险评估'标题前也有'指标范围')
+      data_pages[1] = 营养状况评估页(含前5个氨基酸:苏氨酸/异亮氨酸/亮氨酸/赖氨酸/蛋氨酸)
+      data_pages[2] = 剩余氨基酸页
+      data_pages[3] = 维生素评估页(含微量元素)
+    """
+    data_pages = []
+    for i, line in enumerate(all_lines):
+        if line == '指标范围':
+            data_pages.append(i)
+    result = {}
+
+    # 数据页1: 疾病风险评估(按已知疾病名过滤在调用方做)
+    if len(data_pages) >= 1:
+        i = data_pages[0] + 1
+        if i < len(all_lines) and all_lines[i] == '疾病风险评估':
+            i += 1
+        rows = parse_triplet_until(all_lines[i:],
+            ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估',
+             '主要消化道致病菌', '抗生素风险评估', '抗生素耐药风险'])
+        result['disease_risks'] = [r for r in rows if '注:' not in r['name'] and '注:' not in r['name']]
+
+    # 数据页2: 营养状况评估(含前5个氨基酸)
+    if len(data_pages) >= 2:
+        i = data_pages[1] + 1
+        if i < len(all_lines) and all_lines[i] == '营养状况评估':
+            i += 1
+        rows = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
+        result['nutrition_rows'] = rows[:5]
+        result['amino_p2'] = rows[5:10] if len(rows) > 5 else []
+
+    # 数据页3: 剩余氨基酸
+    if len(data_pages) >= 3:
+        i = data_pages[2] + 1
+        if i < len(all_lines) and '氨基酸' in all_lines[i]:
+            while i < len(all_lines) and '氨基酸' in all_lines[i]:
+                i += 1
+        remaining = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
+        result['amino_p3'] = remaining
+
+    # 数据页4: 维生素评估 + 微量元素
+    if len(data_pages) >= 4:
+        i = data_pages[3] + 1
+        if i < len(all_lines) and all_lines[i] == '维生素评估':
+            i += 1
+        vit_rows = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
+        vit, trace = [], []
+        for r in vit_rows:
+            if '维生素' in r['name']:
+                vit.append(r)
+            else:
+                trace.append(r)
+        result['vitamin_rows'] = vit
+        result['trace_rows'] = trace
+
+    # 主要消化道致病菌(特殊格式:致病菌/丰度%/评估)
+    for i, line in enumerate(all_lines):
+        if line == '主要消化道致病菌':
+            j = i + 1
+            while j < len(all_lines) and all_lines[j] in ('致病菌', '丰度', '评估'):
+                j += 1
+            path_rows = []
+            while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
+                name = all_lines[j]
+                if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
+                    path_rows.append({'name': name, 'value': all_lines[j+1], 'status': all_lines[j+2]})
+                    j += 3
+                else:
+                    j += 1
+            result['pathogens'] = path_rows
+            break
+
+    # 抗生素风险评估(去重:同名只保留第一条)
+    for i, line in enumerate(all_lines):
+        if line == '抗生素风险评估':
+            abx_rows = parse_triplet_until(all_lines[i+1:], ['抗生素耐药风险', '个体化食物推荐表'])
+            seen = set()
+            deduped = []
+            for r in abx_rows:
+                n = r.get('name', '')
+                if n and n not in seen:
+                    seen.add(n)
+                    deduped.append(r)
+            result['antibiotics'] = deduped
+            break
+
+    return result
+
+
+def _extract_barrier_and_scfa_triplet(all_lines):
+    """肠道屏障及代谢物/短链脂肪酸/神经递质及激素 - 已知指标名精确匹配(v5 移植)"""
+    results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
+    current_section = None
+    i = 0
+    while i < len(all_lines):
+        line = all_lines[i]
+        if line == '肠道屏障及菌群代谢物':
+            current_section = 'barrier'
+            i += 1
+            continue
+        if '短链脂肪酸' in line:
+            current_section = 'scfa'
+            i += 1
+            continue
+        if '神经递质' in line:
+            current_section = 'neurotransmitter'
+            i += 1
+            continue
+        if current_section is None:
+            i += 1
+            continue
+
+        # 跳过头行
+        if line in ('名称', '评估值', '正常范围', '过量', '缺乏', '相关症状') or \
+           '过量 /' in line or '缺乏' in line:
+            i += 1
+            continue
+
+        # 提前终止
+        if current_section == 'barrier' and line in ('短链脂肪酸', '抗生素风险', '抗生素耐药风险'):
+            break
+        if current_section == 'scfa' and line in ('神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险'):
+            break
+        if current_section == 'neurotransmitter' and line in ('抗生素风险', '个体化食物推荐表'):
+            break
+
+        # 只处理已知指标名
+        known = {'barrier': KNOWN_BARRIER, 'scfa': KNOWN_SCFA, 'neurotransmitter': KNOWN_NEURO}
+        known_list = known[current_section]
+        matched_name = None
+        for kn in sorted(known_list, key=len, reverse=True):
+            if line.startswith(kn) or line == kn:
+                matched_name = kn
+                break
+        if matched_name is None:
+            i += 1
+            continue
+
+        # 模式1:整行解析 名称+数值+状态+范围(inline 单行)
+        parts = line.split()
+        if len(parts) >= 2:
+            vi = None
+            for pi, p in enumerate(parts):
+                if re.match(r'^\d+$', p) and pi >= 1:
+                    vi = pi
+                    break
+            if vi:
+                val = parts[vi]
+                status = ''
+                range_ = ''
+                for rp in parts[vi+1:]:
+                    if rp in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
+                        status = rp
+                    elif re.match(r'^\d+-\d+$', rp):
+                        range_ = rp
+                results[current_section].append({
+                    'name': matched_name, 'value': val, 'status': status, 'refRange': range_
+                })
+                i += 1
+                continue
+
+        # 模式2:下一行是数值(三元组)
+        if i + 1 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
+            val = all_lines[i+1]
+            status = ''
+            range_ = ''
+            for j in range(2, min(6, len(all_lines)-i)):
+                if all_lines[i+j] in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
+                    status = all_lines[i+j]
+                elif re.match(r'^\d+-\d+$', all_lines[i+j]):
+                    range_ = all_lines[i+j]
+            results[current_section].append({
+                'name': matched_name, 'value': val, 'status': status, 'refRange': range_
+            })
+            i += 2
+            continue
+
+        i += 1
+
+    # 去重(每个 section 内每个指标名只保留一条)
+    for section in ('barrier', 'scfa', 'neurotransmitter'):
+        seen = set()
+        deduped = []
+        for item in results[section]:
+            n = item.get('name', '')
+            if n and n not in seen:
+                seen.add(n)
+                deduped.append(item)
+        results[section] = deduped
+
+    return results
+
+
+def _extract_names_from_region(region_str, known_names, end_pos):
+    """从区域文本中提取已知名称+数值+状态(v5 移植)"""
+    for h in ['肠道屏障及菌群代谢物名称评估值正常范围过量 / 缺乏相关症状',
+              '名称评估值正常范围过量 / 缺乏相关症状',
+              '短链脂肪酸评估值正常范围过量 / 缺乏相关症状',
+              '神经递质及激素评估值正常范围过量 / 缺乏相关症状',
+              '过量 / 缺乏相关症状']:
+        region_str = region_str.replace(h, '')
+    positions = []
+    for kn in known_names:
+        # 找所有匹配位置,选第一个后面有数字(允许空格)且不是头部标记的
+        search_start = 0
+        while True:
+            ii = region_str.find(kn, search_start)
+            if ii == -1:
+                break
+            after = region_str[ii+len(kn):ii+len(kn)+10].lstrip()
+            if re.match(r'\d', after):
+                positions.append((ii, kn))
+                break  # 找到第一个有效匹配
+            search_start = ii + 1
+    if not positions:
+        return []
+    # 去重:同名先后取前面(较精准的),不同名重叠取较长的
+    positions.sort()
+    filtered = []
+    for i, (pos, name) in enumerate(positions):
+        has_longer = False
+        for j, (pos2, name2) in enumerate(positions):
+            if i != j and pos2 <= pos and pos + len(name) <= pos2 + len(name2) and len(name2) > len(name):
+                has_longer = True
+                break
+        if not has_longer:
+            filtered.append((pos, name))
+    positions = filtered
+    seen_names = {}
+    for pos, name in positions:
+        if name not in seen_names:
+            seen_names[name] = pos
+    positions = [(pos, name) for name, pos in seen_names.items()]
+    positions.sort()
+    local = []
+    for i, (ii, kn) in enumerate(positions):
+        start_after = ii + len(kn)
+        after_text = region_str[start_after:].lstrip()
+        vm = re.match(r'(\d+(?:\.\d+)?)', after_text)
+        if not vm:
+            continue
+        val = vm.group(1)
+        whitespace_skipped = len(region_str[start_after:]) - len(after_text)
+        val_start = start_after + whitespace_skipped
+        val_end = val_start + len(val)
+        if i + 1 < len(positions):
+            next_idx = positions[i + 1][0]
+            status = region_str[val_end:next_idx].strip()
+        else:
+            status = region_str[val_end:end_pos].strip()
+        # 清理状态中的残留标记
+        status = re.sub(
+            r'注意大环内酯类|注意呋喃类|注意氯霉素类|注意喹诺酮类|注意磺胺类|'
+            r'注意甲氧苄啶类|注意四环素类|正常氨基酸评估|正常维生素评估|'
+            r'正常微量元素评估|指标范围|疾病风险评估|营养状况评估|'
+            r'抗生素风险评估|抗生素耐药风险|主要消化道致病菌',
+            '', status).strip()
+        # 截断:状态中出现'注'(如'注:本检测仅用于...')只取之前部分
+        status = re.split(r'注[::]', status)[0].strip()
+        if len(status) > 20:
+            status = status[:20]
+        local.append({'name': kn, 'value': val, 'status': status})
+    return local
+
+
+def _extract_inline_module(all_text, start_marker, end_markers, known_names):
+    """从 inline 文本中提取已知名称的指标(v5 移植)
+
+    支持两种布局:
+    A) 标题在前、数据在后(标准)
+    B) 标题在后、数据在前('指标范围'与标题之间)
+    """
+    sm_idx = all_text.find(start_marker)
+    if sm_idx == -1:
+        return []
+    end_pos = len(all_text)
+    for em in end_markers:
+        ei = all_text.find(em, sm_idx + len(start_marker))
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+
+    # Case A: 标题在前,数据在标题和 end_marker 之间
+    region_a = all_text[sm_idx:end_pos]
+    result_a = _extract_names_from_region(region_a, known_names, len(region_a))
+
+    # Case B: 标题在后,数据在'指标范围'和标题之间
+    zb_idx = all_text.rfind('指标范围', 0, sm_idx)
+    if zb_idx == -1:
+        zb_idx = max(0, sm_idx - 500)
+    region_b = all_text[zb_idx:sm_idx]
+    result_b = _extract_names_from_region(region_b, known_names, len(region_b))
+
+    # 取匹配多的那个
+    if len(result_a) >= len(result_b):
+        return result_a
+    return result_b
+
+
+def _extract_pathogens_inline(text):
+    """提取 inline 格式中主要消化道致病菌数据(幽门螺杆菌0%未检出...)"""
+    results = []
+    sidx = text.find('主要消化道致病菌')
+    if sidx == -1:
+        return results
+    eidx = text.find('肠道屏障', sidx)
+    if eidx == -1:
+        eidx = len(text)
+    region = text[sidx:eidx]
+    for name in KNOWN_PATHOGENS:
+        idx = region.find(name)
+        if idx == -1:
+            continue
+        after = region[idx + len(name):]
+        m = re.match(r'\s*(\d+%)', after)
+        if not m:
+            continue
+        abundance = m.group(1)
+        assessment_start = idx + len(name) + m.end()
+        assessment_end = len(region)
+        for next_name in KNOWN_PATHOGENS:
+            if next_name == name:
+                continue
+            ni = region.find(next_name, assessment_start)
+            if ni != -1 and ni < assessment_end:
+                assessment_end = ni
+        assessment = region[assessment_start:assessment_end].strip()
+        results.append({'name': name, 'value': abundance, 'status': assessment})
+    return results
+
+
+def _final_fallback_scan(full_text, result, section_known_map):
+    """最终的全文正则扫描——弥补所有提取器漏掉的已知指标项(v5 移植)"""
+    for section, known_list in section_known_map.items():
+        existing = {r['name'] for r in result.get(section, [])}
+        for name in known_list:
+            if name in existing:
+                continue
+            # 搜索多个变体:完整名、去掉括号的后缀
+            search_names = [name]
+            paren = name.find('(')
+            if paren != -1:
+                search_names.append(name[:paren])
+            found = False
+            for sname in search_names:
+                idx = full_text.find(sname)
+                if idx == -1:
+                    continue
+                # 跳过名称之后紧跟着的重复名称(正字+部首两次出现的情况)
+                skip = idx + len(sname)
+                while skip < len(full_text):
+                    ch = full_text[skip]
+                    if ch == '\n' or ch == ' ':
+                        skip += 1
+                    elif '\u4e00' <= ch <= '\u9fff' or '\u2f00' <= ch <= '\u2fdf':
+                        skip += 1
+                    else:
+                        break
+                after = full_text[skip:].lstrip()
+                m = re.match(r'(\d+(?:\.\d+)?)', after)
+                if not m:
+                    continue
+                val = m.group(1)
+                val_end_in_after = m.end()
+                status_raw = after[val_end_in_after:].lstrip()
+                status_m = re.search(r'([\u4e00-\u9fff/]+)', status_raw)
+                status = status_m.group(1).strip() if status_m else status_raw[:20]
+                if len(status) > 20:
+                    status = status[:20]
+                result.setdefault(section, []).append({'name': name, 'value': val, 'status': status})
+                found = True
+                break
+    return result
+
+
 def parse_report_pdf(file_path: str) -> dict:
-    """主函数:解析 PDF 返回结构化数据"""
+    """主函数:解析 PDF 返回结构化数据(v5 完整提取逻辑移植)"""
+    reader = PdfReader(file_path)
+    pages_text = [p.extract_text() or '' for p in reader.pages]
     lines = extract_text(file_path)
     full_text = '\n'.join(lines)
 
@@ -165,38 +574,121 @@ def parse_report_pdf(file_path: str) -> dict:
     if detect_beijing_format(full_text):
         return _parse_beijing_report(file_path)
 
-    fmt = detect_format(lines)
+    fmt = detect_format(pages_text)
     result = {'format': fmt, 'overview': parse_overview(lines)}
 
-    # 疾病风险评估
-    for i, line in enumerate(lines):
-        if '疾病风险评估' in line and '注' not in line:
-            risks = parse_triplet_until(lines[i+1:],
-                ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估', '抗生素风险评估'])
-            result['disease_risks'] = [r for r in risks if '注' not in r['name']]
-            break
-
-    # 主要营养评估
-    for i, line in enumerate(lines):
-        if '主要营养评估' in line:
-            nutrients = parse_triplet_until(lines[i+1:], ['氨基酸评估'])
-            result['nutrition'] = nutrients[:5]
-            break
+    _SECTION_KNOWN_MAP = {
+        'disease_risks': KNOWN_DISEASE_RISKS,
+        'nutrition': KNOWN_MACRO,
+        'amino_acids': KNOWN_AMINO,
+        'vitamins': KNOWN_VITAMINS,
+        'trace_elements': KNOWN_TRACE,
+        '抗生素风险评估': KNOWN_ANTIBIOTICS,
+        '肠道屏障及代谢物': KNOWN_BARRIER,
+        '短链脂肪酸': KNOWN_SCFA,
+        '神经递质及激素': KNOWN_NEURO,
+    }
 
-    # 氨基酸评估
-    for i, line in enumerate(lines):
-        if '氨基酸评估' in line:
-            aminos = parse_triplet_until(lines[i+1:], ['维生素评估', '微量元素评估'])
-            result['amino_acids'] = aminos
-            break
+    if fmt == 'triplet':
+        parsed = _extract_all_triplet(lines)
+
+        # 疾病风险评估(已知疾病名词表过滤)
+        disease_set = set(KNOWN_DISEASE_RISKS)
+        result['disease_risks'] = [r for r in parsed.get('disease_risks', []) if r['name'] in disease_set]
+        # 兜底:inline 方式补充 triplet 漏掉的项目
+        for r in _extract_inline_module(full_text, '疾病风险评估',
+                                        ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS):
+            if r['name'] not in {x['name'] for x in result['disease_risks']}:
+                result['disease_risks'].append(r)
+
+        # 主要营养评估
+        nutri_set = set(KNOWN_MACRO)
+        result['nutrition'] = [r for r in parsed.get('nutrition_rows', []) if r['name'] in nutri_set]
+        for r in _extract_inline_module(full_text, '营养状况评估',
+                                        ['主要营养评估', '氨基酸评估'], KNOWN_MACRO):
+            if r['name'] not in {x['name'] for x in result['nutrition']}:
+                result['nutrition'].append(r)
+
+        # 氨基酸评估(营养页内嵌 p2 + 独立页 p3 合并 + inline 兜底)
+        amino_rows = parsed.get('amino_p2', []) + parsed.get('amino_p3', [])
+        amino_set = set(KNOWN_AMINO)
+        result['amino_acids'] = [r for r in amino_rows if r['name'] in amino_set]
+        for r in _extract_inline_module(full_text, '氨基酸评估',
+                                        ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO):
+            if r['name'] not in {x['name'] for x in result['amino_acids']}:
+                result['amino_acids'].append(r)
+
+        # 维生素评估 / 微量元素评估
+        vit_set = set(KNOWN_VITAMINS)
+        trace_set = set(KNOWN_TRACE)
+        result['vitamins'] = [r for r in parsed.get('vitamin_rows', []) if r['name'] in vit_set]
+        result['trace_elements'] = [r for r in parsed.get('trace_rows', []) if r['name'] in trace_set]
+        for r in _extract_inline_module(full_text, '维生素评估',
+                                        ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS):
+            if r['name'] not in {x['name'] for x in result['vitamins']}:
+                result['vitamins'].append(r)
+        for r in _extract_inline_module(full_text, '微量元素评估',
+                                        ['主要消化道致病菌'], KNOWN_TRACE):
+            if r['name'] not in {x['name'] for x in result['trace_elements']}:
+                result['trace_elements'].append(r)
+
+        # 主要消化道致病菌
+        result['主要消化道致病菌'] = parsed.get('pathogens', [])
+
+        # 抗生素风险评估(已知抗生素词表过滤)
+        abx_set = set(KNOWN_ANTIBIOTICS)
+        result['抗生素风险评估'] = [r for r in parsed.get('antibiotics', []) if r['name'] in abx_set]
+
+        # 肠道屏障 + 短链脂肪酸 + 神经递质(已知指标名精确匹配)
+        bs = _extract_barrier_and_scfa_triplet(lines)
+        result['肠道屏障及代谢物'] = bs['barrier']
+        result['短链脂肪酸'] = bs['scfa']
+        result['神经递质及激素'] = bs['neurotransmitter']
+        # inline 兜底补充
+        for section_key, start_mk, end_mks, known_list in [
+            ('肠道屏障及代谢物', '肠道屏障及菌群代谢物',
+             ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER),
+            ('短链脂肪酸', '短链脂肪酸',
+             ['神经递质', '抗生素风险'], KNOWN_SCFA),
+            ('神经递质及激素', '神经递质',
+             ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO),
+        ]:
+            existing = {r['name'] for r in result.get(section_key, [])}
+            for r in _extract_inline_module(full_text, start_mk, end_mks, known_list):
+                if r['name'] not in existing:
+                    result.setdefault(section_key, []).append(r)
+                    existing.add(r['name'])
+
+        # 最终兜底:全文正则扫描所有已知指标名
+        _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
 
-    # 维生素评估
-    for i, line in enumerate(lines):
-        if '维生素评估' in line:
-            vits = parse_triplet_until(lines[i+1:], ['微量元素评估', '抗生素风险评估'])
-            result['vitamins'] = [r for r in vits if '维生素' in r['name']]
-            result['trace_elements'] = [r for r in vits if '维生素' not in r['name']]
-            break
+    else:
+        # inline 格式(v5 extract_pdf_to_json inline 分支移植)
+        result['disease_risks'] = _extract_inline_module(full_text, '疾病风险评估',
+            ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS)
+        result['nutrition'] = _extract_inline_module(full_text, '营养状况评估',
+            ['主要营养评估', '氨基酸评估'], KNOWN_MACRO)
+        result['amino_acids'] = _extract_inline_module(full_text, '营养状况评估',
+            ['主要营养评估', '氨基酸评估'], KNOWN_AMINO)
+        amino_p2 = _extract_inline_module(full_text, '氨基酸评估',
+            ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO)
+        for a in amino_p2:
+            if a['name'] not in {x['name'] for x in result['amino_acids']}:
+                result['amino_acids'].append(a)
+        result['vitamins'] = _extract_inline_module(full_text, '维生素评估',
+            ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS)
+        result['trace_elements'] = _extract_inline_module(full_text, '微量元素评估',
+            ['主要消化道致病菌'], KNOWN_TRACE)
+        result['主要消化道致病菌'] = _extract_pathogens_inline(full_text)
+        result['抗生素风险评估'] = _extract_inline_module(full_text, '抗生素风险评估',
+            ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS)
+        result['肠道屏障及代谢物'] = _extract_inline_module(full_text, '肠道屏障及菌群代谢物',
+            ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
+        result['短链脂肪酸'] = _extract_inline_module(full_text, '短链脂肪酸',
+            ['神经递质', '抗生素风险'], KNOWN_SCFA)
+        result['神经递质及激素'] = _extract_inline_module(full_text, '神经递质',
+            ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO)
+        _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
 
     # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
     bacteria_tables = _extract_bacteria_tables(file_path)
@@ -218,7 +710,7 @@ def parse_report_pdf(file_path: str) -> dict:
     result['短链脂肪酸合成能力'] = {}
     result['肠道菌群精准分布'] = []
     result['营养物质及营养素代谢评估'] = {}
-    result['抗生素风险评估'] = {}
+    result.setdefault('抗生素风险评估', {})
     result['毒性物质清除能力评估'] = {}
     result['趣味肠菌评估'] = {}
     result['胃肠道感染病原体'] = []

+ 28 - 3
cfc-langgraph/app/rag/embeddings.py

@@ -1,15 +1,40 @@
-from langchain_openai import OpenAIEmbeddings
+from typing import List
+from langchain_core.embeddings import Embeddings
+from openai import OpenAI
 from app.config import settings
 
+
+class SiliconFlowEmbeddings(Embeddings):
+    """OpenAI 兼容 embeddings 直连客户端(绕开 langchain tiktoken token 化)
+
+    langchain 的 OpenAIEmbeddings 默认 tiktoken_enabled=True,会把文本编码成
+    整数 token 数组发送给 API(OpenAI 官方接受,但 siliconflow 等第三方网关
+    只接受字符串 input,导致 400 code:20015 parameter invalid)。
+    本类直接用 openai SDK 发送字符串数组,兼容 siliconflow / siliconflow 等网关。
+    """
+
+    def __init__(self, model: str, api_key: str, base_url: str):
+        self.model = model
+        self.client = OpenAI(api_key=api_key, base_url=base_url)
+
+    def embed_documents(self, texts: List[str]) -> List[List[float]]:
+        resp = self.client.embeddings.create(model=self.model, input=texts)
+        return [d.embedding for d in resp.data]
+
+    def embed_query(self, text: str) -> List[float]:
+        resp = self.client.embeddings.create(model=self.model, input=[text])
+        return resp.data[0].embedding
+
+
 _embeddings = None
 
 
 def get_embeddings():
     global _embeddings
     if _embeddings is None:
-        _embeddings = OpenAIEmbeddings(
+        _embeddings = SiliconFlowEmbeddings(
             model=settings.embedding_model,
             api_key=settings.effective_embedding_api_key,
             base_url=settings.effective_embedding_base_url,
         )
-    return _embeddings
+    return _embeddings

+ 5 - 1
cfc-langgraph/app/rag/loader.py

@@ -25,7 +25,11 @@ class KnowledgeLoader:
             })
             data = resp.json()
             if data.get("code") == 200:
-                return data.get("data", [])
+                body = data.get("data", [])
+                # 兼容两种响应:直接 list 或 {records:[...]} 分页结构
+                if isinstance(body, dict):
+                    body = body.get("records", []) or []
+                return body
         except Exception as e:
             logger.warning("增量获取文章失败: %s", e)
         return []

+ 32 - 23
cfc-langgraph/app/rag/retriever.py

@@ -1,6 +1,7 @@
 from langchain_chroma import Chroma
-from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_openai import ChatOpenAI
 from app.config import settings
+from app.rag.embeddings import get_embeddings
 from app.tools.java_client import JavaClient
 from typing import Optional
 import logging
@@ -12,11 +13,7 @@ class RagRetriever:
     """简单的向量检索器 - 基于 ChromaDB"""
 
     def __init__(self, collection_name: str = "cfc_knowledge"):
-        embeddings = OpenAIEmbeddings(
-            model=settings.embedding_model,
-            api_key=settings.effective_embedding_api_key,
-            base_url=settings.effective_embedding_base_url,
-        )
+        embeddings = get_embeddings()
         self.vectorstore = Chroma(
             collection_name=collection_name,
             embedding_function=embeddings,
@@ -28,24 +25,36 @@ class RagRetriever:
         """从 Java 侧拉取知识库,更新到向量库"""
         try:
             articles = await self.java_client.get_published_articles()
-            if articles:
-                # 构建文本和内容元数据
-                texts = [f"{a['title']} {a.get('summary', '')} {' '.join(a.get('tags', []))}" 
-                        for a in articles]
-                metadatas = [
-                    {
-                        "id": a["id"],
-                        "title": a["title"],
-                        "summary": a.get("summary", ""),
-                        "tags": a.get("tags", []),
-                        "type": "article",
-                    }
-                    for a in articles
-                ]
+            if not articles:
+                logger.info("知识库初始化:无已发布文章")
+                return
+            # 防御式处理:容忍缺字段 / tags 为字符串或列表
+            texts = []
+            metadatas = []
+            for a in articles:
+                if not isinstance(a, dict):
+                    continue
+                title = a.get("title", "")
+                if not title:
+                    continue
+                tags = a.get("tags", []) or []
+                tags_str = tags if isinstance(tags, str) else " ".join(str(t) for t in tags)
+                texts.append(f"{title} {a.get('summary', '')} {tags_str}")
+                metadatas.append({
+                    "id": str(a["id"]),
+                    "title": title,
+                    "summary": a.get("summary", ""),
+                    "tags": tags,
+                    "type": "article",
+                })
+
+            if not texts:
+                logger.info("知识库初始化:无有效文章内容")
+                return
 
-                # 添加或更新向量数据库
-                await self.vectorstore.aadd_texts(texts=texts, metadatas=metadatas)
-                logger.info("知识库向量化完成:%d 篇文章", len(articles))
+            # 添加或更新向量数据库
+            await self.vectorstore.aadd_texts(texts=texts, metadatas=metadatas)
+            logger.info("知识库向量化完成:%d 篇文章", len(metadatas))
         except Exception as e:
             logger.warning("知识库初始化失败:%s", e)
 

+ 0 - 1
cfc-langgraph/app/tasks/knowledge_sync.py

@@ -119,7 +119,6 @@ async def sync_knowledge_base():
     for i in range(0, len(all_chunks), BATCH_SIZE):
         batch = all_chunks[i:i + BATCH_SIZE]
         await vectorstore.aadd_documents(batch)
-        vectorstore.persist()
         logger.debug("知识库同步进度: %d/%d", i + len(batch), len(all_chunks))
 
     import datetime

+ 17 - 5
cfc-langgraph/app/tools/java_client.py

@@ -39,12 +39,24 @@ class JavaClient:
             JavaClient._instance = None
 
     async def get_published_articles(self) -> list[dict]:
+        """拉取已发布知识库文章(分页 + records 解析,避免单次响应过大 / 返回 dict 结构)"""
         client = await self._get_client()
-        resp = await client.post("/api/article/list", json={"status": "published", "limit": 1000})
-        data = resp.json()
-        if data.get("code") == 200:
-            return data.get("data", [])
-        return []
+        page = 1
+        size = 50
+        records = []
+        while True:
+            resp = await client.post("/api/article/list", json={"status": "published", "page": page, "size": size})
+            data = resp.json()
+            if data.get("code") != 200:
+                break
+            body = data.get("data", {}) or {}
+            items = body.get("records", []) or []
+            records.extend(items)
+            total = body.get("total", 0)
+            if page * size >= int(total or 0) or not items:
+                break
+            page += 1
+        return records
 
     async def search_products(self, keyword: str, limit: int = 5) -> list[dict]:
         client = await self._get_client()

+ 2 - 2
cfc-langgraph/pyproject.toml

@@ -13,8 +13,8 @@ dependencies = [
     "langchain-community>=0.3,<1.0",
     "langchain-openai>=0.3,<1.0",
     "langgraph>=0.3,<1.0",
-    "chromadb>=0.6,<1.0",
-    "langchain-chroma>=0.2,<1.0",
+    "chromadb==0.5.23",
+    "langchain-chroma==0.1.4",
     "python-multipart>=0.0.20",
     "prometheus-client>=0.21",
 ]

+ 2 - 0
cfc-langgraph/requirements.txt

@@ -3,6 +3,8 @@ uvicorn[standard]>=0.29,<1.0
 langgraph>=0.2,<1.0
 langchain-openai>=0.2,<1.0
 langchain-core>=0.3,<1.0
+chromadb==0.5.23
+langchain-chroma==0.1.4
 pydantic>=2.0,<3.0
 python-dotenv>=1.0,<2.0
 httpx>=0.27,<1.0

+ 15 - 32
cfc-web/src/views/Layout.vue

@@ -157,7 +157,6 @@ import { getAdminInfo, changePassword } from '@/api/auth'
 import { updateAdminInfo } from '@/api/admin'
 import { getMyInviteCode } from '@/api/guide'
 import { getUnreadMessageCount } from '@/api/teacherMessage'
-import { getUserMenus } from '@/api/menu'
 import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
 import axios from 'axios'
 
@@ -333,8 +332,6 @@ export default {
               ]},
           ]},
       ],
-      // 后端动态菜单(菜单管理功能;null=未加载/未启用,此时回退到硬编码 menuItems)
-      dynamicMenuItems: null,
       profileDialogVisible: false,
       passwordDialogVisible: false,
       submitting: false,
@@ -384,13 +381,11 @@ export default {
     activeMenu() {
       return this.$route.path
     },
-    // 动态菜单优先,未加载/加载失败时回退硬编码 menuItems
-    effectiveMenuItems() {
-      if (this.dynamicMenuItems && this.dynamicMenuItems.length > 0) {
-        return this.dynamicMenuItems
-      }
-      return this.menuItems
-    },
+    // 菜单数据源:mounted 中 fetchMenu() 用 /api/admin/menu/tree 全量数据覆盖,
+      // 失败时保留硬编码 menuItems 作为兜底
+      effectiveMenuItems() {
+        return this.menuItems
+      },
     currentRole() {
       return localStorage.getItem('role') || 'admin'
     },
@@ -425,18 +420,16 @@ export default {
   mounted() {
     // 清除可能的 beforeunload 处理,防止页面刷新弹出"离开网站?"提示
     window.onbeforeunload = null
-    // 响应式监听
-    this.handleResize = this.handleResize.bind(this)
-    window.addEventListener('resize', this.handleResize)
-    // 加载后端动态菜单(失败静默回退到硬编码)
-    this.loadDynamicMenus()
-    // 有消息权限(老师/营养师角色)才加载未读数,管理员不调用
-    if (hasPermission(this.effectivePerms, 'messages') && this.currentRole !== 'admin') {
-      this.fetchUnreadCount()
-    }
-    // 从后端加载菜单,失败则使用硬编码菜单
-    this.fetchMenu()
-  },
+// 响应式监听
+      this.handleResize = this.handleResize.bind(this)
+      window.addEventListener('resize', this.handleResize)
+      // 有消息权限(老师/营养师角色)才加载未读数,管理员不调用
+      if (hasPermission(this.effectivePerms, 'messages') && this.currentRole !== 'admin') {
+        this.fetchUnreadCount()
+      }
+      // 从后端加载菜单(/api/admin/menu/tree),失败则使用硬编码菜单
+      this.fetchMenu()
+    },
   beforeDestroy() {
     window.removeEventListener('resize', this.handleResize)
   },
@@ -444,16 +437,6 @@ export default {
     hasPerm(required) {
       return hasPermission(this.effectivePerms, required)
     },
-    loadDynamicMenus() {
-      const self = this
-      getUserMenus().then(function(res) {
-        if (res && res.code === 200 && res.data && res.data.length > 0) {
-          self.dynamicMenuItems = res.data
-        }
-      }).catch(function() {
-        // 加载失败静默回退硬编码菜单
-      })
-    },
     getMenuIndex(item) {
       return item.path === '/dashboard' && this.currentRole === 'teacher'
         ? '/teacher-dashboard'