Browse Source

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

- RestTemplateConfig 读超时 30s→180s,避免 LangGraph 报告解析超时回退本地拼接(5776)
- HealthPlanServiceImpl 修复任务行正则,编号行任务可正常生成
- report-detail 移除饮食推荐,肠道菌群/疾病风险/益生菌种/分类/病原菌改为折叠展示
- cfc-langgraph 增强 PDF 报告解析(营养/药敏/氨基酸/症状字段)、rag 检索与向量化
- schema.sql 补充新表;DatabaseInitializer/AIService/Layout 相关调整
Xiaogang Liao 2 tháng trước cách đây
mục cha
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();
         seedHealthNorms();
         runLateMigrations();
         runLateMigrations();
         migration102();
         migration102();
+        // 迁移226-247: 原seedFullRegionData内的DDL迁移,确保不因省市区数据已存在而跳过
+        runFullRegionDataMigrations();
         seedFullRegionData();
         seedFullRegionData();
 
 
         }
         }
@@ -3918,14 +3920,6 @@ try {
 
 
         ensureColumn("health_reports", "subject_id", "BIGINT COMMENT '报告主体用户ID(关联users表,为null表示未绑定)'");
         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("成长规划师模块、财富维度及健康检测报告数据库表初始化完成");
         log.info("成长规划师模块、财富维度及健康检测报告数据库表初始化完成");
 
 
         // 健康打卡表
         // 健康打卡表
@@ -3949,31 +3943,6 @@ try {
         }
         }
 
 
         // 迁移31: 创建联系人表
         // 迁移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 {
         try {
@@ -3993,110 +3962,7 @@ try {
             log.warn("创建article_reading_records表可能已存在: {}", e.getMessage());
             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", "delivery_method", "TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可'");
         ensureColumn("products", "category_id", "BIGINT COMMENT '商品类目ID'");
         ensureColumn("products", "category_id", "BIGINT COMMENT '商品类目ID'");
         ensureColumn("products", "distribution_system_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 {
         try {
             Integer count = jdbcTemplate.queryForObject(
             Integer count = jdbcTemplate.queryForObject(
@@ -4525,20 +4386,6 @@ try {
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("创建 tianpan_member_annual_energy 表可能已存在: {}", e.getMessage());
             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) {
     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')
 		// 迁移225: articles 添加 growth_category 列(成长分类筛选,Article 实体已含字段,生产库缺列导致 Unknown column 'growth_category')
 		ensureColumn("articles", "growth_category", "VARCHAR(50) DEFAULT NULL COMMENT '成长分类'");
 		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 列
 		// 迁移226: 创建 family_platform_balance 表 + family_platform_balance_log 表 + products.points_multiplier 列
 		try {
 		try {
 			jdbcTemplate.execute(
 			jdbcTemplate.execute(
@@ -9113,6 +8860,225 @@ private void runMigration100() {
 		ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
 		ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
 		ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'");
 		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);
 			Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
 			keyToId.put(m[1], id);
 			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) {
     public RestTemplate restTemplate(RestTemplateBuilder builder) {
         return builder
         return builder
                 .setConnectTimeout(Duration.ofSeconds(30))
                 .setConnectTimeout(Duration.ofSeconds(30))
-                .setReadTimeout(Duration.ofSeconds(30))
+                .setReadTimeout(Duration.ofSeconds(180))
                 .build();
                 .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,
     public Map<String, Object> sendMessage(String query, String userId,
                                            String conversationId,
                                            String conversationId,
                                            Map<String, Object> inputs) {
                                            Map<String, Object> inputs) {
         Long uid = Long.valueOf(userId);
         Long uid = Long.valueOf(userId);
 
 
-        // 1. 尝试 Python LangGraph
         Map<String, Object> pythonResult = aiGateway.chat(query, uid, conversationId, inputs);
         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,
     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;
     private String langgraphBaseUrl;
 
 
     // 匹配方案文本中的任务行(与前端 parseTasksFromPlan 保持一致)
     // 匹配方案文本中的任务行(与前端 parseTasksFromPlan 保持一致)
+    // 注意:字符类 [.、)\]] 后不能再有多余的 ],否则数字编号行(如 "1. 腹部B超")永远无法匹配
     private static final Pattern TASK_PATTERN = Pattern.compile(
     private static final Pattern TASK_PATTERN = Pattern.compile(
-        "^(?:\\d+[\\.、\\)\\]]]|[-*•]|\\[.+\\]|【任务\\d+】)[\\s ]*([^\\n]+)"
+        "^(?:\\d+[\\.、\\)\\]]|[-*•]|\\[.+\\]|【任务\\d+】)[\\s ]*([^\\n]+)"
     );
     );
 
 
     @Override
     @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_bundle (bundle_product_id),
     INDEX idx_child (child_product_id)
     INDEX idx_child (child_product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成明细';
 ) 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" 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>
           </view>
           </view>
 
 
           <!-- 疾病风险 -->
           <!-- 疾病风险 -->
           <view class="card" v-if="payload.diseaseRisks && payload.diseaseRisks.length">
           <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>
-
-          <!-- 饮食推荐 -->
-          <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>
           </view>
 
 
           <!-- 益生菌种 -->
           <!-- 益生菌种 -->
           <view class="card" v-if="payload.probioticSpecies && payload.probioticSpecies.length">
           <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>
             </view>
           </view>
           </view>
 
 
           <!-- Taxonomy 分类(纲/目/科/属/种) -->
           <!-- Taxonomy 分类(纲/目/科/属/种) -->
           <view class="card" v-if="hasTaxonomyData()">
           <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>
-            <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>
-            <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>
-            <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>
-            <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>
           </view>
           </view>
 
 
           <!-- 病原菌 -->
           <!-- 病原菌 -->
           <view class="card" v-if="hasPathogenData()">
           <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>
-            </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>
               </view>
             </view>
             </view>
@@ -423,6 +439,7 @@ export default {
       creatingMember: false,
       creatingMember: false,
       createdMemberId: null,
       createdMemberId: null,
       groupExpandList: [],
       groupExpandList: [],
+      collapseState: { gutFlora: false, diseaseRisk: false, probiotic: false, taxonomy: false, pathogen: false },
       showHelpPopup: false,
       showHelpPopup: false,
       helpData: {}
       helpData: {}
     }
     }
@@ -572,11 +589,12 @@ export default {
             self.payload = Object.assign({}, self.defaultPayload(), res.data.payload)
             self.payload = Object.assign({}, self.defaultPayload(), res.data.payload)
           }
           }
           // 后端无 blocks 时从 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
             self.backendBlocksExist = true
           } else if (self.hasPayloadData) {
           } else if (self.hasPayloadData) {
-            self.blocks = self.assembleBlocksFromPayload()
+            self.blocks = self.stripFoodBlocks(self.assembleBlocksFromPayload())
             self.backendBlocksExist = false
             self.backendBlocksExist = false
           } else {
           } else {
             self.blocks = []
             self.blocks = []
@@ -727,6 +745,9 @@ export default {
     toggleGroup(idx) {
     toggleGroup(idx) {
       this.groupExpandList[idx] = !this.groupExpandList[idx]
       this.groupExpandList[idx] = !this.groupExpandList[idx]
     },
     },
+    toggleCollapse(key) {
+      this.collapseState[key] = !this.collapseState[key]
+    },
     toggleAllGroups(open) {
     toggleAllGroups(open) {
       var groups = this.indicatorGroups
       var groups = this.indicatorGroups
       var list = []
       var list = []
@@ -894,6 +915,18 @@ export default {
         uni.showToast({ title: '保存异常', icon: 'none' })
         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 时使用) */
     /** 从 payload 数据自动组装展示块(后端无 blocks 时使用) */
     assembleBlocksFromPayload: function() {
     assembleBlocksFromPayload: function() {
       var blocks = []
       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 || []
       var ps = p.probioticSpecies || []
       if (ps.length > 0) {
       if (ps.length > 0) {
         var psItems = []
         var psItems = []
@@ -1047,7 +1065,7 @@ export default {
         })
         })
       }
       }
 
 
-      // 7. 分类学列表(纲/目/科/属/种)
+      // 6. 分类学列表(纲/目/科/属/种)
       var taxLabels = [
       var taxLabels = [
         { key: 'taxonomyClass', label: '菌纲 (Class)' },
         { key: 'taxonomyClass', label: '菌纲 (Class)' },
         { key: 'taxonomyOrder', label: '菌目 (Order)' },
         { key: 'taxonomyOrder', label: '菌目 (Order)' },
@@ -1168,6 +1186,28 @@ export default {
 }
 }
 .expand-all-btn:active { opacity: 0.7; }
 .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 {
 .indicator-group {
   border: 2rpx solid #E8EDF2;
   border: 2rpx solid #E8EDF2;
@@ -1616,31 +1656,6 @@ export default {
 
 
 .food-item {
 .food-item {
   display: flex;
   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 {
 .action-row {
   display: flex;
   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
 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 app/ app/
 COPY src/ src/
 COPY src/ src/
@@ -26,7 +26,7 @@ EXPOSE 9000
 CMD ["python3", "-m", "gunicorn", "app.main:app", \
 CMD ["python3", "-m", "gunicorn", "app.main:app", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--bind", "0.0.0.0:9000", \
      "--bind", "0.0.0.0:9000", \
-     "--workers", "2", \
+     "--workers", "1", \
      "--timeout", "300", \
      "--timeout", "300", \
      "--keep-alive", "10", \
      "--keep-alive", "10", \
      "--access-logfile", "-", \
      "--access-logfile", "-", \

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

@@ -24,6 +24,34 @@ _CN_TO_EN_OVERVIEW: dict[str, str] = {
     '核心菌属': 'coreGenusScore',
     '核心菌属': '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:
 class ReportParseAgent:
     """报告解析 Agent"""
     """报告解析 Agent"""
@@ -41,6 +69,109 @@ class ReportParseAgent:
             if _cn in overview and _en not in overview:
             if _cn in overview and _en not in overview:
                 overview[_en] = overview[_cn]
                 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:
     async def parse(self, file_path: str) -> dict:
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         # 1. 算法解析
         # 1. 算法解析
@@ -50,6 +181,8 @@ class ReportParseAgent:
 
 
         # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
         # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
         self._normalize_overview_keys(result)
         self._normalize_overview_keys(result)
+        # 1.6 归一化指标/菌群/食物为 Java 消费端 snake_case 键
+        self._normalize_for_java(result)
 
 
         # 2. 如果解析不完整,LLM 兜底
         # 2. 如果解析不完整,LLM 兜底
         if result.get('_parse_incomplete') or not result.get('disease_risks'):
         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():
                     for k, v in llm_result['overview'].items():
                         if k not in result.get('overview', {}):
                         if k not in result.get('overview', {}):
                             result.setdefault('overview', {})[k] = v
                             result.setdefault('overview', {})[k] = v
+                # 重新归一化:LLM 补齐的分区也要并入 indicators
+                self._normalize_for_java(result)
 
 
         # 清理内部标记
         # 清理内部标记
         result.pop('_parse_incomplete', None)
         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)
     memory_mgr = MemoryManager(embeddings)
 
 
     llm_with_tools = llm.bind_tools([
     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 typing import TypedDict, Optional, Literal
 from langgraph.graph import StateGraph, START, END
 from langgraph.graph import StateGraph, START, END
 from langgraph.checkpoint.memory import MemorySaver
 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 langchain_core.messages import SystemMessage, HumanMessage
 from app.tools.java_client import JavaClient
 from app.tools.java_client import JavaClient
 from app.tools.product_tools import (
 from app.tools.product_tools import (
@@ -61,11 +61,8 @@ def create_health_butler_graph():
         temperature=settings.llm_temperature,
         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)
     memory_mgr = MemoryManager(embeddings)
 
 
     java = JavaClient()
     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 typing import TypedDict
 from langgraph.graph import StateGraph, START, END
 from langgraph.graph import StateGraph, START, END
 from langgraph.checkpoint.memory import MemorySaver
 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 langchain_core.messages import SystemMessage, HumanMessage
 from app.rag.retriever import RagRetriever
 from app.rag.retriever import RagRetriever
 from app.memory.store import MemoryManager
 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")
     retriever = RagRetriever(collection_name="health_knowledge")
     memory_mgr = MemoryManager(embeddings)
     memory_mgr = MemoryManager(embeddings)
 
 

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

@@ -3,7 +3,7 @@ import time
 import asyncio
 import asyncio
 import logging
 import logging
 from fastapi import FastAPI, Request
 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.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs
 from app import monitoring
 from app import monitoring
 from src.app import router as questionnaire_router
 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],
             texts=[full_text],
             metadatas=[metadata],
             metadatas=[metadata],
         )
         )
-        self.memory_vectorstore.persist()
         logger.info("已保存对话到向量记忆:conv=%s, user=%s", conversation_id, user_id)
         logger.info("已保存对话到向量记忆:conv=%s, user=%s", conversation_id, user_id)
 
 
     async def recall(self, user_id: int, query: str, k: int = 3) -> list[str]:
     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': '龙',
     '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
     # 氏 radical U+2F52 → U+6C0F
     # 氏 radical U+2F52 → U+6C0F
     '\u2f52': '氏',
     '\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 = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
 KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
@@ -86,16 +103,23 @@ def norm(s):
     return ''.join(RADICAL_MAP.get(c, c) for c in 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 'inline'
-    return 'triplet' if has_triplet else 'inline'
+            return 'triplet'
+    return 'triplet'
 
 
 
 
 def extract_text(file_path):
 def extract_text(file_path):
@@ -156,8 +180,393 @@ def parse_triplet_until(lines, stop_markers):
     return results
     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:
 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)
     lines = extract_text(file_path)
     full_text = '\n'.join(lines)
     full_text = '\n'.join(lines)
 
 
@@ -165,38 +574,121 @@ def parse_report_pdf(file_path: str) -> dict:
     if detect_beijing_format(full_text):
     if detect_beijing_format(full_text):
         return _parse_beijing_report(file_path)
         return _parse_beijing_report(file_path)
 
 
-    fmt = detect_format(lines)
+    fmt = detect_format(pages_text)
     result = {'format': fmt, 'overview': parse_overview(lines)}
     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)
     bacteria_tables = _extract_bacteria_tables(file_path)
@@ -218,7 +710,7 @@ def parse_report_pdf(file_path: str) -> dict:
     result['短链脂肪酸合成能力'] = {}
     result['短链脂肪酸合成能力'] = {}
     result['肠道菌群精准分布'] = []
     result['肠道菌群精准分布'] = []
     result['营养物质及营养素代谢评估'] = {}
     result['营养物质及营养素代谢评估'] = {}
-    result['抗生素风险评估'] = {}
+    result.setdefault('抗生素风险评估', {})
     result['毒性物质清除能力评估'] = {}
     result['毒性物质清除能力评估'] = {}
     result['趣味肠菌评估'] = {}
     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
 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
 _embeddings = None
 
 
 
 
 def get_embeddings():
 def get_embeddings():
     global _embeddings
     global _embeddings
     if _embeddings is None:
     if _embeddings is None:
-        _embeddings = OpenAIEmbeddings(
+        _embeddings = SiliconFlowEmbeddings(
             model=settings.embedding_model,
             model=settings.embedding_model,
             api_key=settings.effective_embedding_api_key,
             api_key=settings.effective_embedding_api_key,
             base_url=settings.effective_embedding_base_url,
             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()
             data = resp.json()
             if data.get("code") == 200:
             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:
         except Exception as e:
             logger.warning("增量获取文章失败: %s", e)
             logger.warning("增量获取文章失败: %s", e)
         return []
         return []

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

@@ -1,6 +1,7 @@
 from langchain_chroma import Chroma
 from langchain_chroma import Chroma
-from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_openai import ChatOpenAI
 from app.config import settings
 from app.config import settings
+from app.rag.embeddings import get_embeddings
 from app.tools.java_client import JavaClient
 from app.tools.java_client import JavaClient
 from typing import Optional
 from typing import Optional
 import logging
 import logging
@@ -12,11 +13,7 @@ class RagRetriever:
     """简单的向量检索器 - 基于 ChromaDB"""
     """简单的向量检索器 - 基于 ChromaDB"""
 
 
     def __init__(self, collection_name: str = "cfc_knowledge"):
     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(
         self.vectorstore = Chroma(
             collection_name=collection_name,
             collection_name=collection_name,
             embedding_function=embeddings,
             embedding_function=embeddings,
@@ -28,24 +25,36 @@ class RagRetriever:
         """从 Java 侧拉取知识库,更新到向量库"""
         """从 Java 侧拉取知识库,更新到向量库"""
         try:
         try:
             articles = await self.java_client.get_published_articles()
             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:
         except Exception as e:
             logger.warning("知识库初始化失败:%s", 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):
     for i in range(0, len(all_chunks), BATCH_SIZE):
         batch = all_chunks[i:i + BATCH_SIZE]
         batch = all_chunks[i:i + BATCH_SIZE]
         await vectorstore.aadd_documents(batch)
         await vectorstore.aadd_documents(batch)
-        vectorstore.persist()
         logger.debug("知识库同步进度: %d/%d", i + len(batch), len(all_chunks))
         logger.debug("知识库同步进度: %d/%d", i + len(batch), len(all_chunks))
 
 
     import datetime
     import datetime

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

@@ -39,12 +39,24 @@ class JavaClient:
             JavaClient._instance = None
             JavaClient._instance = None
 
 
     async def get_published_articles(self) -> list[dict]:
     async def get_published_articles(self) -> list[dict]:
+        """拉取已发布知识库文章(分页 + records 解析,避免单次响应过大 / 返回 dict 结构)"""
         client = await self._get_client()
         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]:
     async def search_products(self, keyword: str, limit: int = 5) -> list[dict]:
         client = await self._get_client()
         client = await self._get_client()

+ 2 - 2
cfc-langgraph/pyproject.toml

@@ -13,8 +13,8 @@ dependencies = [
     "langchain-community>=0.3,<1.0",
     "langchain-community>=0.3,<1.0",
     "langchain-openai>=0.3,<1.0",
     "langchain-openai>=0.3,<1.0",
     "langgraph>=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",
     "python-multipart>=0.0.20",
     "prometheus-client>=0.21",
     "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
 langgraph>=0.2,<1.0
 langchain-openai>=0.2,<1.0
 langchain-openai>=0.2,<1.0
 langchain-core>=0.3,<1.0
 langchain-core>=0.3,<1.0
+chromadb==0.5.23
+langchain-chroma==0.1.4
 pydantic>=2.0,<3.0
 pydantic>=2.0,<3.0
 python-dotenv>=1.0,<2.0
 python-dotenv>=1.0,<2.0
 httpx>=0.27,<1.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 { updateAdminInfo } from '@/api/admin'
 import { getMyInviteCode } from '@/api/guide'
 import { getMyInviteCode } from '@/api/guide'
 import { getUnreadMessageCount } from '@/api/teacherMessage'
 import { getUnreadMessageCount } from '@/api/teacherMessage'
-import { getUserMenus } from '@/api/menu'
 import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
 import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
 import axios from 'axios'
 import axios from 'axios'
 
 
@@ -333,8 +332,6 @@ export default {
               ]},
               ]},
           ]},
           ]},
       ],
       ],
-      // 后端动态菜单(菜单管理功能;null=未加载/未启用,此时回退到硬编码 menuItems)
-      dynamicMenuItems: null,
       profileDialogVisible: false,
       profileDialogVisible: false,
       passwordDialogVisible: false,
       passwordDialogVisible: false,
       submitting: false,
       submitting: false,
@@ -384,13 +381,11 @@ export default {
     activeMenu() {
     activeMenu() {
       return this.$route.path
       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() {
     currentRole() {
       return localStorage.getItem('role') || 'admin'
       return localStorage.getItem('role') || 'admin'
     },
     },
@@ -425,18 +420,16 @@ export default {
   mounted() {
   mounted() {
     // 清除可能的 beforeunload 处理,防止页面刷新弹出"离开网站?"提示
     // 清除可能的 beforeunload 处理,防止页面刷新弹出"离开网站?"提示
     window.onbeforeunload = null
     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() {
   beforeDestroy() {
     window.removeEventListener('resize', this.handleResize)
     window.removeEventListener('resize', this.handleResize)
   },
   },
@@ -444,16 +437,6 @@ export default {
     hasPerm(required) {
     hasPerm(required) {
       return hasPermission(this.effectivePerms, 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) {
     getMenuIndex(item) {
       return item.path === '/dashboard' && this.currentRole === 'teacher'
       return item.path === '/dashboard' && this.currentRole === 'teacher'
         ? '/teacher-dashboard'
         ? '/teacher-dashboard'