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

Merge remote-tracking branch 'origin/cfclub' into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
asus 1 сар өмнө
parent
commit
173598a827
36 өөрчлөгдсөн 1869 нэмэгдсэн , 115 устгасан
  1. 4 0
      cfc-backend/AGENTS.md
  2. 41 46
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindFortuneController.java
  4. 3 3
      cfc-backend/src/test/java/com/etotem/cfc/controller/ArticleControllerTest.java
  5. 9 9
      cfc-backend/src/test/java/com/etotem/cfc/controller/EnergyControllerTest.java
  6. 13 13
      cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerTest.java
  7. 11 11
      cfc-backend/src/test/java/com/etotem/cfc/controller/PointsControllerTest.java
  8. 8 8
      cfc-backend/src/test/java/com/etotem/cfc/controller/TaskControllerTest.java
  9. 5 5
      cfc-backend/src/test/java/com/etotem/cfc/integration/EnergySystemFlowTest.java
  10. 6 6
      cfc-backend/src/test/java/com/etotem/cfc/integration/GrowthRecordSyncFlowTest.java
  11. 4 4
      cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceMinigameTest.java
  12. 1 0
      cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java
  13. 14 2
      cfc-frontend/pages/mind/index.vue
  14. 2 1
      cfc-langgraph/Dockerfile
  15. 45 0
      cfc-langgraph/README.md
  16. 96 0
      cfc-langgraph/app/agents/report_parse_agent.py
  17. 2 0
      cfc-langgraph/app/main.py
  18. 0 0
      cfc-langgraph/app/parsers/__init__.py
  19. 166 0
      cfc-langgraph/app/parsers/pdf_parser.py
  20. 2 1
      cfc-langgraph/docker-compose.yml
  21. 1 0
      cfc-langgraph/requirements.txt
  22. 49 0
      cfc-langgraph/src/app.py
  23. 71 0
      cfc-langgraph/src/graphs/questionnaire.py
  24. 15 0
      cfc-langgraph/src/llm/client.py
  25. 55 0
      cfc-langgraph/src/prompts/questionnaire.py
  26. 47 0
      cfc-langgraph/src/schemas/questionnaire.py
  27. 88 0
      cfc-langgraph/tests/test_graph.py
  28. 1 1
      cfc-web/.last_build_commit
  29. 2 2
      cfc-web/package-lock.json
  30. 1 1
      cfc-web/package.json
  31. 93 0
      cfc-web/public/CHANGELOG-v1.0.md
  32. 94 1
      cfc-web/public/CHANGELOG.md
  33. 3 1
      docs/superpowers/PROJECT-OVERVIEW.md
  34. 550 0
      docs/superpowers/plans/2026-08-04-report-parsing-migration.md
  35. 120 0
      docs/superpowers/specs/2026-08-04-report-parsing-migration-design.md
  36. 244 0
      docs/superpowers/specs/2026-08-05-inventory-management-design.md

+ 4 - 0
cfc-backend/AGENTS.md

@@ -199,6 +199,10 @@ mvn spring-boot:run      # 启动开发服务器 (localhost:9082)
 mvn test                 # 运行测试 (目前只有1个测试类)
 ```
 
+# Python LangGraph 服务(部署到 ai.etotem.com.cn)
+cd cfc-langgraph && pip install -r requirements.txt && uvicorn src.app:app --port 9000
+```
+
 ## 工作流程
 
 1. 修改代码后执行 `mvn clean compile` 验证编译通过

+ 41 - 46
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -60,7 +60,7 @@ public class DatabaseInitializer implements CommandLineRunner {
     private void initializeDatabase() {
         try {
             // 尝试连接数据库,如果不存在则创建
-            jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS xzyj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+            jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS zxyj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
             log.info("数据库检查完成");
         } catch (Exception e) {
             log.warn("创建数据库失败: {}", e.getMessage());
@@ -2152,9 +2152,6 @@ log.info("已添加template_id列到tasks表");
             }
         }
 
-        // 关系类型表添加 default_role 列(角色模板)
-        ensureColumn("relationship_types", "default_role", "VARCHAR(20) DEFAULT NULL COMMENT '默认角色模板: parent/child'");
-
         // 家庭成员表添加 role_override 列(替换旧 capability_role)
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         try {
@@ -2860,34 +2857,6 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建member_discount_configs表失败: {}", e.getMessage());
         }
 
-        try {
-            Integer hasCap = jdbcTemplate.queryForObject(
-                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'capability_role'",
-                Integer.class);
-            Integer hasDefault = jdbcTemplate.queryForObject(
-                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'default_role'",
-                Integer.class);
-            if (hasCap != null && hasCap == 0) {
-                if (hasDefault != null && hasDefault > 0) {
-                    jdbcTemplate.execute("ALTER TABLE relationship_types CHANGE COLUMN default_role capability_role VARCHAR(20) NOT NULL COMMENT '能力角色: parent/child'");
-                    log.info("已将relationship_types.default_role重命名为capability_role");
-                } else {
-                    jdbcTemplate.execute("ALTER TABLE relationship_types ADD COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child' AFTER type_name");
-                    log.info("已添加capability_role列到relationship_types表");
-                }
-            }
-        } catch (Exception e) {
-            log.warn("relationship_types capability_role 迁移失败: {}", e.getMessage());
-        }
-
-        // 确保capability_role有默认值(生产库该列NOT NULL无默认值,导致INSERT时Field doesn't have a default value错误)
-        try {
-            jdbcTemplate.execute("ALTER TABLE relationship_types MODIFY COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child'");
-            log.info("已修改relationship_types.capability_role添加默认值'parent'");
-        } catch (Exception e) {
-            log.warn("修改relationship_types.capability_role默认值失败: {}", e.getMessage());
-        }
-
         // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ====================
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
@@ -5426,12 +5395,7 @@ try {
             log.info("已创建social_circle_member表");
         } catch (Exception e) { /* 表已存在忽略 */ }
 
-        // 迁移89: relationship_types表添加分类和血亲字段
-        ensureColumn("relationship_types", "category", "VARCHAR(50) DEFAULT 'social' COMMENT '关系分类: blood血亲/marriage姻亲/social社会关系'");
-        ensureColumn("relationship_types", "is_blood_relation", "TINYINT DEFAULT 0 COMMENT '是否血亲关系: 1是0否'");
-        ensureColumn("relationship_types", "is_immediate_family", "TINYINT DEFAULT 0 COMMENT '是否直系亲属: 1是0否'");
-
-    // 迁移96: 创建活动订单评价表
+        // 迁移96: 创建活动订单评价表
     try {
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
             "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -7006,13 +6970,7 @@ private void runMigration100() {
 	} catch (Exception e) {
 		// 列已不存在,忽略
 	}
-	try {
-		jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types");
-		log.info("已删除relationship_types表");
-	} catch (Exception e) {
-		// 表已不存在,忽略
 	}
-}
 
 	// 迁移101: product_skus表添加min_quantity列(商品规格最小购买数限制)
 	private void runMigration101() {
@@ -7727,7 +7685,44 @@ private void runMigration100() {
 		// 迁移166: payment_orders 添加 group_no/period_no 列(会员拆单支付,配置驱动拆单)
 		ensureColumn("payment_orders", "group_no", "VARCHAR(64) COMMENT '拆单组号(同一拆单组共享)'");
 		ensureColumn("payment_orders", "period_no", "INT DEFAULT 1 COMMENT '期数序号(1开始)'");
-		// 迁移167: 创建 family_join_requests 表(家庭加入请求待审批)
+		// 迁移167: 回填 families.creator_id(早期版本从未写入,导致会员开通/虚拟支付兜底失败)
+		// 优先 is_family_admin=true 的用户
+		try {
+			jdbcTemplate.execute("UPDATE families f JOIN users u ON f.id = u.family_id AND u.is_family_admin = 1 " +
+					"SET f.creator_id = u.id WHERE f.creator_id IS NULL");
+			log.info("已回填families.creator_id(优先家庭管理员)");
+		} catch (Exception e) {
+			log.warn("回填families.creator_id(管理员)失败: {}", e.getMessage());
+		}
+		// 剩余家庭:任一 openid 非空用户(子查询确定性取一条)
+		try {
+			jdbcTemplate.execute("UPDATE families f SET f.creator_id = (" +
+					"SELECT u.id FROM users u WHERE u.family_id = f.id " +
+					"AND u.openid IS NOT NULL AND u.openid <> '' " +
+					"ORDER BY (u.is_family_admin = 1) DESC, u.id ASC LIMIT 1" +
+					") WHERE f.creator_id IS NULL");
+			log.info("已回填families.creator_id(任一成员)");
+		} catch (Exception e) {
+			log.warn("回填families.creator_id(任一成员)失败: {}", e.getMessage());
+		}
+
+		// 迁移168: 修复文章表 null status 为 'draft'(旧版新建文章未设默认值导致无法编辑)
+		try {
+			jdbcTemplate.execute("UPDATE articles SET status = 'draft' WHERE status IS NULL");
+			log.info("已修复articles表中status为null的记录为draft");
+		} catch (Exception e) {
+			log.warn("修复articles表null status失败: {}", e.getMessage());
+		}
+
+		// 迁移169: 清理已废弃的 relationship_types 表(schema.sql 已移除,迁移代码中 ALTER 语句已清理)
+		try {
+			jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types");
+			log.info("已清理废弃的relationship_types表");
+		} catch (Exception e) {
+			log.warn("清理relationship_types表失败: {}", e.getMessage());
+		}
+
+		// 迁移170: 创建 family_join_requests 表(家庭加入请求待审批)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_join_requests (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -7749,7 +7744,7 @@ private void runMigration100() {
 			// 表已存在,忽略
 		}
 
-		// 迁移168: 创建 knowledge_base_section 表(统一知识库按模块存储JSON,DB为查询源,classpath种子兜底)
+		// 迁移171: 创建 knowledge_base_section 表(统一知识库按模块存储JSON,DB为查询源,classpath种子兜底)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS knowledge_base_section (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindFortuneController.java

@@ -34,6 +34,7 @@ public class MindFortuneController {
      */
     @PostMapping("/fortune")
     public Result<Map<String, Object>> getTodayFortune(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         Map<String, Object> result = batchBuildFortuneResponse(fortune);
@@ -45,6 +46,7 @@ public class MindFortuneController {
      */
     @PostMapping("/fortune/refresh")
     public Result<FamilyFortune> refreshFortune(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         fortune.setLuckyDirection(fortuneService.calculateLuckyDirection(familyId, LocalDate.now()));
@@ -61,6 +63,7 @@ public class MindFortuneController {
     public Result<Map<String, Object>> exportFortuneReport(
             @RequestAttribute("userId") Long userId,
             @RequestBody ExportReportRequest request) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         try {
             // 文件大小校验

+ 3 - 3
cfc-backend/src/test/java/com/etotem/cfc/controller/ArticleControllerTest.java

@@ -200,7 +200,7 @@ public class ArticleControllerTest {
 
         doNothing().when(articleService).recordRead(eq(1L), isNull(), isNull(), eq(30));
 
-        Result<Map<String, Object>> result = controller.recordRead(body, null);
+        Result<Map<String, Object>> result = controller.recordRead(body, null, null);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
     }
@@ -214,7 +214,7 @@ public class ArticleControllerTest {
 
         doNothing().when(articleService).recordRead(eq(1L), isNull(), eq(10L), eq(60));
 
-        Result<Map<String, Object>> result = controller.recordRead(body, 1L);
+        Result<Map<String, Object>> result = controller.recordRead(body, null, 10L);
         assertEquals(200, result.getCode());
     }
 
@@ -225,7 +225,7 @@ public class ArticleControllerTest {
 
         doNothing().when(articleService).recordRead(eq(1L), isNull(), isNull(), eq(0));
 
-        Result<Map<String, Object>> result = controller.recordRead(body, null);
+        Result<Map<String, Object>> result = controller.recordRead(body, null, null);
         assertEquals(200, result.getCode());
     }
 }

+ 9 - 9
cfc-backend/src/test/java/com/etotem/cfc/controller/EnergyControllerTest.java

@@ -139,7 +139,7 @@ public class EnergyControllerTest {
 
         when(energyService.getOverview(10L)).thenReturn(mockOverview);
 
-        Result<Map<String, Object>> result = controller.getEnergyOverview(params);
+        Result<Map<String, Object>> result = controller.getEnergyOverview(params, 10L);
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -169,7 +169,7 @@ public class EnergyControllerTest {
 
         when(energyService.getOverview(20L)).thenReturn(mockOverview);
 
-        Result<Map<String, Object>> result = controller.getEnergyOverview(params);
+        Result<Map<String, Object>> result = controller.getEnergyOverview(params, 20L);
 
         assertEquals(200, result.getCode());
         assertEquals(100, result.getData().get("bodyScore"));
@@ -183,7 +183,7 @@ public class EnergyControllerTest {
     public void getEnergyOverview_NullChildId() {
         Map<String, Object> params = new HashMap<>();
 
-        Result<Map<String, Object>> result = controller.getEnergyOverview(params);
+        Result<Map<String, Object>> result = controller.getEnergyOverview(params, null);
 
         assertEquals(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());
@@ -200,7 +200,7 @@ public class EnergyControllerTest {
 
         when(energyService.getOverview(30L)).thenReturn(mockOverview);
 
-        Result<Map<String, Object>> result = controller.getEnergyOverview(params);
+        Result<Map<String, Object>> result = controller.getEnergyOverview(params, 30L);
 
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().get("totalEnergy"));
@@ -225,7 +225,7 @@ public class EnergyControllerTest {
 
         when(energyService.getLogs(10L, null, 1, 10)).thenReturn(page);
 
-        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params);
+        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params, 10L);
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -249,7 +249,7 @@ public class EnergyControllerTest {
 
         when(energyService.getLogs(10L, "body", 1, 10)).thenReturn(page);
 
-        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params);
+        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params, 10L);
 
         assertEquals(200, result.getCode());
         assertEquals(1, result.getData().getTotal());
@@ -267,7 +267,7 @@ public class EnergyControllerTest {
 
         when(energyService.getLogs(10L, null, 1, 10)).thenReturn(emptyPage);
 
-        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params);
+        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params, 10L);
 
         assertEquals(200, result.getCode());
     }
@@ -276,7 +276,7 @@ public class EnergyControllerTest {
     public void getEnergyLogs_NullChildId() {
         Map<String, Object> params = new HashMap<>();
 
-        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params);
+        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params, null);
 
         assertEquals(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());
@@ -295,7 +295,7 @@ public class EnergyControllerTest {
 
         when(energyService.getLogs(99L, null, 1, 10)).thenReturn(emptyPage);
 
-        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params);
+        Result<Page<EnergyLog>> result = controller.getEnergyLogs(params, 99L);
 
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().getTotal());

+ 13 - 13
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerTest.java

@@ -90,7 +90,7 @@ public class HealthReportControllerTest {
                 return r;
             });
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData().getId());
         assertEquals("physical_exam", result.getData().getReportType());
@@ -110,7 +110,7 @@ public class HealthReportControllerTest {
                 return r;
             });
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertEquals("gut_flora", result.getData().getReportType());
         assertEquals("25岁", result.getData().getGutAge());
@@ -132,7 +132,7 @@ public class HealthReportControllerTest {
                 return r;
             });
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData().getId());
     }
@@ -142,7 +142,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("reportType", "physical_exam");
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, null);
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("childId"));
     }
@@ -152,7 +152,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5L);
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("reportType"));
     }
@@ -168,7 +168,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
 
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(5L, result.getData().getUserId().longValue());
     }
@@ -180,7 +180,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 999);
 
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, 999L);
         assertEquals(200, result.getCode());
         assertNull(result.getData());
     }
@@ -188,7 +188,7 @@ public class HealthReportControllerTest {
     @Test
     public void getLatestReport_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, null);
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("childId"));
     }
@@ -207,7 +207,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
 
-        Result<List<HealthReport>> result = controller.listReports(params);
+        Result<List<HealthReport>> result = controller.listReports(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
     }
@@ -219,7 +219,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
 
-        Result<List<HealthReport>> result = controller.listReports(params);
+        Result<List<HealthReport>> result = controller.listReports(params, 5L);
         assertEquals(200, result.getCode());
         assertTrue(result.getData().isEmpty());
     }
@@ -227,7 +227,7 @@ public class HealthReportControllerTest {
     @Test
     public void listReports_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
-        Result<List<HealthReport>> result = controller.listReports(params);
+        Result<List<HealthReport>> result = controller.listReports(params, null);
         assertEquals(500, result.getCode());
     }
 
@@ -273,7 +273,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
 
-        Result<List<HealthIndicator>> result = controller.getBodyDetail(params);
+        Result<List<HealthIndicator>> result = controller.getBodyDetail(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
     }
@@ -281,7 +281,7 @@ public class HealthReportControllerTest {
     @Test
     public void getBodyDetail_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
-        Result<List<HealthIndicator>> result = controller.getBodyDetail(params);
+        Result<List<HealthIndicator>> result = controller.getBodyDetail(params, null);
         assertEquals(500, result.getCode());
     }
 

+ 11 - 11
cfc-backend/src/test/java/com/etotem/cfc/controller/PointsControllerTest.java

@@ -52,7 +52,7 @@ public class PointsControllerTest {
 
         when(pointsService.getBalance(10L)).thenReturn(mockBalance);
 
-        Result<Map<String, Object>> result = controller.getBalance(params);
+        Result<Map<String, Object>> result = controller.getBalance(params, 10L);
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -66,7 +66,7 @@ public class PointsControllerTest {
 
         when(pointsService.getBalance(null)).thenReturn(null);
 
-        Result<Map<String, Object>> result = controller.getBalance(params);
+        Result<Map<String, Object>> result = controller.getBalance(params, null);
 
         assertEquals(200, result.getCode());
         assertNull(result.getData());
@@ -82,7 +82,7 @@ public class PointsControllerTest {
 
         when(pointsService.getBalance(20L)).thenReturn(mockBalance);
 
-        Result<Map<String, Object>> result = controller.getBalance(params);
+        Result<Map<String, Object>> result = controller.getBalance(params, 20L);
 
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().get("balance"));
@@ -107,7 +107,7 @@ public class PointsControllerTest {
 
         when(pointsService.getPointsLogs(10L, 1, 10)).thenReturn(page);
 
-        Result<Page<PointsLog>> result = controller.getPointsLogs(params);
+        Result<Page<PointsLog>> result = controller.getPointsLogs(params, 10L);
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -126,7 +126,7 @@ public class PointsControllerTest {
 
         when(pointsService.getPointsLogs(10L, 1, 10)).thenReturn(emptyPage);
 
-        Result<Page<PointsLog>> result = controller.getPointsLogs(params);
+        Result<Page<PointsLog>> result = controller.getPointsLogs(params, 10L);
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -146,7 +146,7 @@ public class PointsControllerTest {
 
         when(pointsService.getPointsLogs(99L, 1, 10)).thenReturn(emptyPage);
 
-        Result<Page<PointsLog>> result = controller.getPointsLogs(params);
+        Result<Page<PointsLog>> result = controller.getPointsLogs(params, 99L);
 
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
@@ -170,7 +170,7 @@ public class PointsControllerTest {
 
         when(pointsService.getPointsLogsByCategory(10L, "earn", 1, 10)).thenReturn(page);
 
-        Result<Page<PointsLog>> result = controller.getPointsLogsByCategory(params);
+        Result<Page<PointsLog>> result = controller.getPointsLogsByCategory(params, 10L);
 
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().getRecords().size());
@@ -188,7 +188,7 @@ public class PointsControllerTest {
 
         when(pointsService.getPointsLogsByCategory(10L, null, 1, 10)).thenReturn(page);
 
-        Result<Page<PointsLog>> result = controller.getPointsLogsByCategory(params);
+        Result<Page<PointsLog>> result = controller.getPointsLogsByCategory(params, 10L);
 
         assertEquals(200, result.getCode());
     }
@@ -202,7 +202,7 @@ public class PointsControllerTest {
 
         when(pointsService.getSystemPointsBalance(10L)).thenReturn(500);
 
-        Result<Map<String, Object>> result = controller.getSystemBalance(params);
+        Result<Map<String, Object>> result = controller.getSystemBalance(params, 10L);
 
         assertEquals(200, result.getCode());
         assertEquals(500, result.getData().get("systemPoints"));
@@ -215,7 +215,7 @@ public class PointsControllerTest {
 
         when(pointsService.getSystemPointsBalance(20L)).thenReturn(0);
 
-        Result<Map<String, Object>> result = controller.getSystemBalance(params);
+        Result<Map<String, Object>> result = controller.getSystemBalance(params, 20L);
 
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().get("systemPoints"));
@@ -226,7 +226,7 @@ public class PointsControllerTest {
         Map<String, Object> params = new HashMap<>();
         // childId not provided
 
-        Result<Map<String, Object>> result = controller.getSystemBalance(params);
+        Result<Map<String, Object>> result = controller.getSystemBalance(params, null);
 
         assertEquals(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());

+ 8 - 8
cfc-backend/src/test/java/com/etotem/cfc/controller/TaskControllerTest.java

@@ -89,7 +89,7 @@ public class TaskControllerTest {
         dto.setTitle("每日阅读");
         dto.setDescription("阅读30分钟");
         dto.setPoints(5);
-        dto.setChildIds(Collections.singletonList(10L));
+        dto.setMemberIds(Collections.singletonList(10L));
 
         when(taskService.createTask(1L, dto)).thenReturn(100L);
 
@@ -111,7 +111,7 @@ public class TaskControllerTest {
         );
         when(taskService.getTodayTasks(10L)).thenReturn(tasks);
 
-        Result<List<Task>> result = controller.getTodayTasks(params);
+        Result<List<Task>> result = controller.getTodayTasks(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
     }
@@ -121,7 +121,7 @@ public class TaskControllerTest {
     @Test
     public void completeTask_Success() {
         CompleteTaskDTO dto = new CompleteTaskDTO();
-        dto.setChildId(10L);
+        dto.setMemberId(10L);
         dto.setPhotoUrl("http://example.com/photo.jpg");
 
         Map<String, Object> completionResult = new HashMap<>();
@@ -131,7 +131,7 @@ public class TaskControllerTest {
 
         when(taskService.completeTask(1L, 10L, "http://example.com/photo.jpg")).thenReturn(completionResult);
 
-        Result<Map<String, Object>> result = controller.completeTask(1L, dto);
+        Result<Map<String, Object>> result = controller.completeTask(1L, dto, 10L);
         assertEquals(200, result.getCode());
         assertEquals(5, result.getData().get("pointsEarned"));
     }
@@ -184,7 +184,7 @@ public class TaskControllerTest {
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
 
-        Result<Page<Task>> result = controller.getTaskHistory(params);
+        Result<Page<Task>> result = controller.getTaskHistory(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().getTotal());
     }
@@ -200,7 +200,7 @@ public class TaskControllerTest {
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
 
-        Result<Page<Task>> result = controller.getTaskHistory(params);
+        Result<Page<Task>> result = controller.getTaskHistory(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
     }
@@ -278,7 +278,7 @@ public class TaskControllerTest {
 
         when(taskService.completeMinigameTask(1L, 10L, 25, 85)).thenReturn(gameResult);
 
-        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body);
+        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body, 10L);
         assertEquals(200, result.getCode());
         assertEquals(7, result.getData().get("pointsEarned"));
     }
@@ -294,7 +294,7 @@ public class TaskControllerTest {
 
         when(taskService.completeMinigameTask(eq(1L), eq(10L), isNull(), isNull())).thenReturn(gameResult);
 
-        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body);
+        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body, 10L);
         assertEquals(200, result.getCode());
     }
 

+ 5 - 5
cfc-backend/src/test/java/com/etotem/cfc/integration/EnergySystemFlowTest.java

@@ -143,7 +143,7 @@ public class EnergySystemFlowTest {
         grantParams.put("dimensionCode", dimensionCode);
 
         // === When:触发能量发放 ===
-        Result<EnergyLog> result = energyController.grantEnergy(grantParams);
+        Result<EnergyLog> result = energyController.grantEnergy(grantParams, CHILD_ID);
 
         // === Then:Milestone-2 - 能量发放成功 ===
         assertEquals(200, result.getCode());
@@ -175,7 +175,7 @@ public class EnergySystemFlowTest {
         deductParams.put("amount", energyDeduct);
 
         // === When:使用能量兑换心愿 ===
-        Result<Boolean> result = energyController.deductForWish(deductParams);
+        Result<Boolean> result = energyController.deductForWish(deductParams, CHILD_ID);
 
         // === Then:Milestone-3 - 能量扣减成功 ===
         assertEquals(200, result.getCode());
@@ -204,7 +204,7 @@ public class EnergySystemFlowTest {
                 .thenThrow(new RuntimeException("能量不足,当前可用:300,需要:5000"));
 
         // === When:尝试兑换 ===
-        Result<Boolean> result = energyController.deductForWish(deductRequest);
+        Result<Boolean> result = energyController.deductForWish(deductRequest, CHILD_ID);
 
         // === Then:返回错误 ===
         assertEquals(500, result.getCode());
@@ -239,7 +239,7 @@ public class EnergySystemFlowTest {
                 .thenReturn(mockPage);
 
         // === When:查询能量流水 ===
-        Result<Page<EnergyLog>> result = energyController.getEnergyLogs(request);
+        Result<Page<EnergyLog>> result = energyController.getEnergyLogs(request, CHILD_ID);
 
         // === Then:返回分页数据 ===
         assertEquals(200, result.getCode());
@@ -275,7 +275,7 @@ public class EnergySystemFlowTest {
         when(energyService.getOverview(CHILD_ID)).thenReturn(mockOverview);
 
         // === When:请求能量概览 ===
-        Result<Map<String, Object>> result = energyController.getEnergyOverview(request);
+        Result<Map<String, Object>> result = energyController.getEnergyOverview(request, CHILD_ID);
 
         // === Then:Milestone-5 - 返回概览数据包含健康指数 ===
         assertEquals(200, result.getCode());

+ 6 - 6
cfc-backend/src/test/java/com/etotem/cfc/integration/GrowthRecordSyncFlowTest.java

@@ -161,7 +161,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.createRecord(any(GrowthRecord.class))).thenReturn(savedRecord);
 
         // === When:提交创建档案和购买测评 ===
-        Result<Map<String, Object>> result = growthRecordController.createRecordWithOrder(request, PARENT_USER_ID);
+        Result<Map<String, Object>> result = growthRecordController.createRecordWithOrder(request, PARENT_USER_ID, CHILD_ID);
 
         // === Then:档案创建成功,订单也创建 ===
         assertEquals(200, result.getCode());
@@ -252,7 +252,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
         // === When:家长请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
@@ -274,7 +274,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
         // === When:家长通过新接口请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
@@ -296,7 +296,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
@@ -318,7 +318,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
@@ -340,7 +340,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());

+ 4 - 4
cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceMinigameTest.java

@@ -38,7 +38,7 @@ public class TaskServiceMinigameTest {
         dto.setDescription("测试描述");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
 
         Long taskId = taskService.createTask(1L, dto);
@@ -61,7 +61,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("积分测试任务");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
 
         Long taskId = taskService.createTask(1L, dto);
@@ -77,7 +77,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("无效小游戏任务");
         dto.setCategory("小游戏类");
         dto.setMinigameCode("invalid_code_xyz");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
 
         Long taskId = taskService.createTask(1L, dto);
@@ -89,7 +89,7 @@ public class TaskServiceMinigameTest {
         CreateTaskDTO dto = new CreateTaskDTO();
         dto.setTitle("小游戏类但无code");
         dto.setCategory("小游戏类");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
 
         Long taskId = taskService.createTask(1L, dto);

+ 1 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -23,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import com.alibaba.fastjson.JSON;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;

+ 14 - 2
cfc-frontend/pages/mind/index.vue

@@ -916,7 +916,13 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
     },
     loadHealthAlerts: function() {
       var self = this
-      getHealthAlerts({}).then(function(res) {
+      var familyId = uni.getStorageSync('currentFamilyId')
+      if (!familyId) {
+        var userInfo = uni.getStorageSync('userInfo')
+        if (userInfo && userInfo.familyId) familyId = userInfo.familyId
+      }
+      if (!familyId) return
+      getHealthAlerts(familyId).then(function(res) {
         if (res.code === 200) {
           self.healthAlerts = Array.isArray(res.data) ? res.data : []
         } else {
@@ -928,7 +934,13 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
     },
     loadMilestones: function() {
       var self = this
-      getMilestones({}, 30).then(function(res) {
+      var familyId = uni.getStorageSync('currentFamilyId')
+      if (!familyId) {
+        var userInfo = uni.getStorageSync('userInfo')
+        if (userInfo && userInfo.familyId) familyId = userInfo.familyId
+      }
+      if (!familyId) return
+      getMilestones(familyId, 30).then(function(res) {
         if (res.code === 200) {
           self.milestones = Array.isArray(res.data) ? res.data : []
         } else {

+ 2 - 1
cfc-langgraph/Dockerfile

@@ -11,6 +11,7 @@ RUN tar xzf /tmp/pkgs.tar.gz -C /usr/local/lib/python3.12/site-packages/ && \
     rm /tmp/pkgs.tar.gz
 
 COPY app/ app/
+COPY src/ src/
 COPY data/ data/
 
 RUN useradd -m -u 1000 appuser && \
@@ -26,7 +27,7 @@ CMD ["python3", "-m", "gunicorn", "app.main:app", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--bind", "0.0.0.0:9000", \
      "--workers", "2", \
-     "--timeout", "60", \
+     "--timeout", "300", \
      "--keep-alive", "10", \
      "--access-logfile", "-", \
      "--error-logfile", "-"]

+ 45 - 0
cfc-langgraph/README.md

@@ -0,0 +1,45 @@
+# CFC LangGraph 问卷生成服务
+
+关系评估问卷 AI 出题 graph,部署到 `ai.etotem.com.cn`。
+
+## 启动
+
+```bash
+cd cfc-langgraph
+pip install -r requirements.txt
+cp .env.example .env  # 填写 OPENAI_API_KEY / OPENAI_BASE_URL / LLM_MODEL
+uvicorn src.app:app --host 0.0.0.0 --port 9000
+```
+
+## 端点
+
+| Method | Path | 说明 |
+|--------|------|------|
+| GET | `/health` | 健康检查 |
+| POST | `/api/v1/questionnaire/generate` | 生成关系问卷 JSON |
+
+## 测试
+
+```bash
+cd cfc-langgraph
+pytest tests/ -v
+```
+
+## 契约
+
+Request:
+```json
+{
+  "member_name": "小明",
+  "relationship_type": "child",
+  "family_context": {}
+}
+```
+
+Response:
+```json
+{
+  "questionnaire_json": "<stringified JSON>",
+  "version": "20260804115900"
+}
+```

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

@@ -0,0 +1,96 @@
+"""
+报告解析 Agent:算法解析 + LLM 兜底
+"""
+import json
+import logging
+from typing import Optional
+
+from app.config import settings
+from app.parsers.pdf_parser import parse_report_pdf_with_fallback
+
+logger = logging.getLogger(__name__)
+
+
+class ReportParseAgent:
+    """报告解析 Agent"""
+
+    def __init__(self):
+        self.llm_api_key = getattr(settings, 'llm_api_key', '')
+
+    async def parse(self, file_path: str) -> dict:
+        """解析 PDF 报告,算法解析 + LLM 兜底"""
+        # 1. 算法解析
+        result = parse_report_pdf_with_fallback(file_path)
+        logger.info("算法解析完成: format=%s, overview_keys=%d",
+                     result.get('format'), len(result.get('overview', {})))
+
+        # 2. 如果解析不完整,LLM 兜底
+        if result.get('_parse_incomplete') or not result.get('disease_risks'):
+            logger.info("算法解析不完整,尝试 LLM 兜底")
+            llm_result = await self._parse_with_llm(file_path)
+            if llm_result:
+                # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
+                for key in ['disease_risks', 'nutrition', 'amino_acids',
+                            'vitamins', 'trace_elements', 'indicators']:
+                    if key in llm_result and not result.get(key):
+                        result[key] = llm_result[key]
+                if llm_result.get('overview'):
+                    for k, v in llm_result['overview'].items():
+                        if k not in result.get('overview', {}):
+                            result.setdefault('overview', {})[k] = v
+
+        # 清理内部标记
+        result.pop('_parse_incomplete', None)
+        return result
+
+    async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
+        """LLM 兜底解析"""
+        try:
+            from PyPDF2 import PdfReader
+            reader = PdfReader(file_path)
+            text = '\n'.join(page.extract_text() or '' for page in reader.pages)
+
+            # 构造 prompt
+            prompt = f"""你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
+
+文本内容:
+{text[:8000]}
+
+请按以下JSON Schema返回:
+{{
+    "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
+        "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
+        "gutAge": "", "gutType": "" }},
+    "disease_risks": [{{"name": "", "value": "", "status": ""}}],
+    "nutrition": [{{"name": "", "value": "", "status": ""}}],
+    "amino_acids": [{{"name": "", "value": "", "status": ""}}],
+    "vitamins": [{{"name": "", "value": "", "status": ""}}],
+    "trace_elements": [{{"name": "", "value": "", "status": ""}}]
+}}
+
+只返回JSON,不要其他文字。"""
+
+            if self.llm_api_key:
+                # 调 OpenAI 兼容 API
+                import httpx
+                async with httpx.AsyncClient(timeout=60) as client:
+                    resp = await client.post(
+                        f"{settings.llm_base_url}/chat/completions",
+                        json={
+                            "model": settings.llm_model or "gpt-4o",
+                            "messages": [{"role": "user", "content": prompt}],
+                            "temperature": 0.1,
+                        },
+                        headers={"Authorization": f"Bearer {self.llm_api_key}"},
+                    )
+                    resp.raise_for_status()
+                    data = resp.json()
+                    content = data['choices'][0]['message']['content']
+                    content = content.replace('```json', '').replace('```', '').strip()
+                    return json.loads(content)
+            else:
+                logger.warning("LLM 未配置,跳过 LLM 兜底")
+                return None
+        except Exception as e:
+            logger.warning("LLM 解析失败: %s", e)
+            return None

+ 2 - 0
cfc-langgraph/app/main.py

@@ -5,6 +5,7 @@ import logging
 from fastapi import FastAPI, Request
 from app.api import health, recommend, chat, analyze, tongue, adapter
 from app import monitoring
+from src.app import router as questionnaire_router
 
 logger = logging.getLogger(__name__)
 
@@ -17,6 +18,7 @@ app.include_router(analyze.router)
 app.include_router(tongue.router)
 app.include_router(adapter.router)
 app.include_router(monitoring.router)
+app.include_router(questionnaire_router)
 
 
 @app.middleware("http")

+ 0 - 0
cfc-langgraph/app/parsers/__init__.py


+ 166 - 0
cfc-langgraph/app/parsers/pdf_parser.py

@@ -0,0 +1,166 @@
+"""
+菌群报告 PDF 解析器
+从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
+"""
+import re
+from PyPDF2 import PdfReader
+
+# === 常量 ===
+RADICAL_MAP = {
+    '\u2f52': '\u6c0f', '\u2f51': '\u6bcd', '\u2f59': '\u6b6f',
+    '\u2f04': '\u4e59', '\u2f20': '\u4e00', '\u2f21': '\u4e28',
+    '\u2f22': '\u4e3f', '\u2f23': '\u4e39', '\u2f2b': '\u5c38',
+    '\u2f2d': '\u5c71', '\u2f44': '\u4e59', '\u2f53': '\u6c14',
+    '\u2f55': '\u6c34', '\u2f5c': '\u725b', '\u2f5f': '\u7389',
+    '\u2f7a': '\u7f8a', '\u2f81': '\u8089', '\u2f83': '\u81ea',
+    '\u2f8a': '\u8272', '\u2f8e': '\u8840', '\u2f95': '\u79be',
+    '\u2f96': '\u8c46', '\u2faf': '\u9762', '\u2fb9': '\u9999',
+    '\u2fca': '\u9ed1', '\u2ec9': '\u8d1d', '\u2edd': '\u98df',
+    '\u2ee2': '\u9a6c', '\u2ee9': '\u9ec4',
+}
+
+KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
+KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
+    '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
+    '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
+    '精氨酸', '色氨酸']
+KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
+    '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
+KNOWN_TRACE = ['铁', '锌']
+KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
+    '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
+    '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
+    '心脑血管疾病', 'II型糖尿病']
+KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
+    '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
+KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
+KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
+    '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
+    '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
+    '去甲肾上腺素', '乙酰胆碱', '皮质醇']
+KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
+    '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
+KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
+
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+
+def detect_format(lines):
+    """检测 triplet / inline 格式"""
+    text = '\n'.join(lines)
+    has_triplet = '指标范围' in text and '疾病风险评估' in text
+    for line in lines:
+        if len(line) > 15 and re.search(r'[\u4e00-\u9fff]+[\d.]+[\u4e00-\u9fff/]+', line):
+            for known in KNOWN_DISEASE_RISKS:
+                if known in line:
+                    return 'inline'
+    return 'triplet' if has_triplet else 'inline'
+
+
+def extract_text(file_path):
+    """读取 PDF 并提取文本"""
+    reader = PdfReader(file_path)
+    lines = []
+    for page in reader.pages:
+        text = norm(page.extract_text() or '')
+        for line in text.split('\n'):
+            ls = line.strip()
+            if ls:
+                lines.append(ls)
+    return lines
+
+
+def parse_overview(lines):
+    """提取报告概述"""
+    text = '\n'.join(lines)
+    r = {}
+    m = re.search(r'编号[::\s]*(\d+)', text)
+    if m: r['report_number'] = m.group(1)
+    m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
+    if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
+    m = re.search(r'年龄[::\s]*(\d+)', text)
+    if m: r['age'] = int(m.group(1))
+    m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
+    if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
+    for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
+               '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(rf'{kw}\s*(\d+)', text)
+        if m: r[kw] = int(m.group(1))
+    m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
+    if m: r['gut_age'] = m.group(1)
+    m = re.search(r'肠型[::\s]*(\S+)', text)
+    if m: r['gut_type'] = m.group(1)
+    return r
+
+
+def parse_triplet_until(lines, stop_markers):
+    """三元组解析:3行一组 名称/数值/状态"""
+    results = []
+    i = 0
+    while i < len(lines):
+        if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
+            break
+        if lines[i] in ('指标范围', '名称', '丰度', '评估'):
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 >= len(lines): break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'name': name, 'value': val, 'status': status})
+            i += 3
+        else:
+            i += 1
+    return results
+
+
+def parse_report_pdf(file_path: str) -> dict:
+    """主函数:解析 PDF 返回结构化数据"""
+    lines = extract_text(file_path)
+    fmt = detect_format(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
+
+    # 氨基酸评估
+    for i, line in enumerate(lines):
+        if '氨基酸评估' in line:
+            aminos = parse_triplet_until(lines[i+1:], ['维生素评估', '微量元素评估'])
+            result['amino_acids'] = aminos
+            break
+
+    # 维生素评估
+    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
+
+    return result
+
+
+def parse_report_pdf_with_fallback(file_path: str) -> dict:
+    """算法解析 + 简单校验,返回结构化数据"""
+    result = parse_report_pdf(file_path)
+    # 简单校验:如果关键字段缺失,标记为解析不完整
+    if not result.get('overview', {}).get('overallScore') and \
+       not result.get('overview', {}).get('健康总分'):
+        result['_parse_incomplete'] = True
+    return result

+ 2 - 1
cfc-langgraph/docker-compose.yml

@@ -30,6 +30,7 @@ services:
       - .env.production
     environment:
       - JAVA_BASE_URL=http://172.17.0.1:9082
+      - JAVA_CONTEXT_URL=http://172.17.0.1:9082/api/ai/context
       - CHROMA_DB_PATH=/data/chroma_db
       - JSON_LOGS=true
       - LANGCHAIN_TRACING_V2=${LANGCHAIN_TRACING_V2:-false}
@@ -39,7 +40,7 @@ services:
       - cfc-net
     restart: unless-stopped
     healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:9000/api/v1/health"]
+      test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:9000/health', timeout=5).status==200 else 1)"]
       interval: 30s
       timeout: 5s
       retries: 3

+ 1 - 0
cfc-langgraph/requirements.txt

@@ -7,3 +7,4 @@ pydantic>=2.0,<3.0
 python-dotenv>=1.0,<2.0
 httpx>=0.27,<1.0
 pytest>=8.0,<9.0
+PyPDF2>=3.0,<4.0

+ 49 - 0
cfc-langgraph/src/app.py

@@ -0,0 +1,49 @@
+import json
+from datetime import datetime
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from fastapi import APIRouter, FastAPI
+from fastapi.responses import JSONResponse
+
+from .schemas.questionnaire import GenerateRequest, GenerateResponse
+from .graphs.questionnaire import get_questionnaire_graph
+
+router = APIRouter(prefix="/api/v1", tags=["questionnaire"])
+
+
+@router.post("/questionnaire/generate")
+async def generate_questionnaire(req: GenerateRequest):
+    graph = get_questionnaire_graph()
+    try:
+        result = graph.invoke({
+            "request": req,
+            "raw_response": "",
+            "questionnaire": None,
+            "error": None,
+        })
+        if result.get("error"):
+            return JSONResponse(
+                status_code=500,
+                content={"error": result["error"]}
+            )
+        version = datetime.now().strftime("%Y%m%d%H%M%S")
+        return GenerateResponse(
+            questionnaire_json=json.dumps(result["questionnaire"], ensure_ascii=False),
+            version=version,
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"error": f"graph 执行失败: {str(e)}"}
+        )
+
+
+app = FastAPI(title="CFC LangGraph 问卷生成服务")
+app.include_router(router)
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}

+ 71 - 0
cfc-langgraph/src/graphs/questionnaire.py

@@ -0,0 +1,71 @@
+import json
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import HumanMessage
+
+from ..llm.client import get_llm
+from ..prompts.questionnaire import PARENT_TEMPLATE, CHILD_TEMPLATE
+from ..schemas.questionnaire import Questionnaire, GenerateRequest
+
+
+class GraphState(TypedDict):
+    request: GenerateRequest
+    _prompt: Optional[str]
+    raw_response: str
+    questionnaire: Optional[dict]
+    error: Optional[str]
+
+
+def build_prompt(state: GraphState) -> GraphState:
+    req = state["request"]
+    template = PARENT_TEMPLATE if req.relationship_type == "parent" else CHILD_TEMPLATE
+    prompt = template.format(member_name=req.member_name)
+    return {**state, "_prompt": prompt}
+
+
+def call_llm(state: GraphState) -> GraphState:
+    llm = get_llm()
+    messages = [HumanMessage(content=state["_prompt"])]
+    response = llm.invoke(messages)
+    return {**state, "raw_response": response.content}
+
+
+def validate(state: GraphState) -> GraphState:
+    raw = state["raw_response"]
+    # 提取 JSON 块
+    text = raw.strip()
+    if "```json" in text:
+        text = text.split("```json")[1].split("```")[0].strip()
+    elif "```" in text:
+        text = text.split("```")[1].split("```")[0].strip()
+
+    try:
+        data = json.loads(text)
+        q = Questionnaire(**data)
+        if not q.validate_structure():
+            return {**state, "error": "问卷结构校验失败:至少需要1题且每题有options或scale"}
+        return {**state, "questionnaire": data, "error": None}
+    except Exception as e:
+        return {**state, "error": f"JSON 解析失败: {str(e)}"}
+
+
+def build_questionnaire_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("build_prompt", build_prompt)
+    graph.add_node("call_llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "build_prompt")
+    graph.add_edge("build_prompt", "call_llm")
+    graph.add_edge("call_llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_questionnaire_graph = None
+
+
+def get_questionnaire_graph():
+    global _questionnaire_graph
+    if _questionnaire_graph is None:
+        _questionnaire_graph = build_questionnaire_graph()
+    return _questionnaire_graph

+ 15 - 0
cfc-langgraph/src/llm/client.py

@@ -0,0 +1,15 @@
+import os
+from langchain_openai import ChatOpenAI
+
+
+def get_llm() -> ChatOpenAI:
+    # 优先 LLM_* 系列(与 .env.production 一致),兼容 OPENAI_* 系列
+    # timeout=180/max_retries=1:问卷推理可达 30-60s,重试叠加会拖垮 gunicorn worker
+    return ChatOpenAI(
+        model=os.getenv("LLM_MODEL", os.getenv("OPENAI_MODEL", "gpt-4o-mini")),
+        api_key=os.getenv("LLM_API_KEY", os.getenv("OPENAI_API_KEY", "")),
+        base_url=os.getenv("LLM_BASE_URL", os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")),
+        temperature=0.7,
+        timeout=180,
+        max_retries=1,
+    )

+ 55 - 0
cfc-langgraph/src/prompts/questionnaire.py

@@ -0,0 +1,55 @@
+PARENT_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""
+
+CHILD_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""

+ 47 - 0
cfc-langgraph/src/schemas/questionnaire.py

@@ -0,0 +1,47 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional, Union
+
+
+class Option(BaseModel):
+    id: str
+    score: float
+
+
+class Scale(BaseModel):
+    min: float = 0
+    max: float = 4
+
+
+class Question(BaseModel):
+    id: str
+    dimension: str = Field(pattern="^(trust|intimacy|communication)$")
+    direction: str = Field(default="positive", pattern="^(positive|negative)$")
+    weight: float = 1.0
+    text: str
+    options: Optional[List[Option]] = None
+    scale: Optional[Scale] = None
+
+
+class Questionnaire(BaseModel):
+    version: int = 1
+    questions: List[Question]
+
+    def validate_structure(self) -> bool:
+        """至少 1 题,每题有 options 或 scale"""
+        if not self.questions:
+            return False
+        for q in self.questions:
+            if q.options is None and q.scale is None:
+                return False
+        return True
+
+
+class GenerateRequest(BaseModel):
+    member_name: str
+    relationship_type: str = Field(pattern="^(parent|child)$")
+    family_context: Optional[dict] = None
+
+
+class GenerateResponse(BaseModel):
+    questionnaire_json: str
+    version: str

+ 88 - 0
cfc-langgraph/tests/test_graph.py

@@ -0,0 +1,88 @@
+import json
+from unittest.mock import patch, MagicMock
+from src.schemas.questionnaire import GenerateRequest, Questionnaire, Question, Option
+
+
+def _mock_llm_response():
+    """返回合规 JSON 的 mock LLM 响应"""
+    mock_msg = MagicMock()
+    mock_msg.content = json.dumps({
+        "version": 1,
+        "questions": [
+            {
+                "id": "q1",
+                "dimension": "trust",
+                "direction": "positive",
+                "weight": 1.0,
+                "text": "您对小明的信任程度如何?",
+                "options": [
+                    {"id": "a", "score": 0},
+                    {"id": "b", "score": 1},
+                    {"id": "c", "score": 2},
+                ],
+            }
+        ]
+    }, ensure_ascii=False)
+    return mock_msg
+
+
+def _mock_llm_invalid_response():
+    mock_msg = MagicMock()
+    mock_msg.content = "这不是JSON"
+    return mock_msg
+
+
+class TestQuestionnaireSchema:
+    def test_valid_questionnaire(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test",
+                     options=[Option(id="a", score=0)])
+        ])
+        assert q.validate_structure() is True
+
+    def test_empty_questions_fails_validation(self):
+        q = Questionnaire(version=1, questions=[])
+        assert q.validate_structure() is False
+
+    def test_question_without_options_or_scale_fails(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test")
+        ])
+        assert q.validate_structure() is False
+
+
+class TestGraph:
+    def test_graph_valid_output(self):
+        with patch("src.graphs.questionnaire.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is None
+            assert result["questionnaire"] is not None
+            assert len(result["questionnaire"]["questions"]) == 1
+
+    def test_graph_invalid_llm_response_rejected(self):
+        with patch("src.graphs.questionnaire.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_invalid_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is not None
+            assert result["questionnaire"] is None

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-1ca5ea7e9dff4d9693b0dc15a03d1c82fb007736
+60fcf0d57db4aef283c567ae496f4c610aa026ae

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.799",
+  "version": "1.0.807",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.799",
+      "version": "1.0.807",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.799",
+  "version": "1.0.808",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 93 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,99 @@
 
 ---
 
+## v1.0.808 (2026-08-05)
+
+### Bug 修复
+- add raise_for_status to expose real LLM HTTP errors
+- 心维度页面 milestones/health-alerts 传入实际 familyId 替代 {} 对象
+
+### 其他
+- 导致后端解析 Long.valueOf('{}') 时抛出 NumberFormatException。
+- 改为从 currentFamilyId/userInfo.familyId 获取实际值,与同文件
+- loadCompatibilityData 等方法的现有模式保持一致。
+- 
+
+
+## v1.0.807 (2026-08-05)
+
+### Bug 修复
+- MindFortuneController 添加 userId 为空检查(过期JWT被设为匿名用户时抛出'用户不存在')
+
+
+## v1.0.806 (2026-08-05)
+
+### 重构
+- 清理已废弃的 relationship_types 表(迁移169)——移除活路径 ALTER 代码、DROP TABLE 从死代码移到活路径
+
+### 新功能
+- 创建 ReportParseAgent(算法解析 + LLM 兜底)
+
+### Bug 修复
+- detect_format 兜底分支改为返回 inline(修复同义反复)
+- compose 显式覆盖 JAVA_CONTEXT_URL 指向宿主机 9082,修复容器内 chat load_context 回调 Java 失败
+
+
+## v1.0.805 (2026-08-05)
+
+### Bug 修复
+- DatabaseInitializer.initializeDatabase() xzyj→zxyj 数据库名拼写错误
+
+### 新功能
+- 提取 PDF 解析核心模块到 LangGraph parsers
+
+### 其他
+- - 从 extract_full_report_v5.py 提取核心逻辑(overview/疾病风险/营养/氨基酸/维生素)
+- - requirements.txt: 补充 PyPDF2 依赖
+- 
+
+
+## v1.0.804 (2026-08-05)
+
+### Bug 修复
+- 问卷推理超时防护 — gunicorn timeout 60→300s + ChatOpenAI timeout=180/max_retries=1
+
+### 其他
+- 超过 gunicorn 60s 超时导致 worker 被杀(WORKER TIMEOUT + SIGABRT)。
+- - gunicorn --timeout 60→300:给推理留足时间
+- - ChatOpenAI timeout=180/max_retries=1:单次推理上限 180s,失败仅重试 1 次
+- 
+
+
+## v1.0.803 (2026-08-05)
+
+### 文档
+- 库存管理设计文档(入库单+流水+盘点+预警+套餐拆库存)
+
+
+## v1.0.802 (2026-08-05)
+
+### Bug 修复
+- 问卷生成服务接入旧入口 — src 路由重构为 router + LLM env 兼容 + healthcheck 修复
+
+### 其他
+- - app/main.py: 合并 questionnaire_router,旧路由/中间件/知识库同步不变
+- - src/llm/client.py: LLM_* 环境变量优先(与 .env.production 一致),兼容 OPENAI_*
+- - Dockerfile: 新增 COPY src/ src/(生产镜像此前无问卷代码)
+- - docker-compose.yml: langgraph-svc healthcheck 改用 python urllib 探 /health(镜像无 curl + 原路径 404)
+- 
+
+
+## v1.0.801 (2026-08-05)
+
+### 文档
+- AGENTS.md 追加 LangGraph 启动命令 + PROJECT-OVERVIEW 关系域标记已实施
+
+### Bug 修复
+- GraphState 补 _prompt 字段 + 测试 mock 目标修正为模块内引用
+
+
+## v1.0.800 (2026-08-05)
+
+### 文档
+- 报告解析迁移实现计划
+- 报告解析迁移设计 — Java→LangGraph(Python算法+LLM兜底)
+
+
 ## v1.0.799 (2026-08-05)
 
 ### Bug 修复

+ 94 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.799
+> 当前版本: v1.0.808
 
 ## 历史版本
 
@@ -8,6 +8,99 @@
 
 ---
 
+## v1.0.808 (2026-08-05)
+
+### Bug 修复
+- add raise_for_status to expose real LLM HTTP errors
+- 心维度页面 milestones/health-alerts 传入实际 familyId 替代 {} 对象
+
+### 其他
+- 导致后端解析 Long.valueOf('{}') 时抛出 NumberFormatException。
+- 改为从 currentFamilyId/userInfo.familyId 获取实际值,与同文件
+- loadCompatibilityData 等方法的现有模式保持一致。
+- 
+
+
+## v1.0.807 (2026-08-05)
+
+### Bug 修复
+- MindFortuneController 添加 userId 为空检查(过期JWT被设为匿名用户时抛出'用户不存在')
+
+
+## v1.0.806 (2026-08-05)
+
+### 重构
+- 清理已废弃的 relationship_types 表(迁移169)——移除活路径 ALTER 代码、DROP TABLE 从死代码移到活路径
+
+### 新功能
+- 创建 ReportParseAgent(算法解析 + LLM 兜底)
+
+### Bug 修复
+- detect_format 兜底分支改为返回 inline(修复同义反复)
+- compose 显式覆盖 JAVA_CONTEXT_URL 指向宿主机 9082,修复容器内 chat load_context 回调 Java 失败
+
+
+## v1.0.805 (2026-08-05)
+
+### Bug 修复
+- DatabaseInitializer.initializeDatabase() xzyj→zxyj 数据库名拼写错误
+
+### 新功能
+- 提取 PDF 解析核心模块到 LangGraph parsers
+
+### 其他
+- - 从 extract_full_report_v5.py 提取核心逻辑(overview/疾病风险/营养/氨基酸/维生素)
+- - requirements.txt: 补充 PyPDF2 依赖
+- 
+
+
+## v1.0.804 (2026-08-05)
+
+### Bug 修复
+- 问卷推理超时防护 — gunicorn timeout 60→300s + ChatOpenAI timeout=180/max_retries=1
+
+### 其他
+- 超过 gunicorn 60s 超时导致 worker 被杀(WORKER TIMEOUT + SIGABRT)。
+- - gunicorn --timeout 60→300:给推理留足时间
+- - ChatOpenAI timeout=180/max_retries=1:单次推理上限 180s,失败仅重试 1 次
+- 
+
+
+## v1.0.803 (2026-08-05)
+
+### 文档
+- 库存管理设计文档(入库单+流水+盘点+预警+套餐拆库存)
+
+
+## v1.0.802 (2026-08-05)
+
+### Bug 修复
+- 问卷生成服务接入旧入口 — src 路由重构为 router + LLM env 兼容 + healthcheck 修复
+
+### 其他
+- - app/main.py: 合并 questionnaire_router,旧路由/中间件/知识库同步不变
+- - src/llm/client.py: LLM_* 环境变量优先(与 .env.production 一致),兼容 OPENAI_*
+- - Dockerfile: 新增 COPY src/ src/(生产镜像此前无问卷代码)
+- - docker-compose.yml: langgraph-svc healthcheck 改用 python urllib 探 /health(镜像无 curl + 原路径 404)
+- 
+
+
+## v1.0.801 (2026-08-05)
+
+### 文档
+- AGENTS.md 追加 LangGraph 启动命令 + PROJECT-OVERVIEW 关系域标记已实施
+
+### Bug 修复
+- GraphState 补 _prompt 字段 + 测试 mock 目标修正为模块内引用
+
+
+## v1.0.800 (2026-08-05)
+
+### 文档
+- 报告解析迁移实现计划
+- 报告解析迁移设计 — Java→LangGraph(Python算法+LLM兜底)
+
+
 ## v1.0.799 (2026-08-05)
 
 ### Bug 修复

+ 3 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -67,7 +67,7 @@
 | 家庭成员关系图谱(Canvas重设计) | ✅ 已实施,广泛集成(FamilyRelationGraph.vue 797行,10+页面使用) | `specs/2026-06-19-family-relation-graph-design.md` + `specs/2026-06-22-family-relation-graph-redesign.md` |
 | 家庭成员卡片(跨页展示+可见性) | ✅ 已上线 | `specs/2026-06-15-family-member-cards-design.md` + `plans/2026-06-15-family-member-cards.md` |
 | 成员详情页维度专属内容区 | ✅ 已实施(member-detail.vue 505行 + 4维度section组件 879行) | `specs/2026-07-14-member-detail-dimension-sections.md` |
-| 关系问卷与互动记录功能补全 | 🟡 设计稿(2026-08-04) | `specs/2026-08-04-relationship-feedback-fix-design.md` |
+| 关系问卷与互动记录功能补全 | ✅ 已实施(3表迁移154-156+评分算法+LangGraph接入+跨家庭校验+编辑bug修复) | `specs/2026-08-04-relationship-feedback-fix-design.md` + `plans/2026-08-04-relationship-feedback-fix.md` |
 
 ---
 
@@ -189,6 +189,7 @@
 | 供应商销售体系(体系账期+留利) | Phase 2 | 🟡 设计稿 | `specs/2026-07-05-supply-distribution-system-design.md` + `plans/2026-07-05-supply-distribution-system.md` |
 | 商品规格最小购买数限制 | Phase 4 | ✅ 已实施(ProductSku.minQuantity + ProductOrderService校验 + schema.sql同步) | `plans/2026-07-13-supplier-system-gap-fix.md` |
 | 供应商订单修复 | Phase 4 | 🟡 计划中 | `plans/2026-06-20-danshop-order-fix.md` |
+| 库存管理(入库单+流水+盘点+预警+套餐拆库存) | Phase 4 | 🟡 设计稿 | `specs/2026-08-05-inventory-management-design.md` |
 
 ---
 
@@ -334,6 +335,7 @@
 | `2026-08-04-registration-profile-backfill-design.md` | 🟡 设计稿 | 新用户注册引导 + 个人信息按需补充/回写 |
 | `2026-08-04-report-confirm-member-selector-design.md` | 🟡 设计稿 | 健康报告确认页被检测人选择面板整合(基本信息内选/添被检人) |
 | `2026-08-04-family-challenge-custom.md` | 🟢 已实施 | 家庭挑战自定义管理(创建/编辑/删除/模板) |
+| `2026-08-05-inventory-management-design.md` | 🟡 设计稿 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) |
 
 ### 实施计划(plans/)
 

+ 550 - 0
docs/superpowers/plans/2026-08-04-report-parsing-migration.md

@@ -0,0 +1,550 @@
+# 报告解析迁移到 LangGraph 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 将 PDF 报告解析从 Java(PdfParseService)迁移到 LangGraph(Python 算法 + LLM 兜底),Java 侧通过 HTTP 调用 LangGraph 接口。
+
+**架构:** Java 保存文件 → 调 LangGraph `/api/v1/report/parse` → LangGraph 先用 Python 算法解析(复用 `extract_full_report_v5.py` 核心逻辑),失败时 LLM 兜底 → 返回结构化 JSON。Java 保留本地解析作为 fallback。
+
+**技术栈:** Python 3.11, PyPDF2, FastAPI, LangChain, httpx, Java Spring Boot
+
+---
+
+## 文件结构
+
+### 创建的文件
+
+| 文件 | 职责 |
+|------|------|
+| `cfc-langgraph/app/agents/report_parse_agent.py` | 报告解析 Agent:算法解析 + LLM 兜底 |
+| `cfc-langgraph/app/parsers/pdf_parser.py` | Python PDF 解析核心逻辑(从 `extract_full_report_v5.py` 提取) |
+| `cfc-langgraph/app/parsers/__init__.py` | 包初始化 |
+| `cfc-langgraph/app/api/report_parse.py` | `POST /api/v1/report/parse` 接口 |
+| `cfc-langgraph/tests/test_report_parse.py` | 解析测试 |
+
+### 修改的文件
+
+| 文件 | 修改 |
+|------|------|
+| `cfc-langgraph/app/main.py` | 注册新的 report_parse router |
+| `cfc-backend/.../controller/HealthReportController.java` | `parsePreview()` 先调 LangGraph,失败回退 Java |
+
+---
+
+### 任务 1:提取 Python PDF 解析核心模块
+
+**文件:**
+- 创建:`cfc-langgraph/app/parsers/__init__.py`
+- 创建:`cfc-langgraph/app/parsers/pdf_parser.py`
+- 参考:`docs/参考资料/extract_full_report_v5.py`
+
+- [ ] **步骤 1:创建 parsers 包**
+
+```bash
+mkdir -p /app/cfc/cfc-langgraph/app/parsers
+touch /app/cfc/cfc-langgraph/app/parsers/__init__.py
+```
+
+- [ ] **步骤 2:编写 pdf_parser.py 核心函数**
+
+从 `extract_full_report_v5.py` 提取核心逻辑,封装为 `parse_report_pdf(file_path: str) -> dict`:
+
+```python
+"""
+菌群报告 PDF 解析器
+从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
+"""
+import re, json
+from PyPDF2 import PdfReader
+
+# === 常量 ===
+RADICAL_MAP = {
+    '\u2f52': '\u6c0f', '\u2f51': '\u6bcd', '\u2f59': '\u6b6f',
+    '\u2f04': '\u4e59', '\u2f20': '\u4e00', '\u2f21': '\u4e28',
+    '\u2f22': '\u4e3f', '\u2f23': '\u4e39', '\u2f2b': '\u5c38',
+    '\u2f2d': '\u5c71', '\u2f44': '\u4e59', '\u2f53': '\u6c14',
+    '\u2f55': '\u6c34', '\u2f5c': '\u725b', '\u2f5f': '\u7389',
+    '\u2f7a': '\u7f8a', '\u2f81': '\u8089', '\u2f83': '\u81ea',
+    '\u2f8a': '\u8272', '\u2f8e': '\u8840', '\u2f95': '\u79be',
+    '\u2f96': '\u8c46', '\u2faf': '\u9762', '\u2fb9': '\u9999',
+    '\u2fca': '\u9ed1', '\u2ec9': '\u8d1d', '\u2edd': '\u98df',
+    '\u2ee2': '\u9a6c', '\u2ee9': '\u9ec4',
+}
+
+KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
+KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
+    '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
+    '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
+    '精氨酸', '色氨酸']
+KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
+    '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
+KNOWN_TRACE = ['铁', '锌']
+KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
+    '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
+    '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
+    '心脑血管疾病', 'II型糖尿病']
+KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
+    '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
+KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
+KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
+    '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
+    '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
+    '去甲肾上腺素', '乙酰胆碱', '皮质醇']
+KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
+    '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
+KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
+
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+
+def detect_format(lines):
+    """检测 triplet / inline 格式"""
+    text = '\n'.join(lines)
+    has_triplet = '指标范围' in text and '疾病风险评估' in text
+    for line in lines:
+        if len(line) > 15 and re.search(r'[\u4e00-\u9fff]+[\d.]+[\u4e00-\u9fff/]+', line):
+            for known in KNOWN_DISEASE_RISKS:
+                if known in line:
+                    return 'inline'
+    return 'triplet' if has_triplet else 'triplet'
+
+
+def extract_text(file_path):
+    """读取 PDF 并提取文本"""
+    reader = PdfReader(file_path)
+    lines = []
+    for page in reader.pages:
+        text = norm(page.extract_text() or '')
+        for line in text.split('\n'):
+            ls = line.strip()
+            if ls:
+                lines.append(ls)
+    return lines
+
+
+def parse_overview(lines):
+    """提取报告概述"""
+    text = '\n'.join(lines)
+    r = {}
+    m = re.search(r'编号[::\s]*(\d+)', text)
+    if m: r['report_number'] = m.group(1)
+    m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
+    if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
+    m = re.search(r'年龄[::\s]*(\d+)', text)
+    if m: r['age'] = int(m.group(1))
+    m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
+    if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
+    for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
+               '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(rf'{kw}\s*(\d+)', text)
+        if m: r[kw] = int(m.group(1))
+    m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
+    if m: r['gut_age'] = m.group(1)
+    m = re.search(r'肠型[::\s]*(\S+)', text)
+    if m: r['gut_type'] = m.group(1)
+    return r
+
+
+def parse_triplet_until(lines, stop_markers):
+    """三元组解析:3行一组 名称/数值/状态"""
+    results = []
+    i = 0
+    while i < len(lines):
+        if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
+            break
+        if lines[i] in ('指标范围', '名称', '丰度', '评估'):
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 >= len(lines): break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'name': name, 'value': val, 'status': status})
+            i += 3
+        else:
+            i += 1
+    return results
+
+
+def parse_report_pdf(file_path: str) -> dict:
+    """主函数:解析 PDF 返回结构化数据"""
+    lines = extract_text(file_path)
+    fmt = detect_format(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
+
+    # 氨基酸评估
+    for i, line in enumerate(lines):
+        if '氨基酸评估' in line:
+            aminos, _ = parse_triplet_until(lines[i+1:], ['维生素评估', '微量元素评估'])
+            result['amino_acids'] = aminos
+            break
+
+    # 维生素评估
+    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
+
+    return result
+
+
+def parse_report_pdf_with_fallback(file_path: str) -> dict:
+    """算法解析 + 简单校验,返回结构化数据"""
+    result = parse_report_pdf(file_path)
+    # 简单校验:如果关键字段缺失,标记为解析不完整
+    if not result.get('overview', {}).get('overallScore') and \
+       not result.get('overview', {}).get('健康总分'):
+        result['_parse_incomplete'] = True
+    return result
+```
+
+- [ ] **步骤 3:运行测试验证导入**
+
+```bash
+cd /app/cfc/cfc-langgraph && python -c "from app.parsers.pdf_parser import parse_report_pdf; print('OK')"
+```
+
+预期输出:`OK`
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-langgraph/app/parsers/
+git commit -m "feat: 提取 PDF 解析核心模块到 LangGraph parsers"
+```
+
+---
+
+### 任务 2:创建报告解析 Agent
+
+**文件:**
+- 创建:`cfc-langgraph/app/agents/report_parse_agent.py`
+
+- [ ] **步骤 1:编写 ReportParseAgent**
+
+```python
+"""
+报告解析 Agent:算法解析 + LLM 兜底
+"""
+import json
+import logging
+from typing import Optional
+from app.config import settings
+from app.parsers.pdf_parser import parse_report_pdf_with_fallback
+
+logger = logging.getLogger(__name__)
+
+
+class ReportParseAgent:
+    """报告解析 Agent"""
+
+    def __init__(self):
+        self.llm_api_key = getattr(settings, 'openai_api_key', '') or \
+                           getattr(settings, 'dify_api_key', '')
+
+    async def parse(self, file_path: str) -> dict:
+        """解析 PDF 报告,算法解析 + LLM 兜底"""
+        # 1. 算法解析
+        result = parse_report_pdf_with_fallback(file_path)
+        logger.info("算法解析完成: format=%s, overview_keys=%d",
+                     result.get('format'), len(result.get('overview', {})))
+
+        # 2. 如果解析不完整,LLM 兜底
+        if result.get('_parse_incomplete') or not result.get('disease_risks'):
+            logger.info("算法解析不完整,尝试 LLM 兜底")
+            llm_result = await self._parse_with_llm(file_path)
+            if llm_result:
+                # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
+                for key in ['disease_risks', 'nutrition', 'amino_acids',
+                            'vitamins', 'trace_elements', 'indicators']:
+                    if key in llm_result and not result.get(key):
+                        result[key] = llm_result[key]
+                if llm_result.get('overview'):
+                    for k, v in llm_result['overview'].items():
+                        if k not in result.get('overview', {}):
+                            result.setdefault('overview', {})[k] = v
+
+        # 清理内部标记
+        result.pop('_parse_incomplete', None)
+        return result
+
+    async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
+        """LLM 兜底解析"""
+        try:
+            from PyPDF2 import PdfReader
+            reader = PdfReader(file_path)
+            text = '\n'.join(page.extract_text() or '' for page in reader.pages)
+
+            # 构造 prompt
+            prompt = f"""你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
+
+文本内容:
+{text[:8000]}
+
+请按以下JSON Schema返回:
+{{
+    "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
+        "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
+        "gutAge": "", "gutType": "" }},
+    "disease_risks": [{{"name": "", "value": "", "status": ""}}],
+    "nutrition": [{{"name": "", "value": "", "status": ""}}],
+    "amino_acids": [{{"name": "", "value": "", "status": ""}}],
+    "vitamins": [{{"name": "", "value": "", "status": ""}}],
+    "trace_elements": [{{"name": "", "value": "", "status": ""}}]
+}}
+
+只返回JSON,不要其他文字。"""
+
+            if self.llm_api_key and 'openai' in settings.model_type.lower():
+                # 调 OpenAI 兼容 API
+                import httpx
+                async with httpx.AsyncClient(timeout=60) as client:
+                    resp = await client.post(
+                        f"{settings.llm_base_url}/chat/completions",
+                        json={
+                            "model": settings.llm_model or "gpt-4o",
+                            "messages": [{"role": "user", "content": prompt}],
+                            "temperature": 0.1,
+                        },
+                        headers={"Authorization": f"Bearer {self.llm_api_key}"},
+                    )
+                    data = resp.json()
+                    content = data['choices'][0]['message']['content']
+                    content = content.replace('```json', '').replace('```', '').strip()
+                    return json.loads(content)
+            else:
+                logger.warning("LLM 未配置,跳过 LLM 兜底")
+                return None
+        except Exception as e:
+            logger.warning("LLM 解析失败: %s", e)
+            return None
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add cfc-langgraph/app/agents/report_parse_agent.py
+git commit -m "feat: 创建 ReportParseAgent(算法解析 + LLM 兜底)"
+```
+
+---
+
+### 任务 3:创建 LangGraph 解析 API
+
+**文件:**
+- 创建:`cfc-langgraph/app/api/report_parse.py`
+- 修改:`cfc-langgraph/app/main.py`
+
+- [ ] **步骤 1:创建 report_parse.py API**
+
+```python
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+from app.agents.report_parse_agent import ReportParseAgent
+import logging
+import os
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["report_parse"])
+_agent = None
+
+
+def get_agent():
+    global _agent
+    if _agent is None:
+        _agent = ReportParseAgent()
+    return _agent
+
+
+class ParseRequest(BaseModel):
+    file_path: str
+    family_id: Optional[int] = None
+    user_id: Optional[int] = None
+
+
+class ParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+@router.post("/report/parse", response_model=ParseResponse)
+async def parse_report(req: ParseRequest):
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    agent = get_agent()
+    try:
+        result = await agent.parse(req.file_path)
+        return ParseResponse(data=result)
+    except Exception as e:
+        logger.error("报告解析失败: %s", e, exc_info=True)
+        return ParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+```
+
+- [ ] **步骤 2:注册到 main.py**
+
+```python
+# 在 app/main.py 的 import 后添加
+from app.api import report_parse
+
+# 在 include_router 中添加
+app.include_router(report_parse.router)
+```
+
+- [ ] **步骤 3:验证启动**
+
+```bash
+cd /app/cfc/cfc-langgraph && python -c "
+from app.agents.report_parse_agent import ReportParseAgent
+agent = ReportParseAgent()
+print('ReportParseAgent OK')
+from app.api.report_parse import router
+print('ReportParse router OK')
+"
+```
+
+预期输出:`ReportParseAgent OK` `ReportParse router OK`
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-langgraph/app/api/report_parse.py cfc-langgraph/app/main.py
+git commit -m "feat: 创建 POST /api/v1/report/parse 接口"
+```
+
+---
+
+### 任务 4:修改 Java 控制器调用 LangGraph
+
+**文件:**
+- 修改:`cfc-backend/.../controller/HealthReportController.java`
+
+- [ ] **步骤 1:在 controller 中添加 LangGraph 客户端调用**
+
+在 `HealthReportController.java` 中修改 `parsePreview()` 方法,在文件保存后先调 LangGraph:
+
+```java
+// 在 parsePreview() 方法的 try 块中,替换 PDF 解析逻辑
+// 保存文件到磁盘
+String filePath = saveUploadFileToDisk(file, userId);
+
+// 调 LangGraph 解析
+try {
+    // 构造 LangGraph 请求
+    Map<String, Object> lgRequest = new HashMap<>();
+    lgRequest.put("file_path", filePath);
+    if (familyId != null) lgRequest.put("family_id", familyId);
+    lgRequest.put("user_id", userId);
+
+    // 发送 HTTP POST 到 LangGraph
+    String langGraphUrl = "http://localhost:8000/api/v1/report/parse";
+    // 使用 RestTemplate 或 WebClient 调用
+    // 这里假设有 RestTemplate bean
+    ResponseEntity<Map> lgResponse = restTemplate.postForEntity(
+            langGraphUrl, lgRequest, Map.class);
+    Map<String, Object> lgBody = lgResponse.getBody();
+    if (lgBody != null && Integer.valueOf(200).equals(lgBody.get("code"))) {
+        Map<String, Object> lgData = (Map<String, Object>) lgBody.get("data");
+        // 将 LangGraph 返回的 data 转换为 ParsedReportPayload.Payload
+        // 并构建返回结果
+        // ... 转换逻辑略 ...
+        return Result.success(buildResultFromLgData(lgData));
+    }
+} catch (Exception e) {
+    log.warn("LangGraph 解析失败,回退到本地 Java 解析: {}", e.getMessage());
+}
+
+// Fallback: 本地 Java 解析(现有逻辑不变)
+ParsedReportResult parsed = pdfParseService.parse(file.getInputStream());
+```
+
+- [ ] **步骤 2:添加 RestTemplate Bean(如不存在)**
+
+在 `cfc-backend/.../config/` 中检查是否有 `RestTemplate` Bean,如无则添加:
+
+```java
+@Bean
+public RestTemplate restTemplate() {
+    return new RestTemplate();
+}
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
+git commit -m "feat: parsePreview 先调 LangGraph 解析,失败回退 Java"
+```
+
+---
+
+### 任务 5:端到端测试
+
+- [ ] **步骤 1:上传测试 PDF 验证解析**
+
+```bash
+# 启动 LangGraph
+cd /app/cfc/cfc-langgraph && uvicorn app.main:app --port 8000 &
+
+# 启动 Java 后端
+cd /app/cfc/cfc-backend && mvn spring-boot:run &
+
+# 用 curl 测试上传
+curl -X POST http://localhost:9082/api/health/report/parse-preview \
+  -F "file=@/app/cfc/docs/参考资料/501999942-某人.pdf" \
+  -F "familyId=1"
+```
+
+预期:返回结构化 JSON,包含 overview、disease_risks、nutrition 等字段
+
+- [ ] **步骤 2:验证三份参考 PDF**
+
+```bash
+for pdf in /app/cfc/docs/参考资料/*.pdf; do
+  echo "=== Testing: $pdf ==="
+  curl -s -X POST http://localhost:9082/api/health/report/parse-preview \
+    -F "file=@$pdf" -F "familyId=1" | python3 -c "import sys,json; d=json.load(sys.stdin); print('code:', d.get('code')); print('overview:', list(d.get('data',{}).get('overview',{}).keys())[:5])"
+done
+```
+
+- [ ] **步骤 3:性能测试**
+
+```bash
+# 测试 10 次并发
+for i in $(seq 1 10); do
+  curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
+    -X POST http://localhost:9082/api/health/report/parse-preview \
+    -F "file=@/app/cfc/docs/参考资料/501999942-某人.pdf" -F "familyId=1" &
+done
+wait
+```
+
+预期:全部返回 200,单次 < 5s

+ 120 - 0
docs/superpowers/specs/2026-08-04-report-parsing-migration-design.md

@@ -0,0 +1,120 @@
+# 报告解析迁移设计:Java → LangGraph(Python 算法 + LLM 兜底)
+
+## 背景
+
+当前健康报告(菌群检测 PDF)解析在 Java 侧通过 `PdfParseService`(PDFBox + 规则解析)实现,但存在以下问题:
+
+- 格式兼容性差:inline 格式频繁抛出 `StringIndexOutOfBoundsException`
+- 采集不全:Python 版 `extract_full_report_v5.py` 覆盖 13 个分组,Java 版只有 8 个
+- 维护成本高:同一套解析逻辑在 Java 和 Python 各维护一份
+
+## 目标架构
+
+```
+用户上传 PDF
+    ↓
+Java HealthReportController
+    ├─ 保存文件到磁盘
+    ├─ 调 LangGraph /api/v1/report/parse
+    └─ 返回结构化数据给前端
+         ↓
+LangGraph report_parse_agent
+    ├─ Python 算法解析(复用 extract_full_report_v5.py)
+    │   ├─ 成功 → 返回结构化 JSON
+    │   └─ 失败 → LLM 兜底解析
+    └─ 返回结构化数据给 Java
+```
+
+## 组件职责
+
+### Java 侧(`HealthReportController`)
+
+- 接收文件上传(MultipartFile)
+- 保存文件到磁盘(`/tmp/uploads/` 或配置路径)
+- 调用 LangGraph 解析接口(POST `/api/v1/report/parse`)
+- 将 LangGraph 返回的结构化数据转换为 `ParsedReportPayload.Payload`
+- 后续流程不变(草稿 → 确认 → 入库)
+
+### LangGraph 侧(新增 `report_parse_agent`)
+
+- 接收文件路径
+- 调用 Python 解析脚本(从 `extract_full_report_v5.py` 提取核心逻辑)
+- 算法解析失败时,调用 LLM 进行结构化提取
+- 返回统一格式的 JSON
+
+## API 接口设计
+
+### LangGraph 解析接口
+
+```
+POST /api/v1/report/parse
+Content-Type: application/json
+
+Request:
+{
+    "file_path": "/tmp/uploads/xxx.pdf",
+    "family_id": 123,
+    "user_id": 456
+}
+
+Response:
+{
+    "code": 200,
+    "data": {
+        "summary": { ... },
+        "disease_risks": [ ... ],
+        "indicators": [ ... ],
+        "gut_flora": [ ... ],
+        "probiotic_species": [ ... ],
+        "food_suitability": [ ... ],
+        "extracted_name": "某人",
+        "extracted_gender": "male",
+        "extracted_age": 53
+    }
+}
+```
+
+## 数据流
+
+1. 用户上传 PDF → `Java HealthReportController.parsePreview()`
+2. Java 保存文件 → `POST /api/v1/report/parse?file_path=...`
+3. LangGraph 收到请求:
+   a. 调用 Python 算法解析模块(复用 `extract_full_report_v5.py` 核心逻辑)
+   b. 算法成功 → 返回结构化数据
+   c. 算法失败 → 调用 LLM(GPT-4o 等)提取结构化数据
+4. LangGraph 返回结构化 JSON 给 Java
+5. Java 将 JSON 转为 `ParsedReportPayload.Payload`,后续流程不变
+
+## 算法解析模块(Python)
+
+从 `extract_full_report_v5.py` 提取核心逻辑,封装为可调用的 Python 函数:
+
+```python
+def parse_report_pdf(file_path: str) -> dict:
+    """解析菌群报告 PDF,返回结构化数据"""
+    # 1. 读取 PDF 文本(PyPDF2)
+    # 2. 检测格式(triplet / inline)
+    # 3. 提取各分组数据
+    # 4. 返回统一格式字典
+```
+
+## LLM 兜底
+
+当算法解析失败时(返回空数据或关键字段缺失),调用 LLM:
+
+```python
+async def parse_with_llm(file_path: str) -> dict:
+    """LLM 兜底解析"""
+    # 1. 提取 PDF 文本
+    # 2. 构造 prompt,要求 LLM 按指定 JSON Schema 输出
+    # 3. 解析 LLM 输出
+    # 4. 返回结构化数据
+```
+
+## 迁移步骤
+
+1. 在 LangGraph 中创建 `report_parse_agent`(算法 + LLM 兜底)
+2. 创建 `POST /api/v1/report/parse` 接口
+3. 修改 Java `HealthReportController.parsePreview()`,先调 LangGraph,失败时回退到本地 Java 解析
+4. 验证通过后,移除 Java 端的 `PdfParseService`
+5. 编写测试用例覆盖 triplet/inline 两种格式

+ 244 - 0
docs/superpowers/specs/2026-08-05-inventory-management-design.md

@@ -0,0 +1,244 @@
+# 库存管理设计
+
+## 概述
+
+为商品管理模块增加完整的库存管理功能,记录实物商品的入库、出库(订单)、盘点及预警,支持 SKU 级别库存。
+
+## 数据库设计
+
+### 1. inventory_transactions — 库存流水
+
+```sql
+CREATE TABLE IF NOT EXISTS inventory_transactions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    sku_id BIGINT COMMENT 'SKU ID,NULL=商品级库存',
+    type VARCHAR(20) NOT NULL COMMENT '类型: inbound/outbound/adjustment/counting/refund',
+    direction TINYINT NOT NULL COMMENT '方向: 1=入库 -1=出库',
+    quantity INT NOT NULL COMMENT '变动数量(正数)',
+    before_stock INT NOT NULL COMMENT '变动前库存',
+    after_stock INT NOT NULL COMMENT '变动后库存',
+    reference_type VARCHAR(32) COMMENT '关联类型: inbound_order/order/counting/adjustment',
+    reference_id BIGINT COMMENT '关联ID',
+    operator_id BIGINT COMMENT '操作人ID,关联 users.id',
+    remark VARCHAR(500) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id),
+    INDEX idx_sku (sku_id),
+    INDEX idx_type (type),
+    INDEX idx_reference (reference_type, reference_id),
+    INDEX idx_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';
+```
+
+### 2. inventory_inbound_orders — 入库单
+
+```sql
+CREATE TABLE IF NOT EXISTS inventory_inbound_orders (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '入库单号',
+    supplier_id BIGINT COMMENT '供应商ID,关联 ecom_supplier.id',
+    supplier_name VARCHAR(200) COMMENT '供应商名称(冗余)',
+    status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/completed/cancelled',
+    total_items INT NOT NULL DEFAULT 0 COMMENT '商品总数',
+    operator_id BIGINT COMMENT '操作人ID,关联 users.id',
+    remark VARCHAR(500) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_status (status),
+    INDEX idx_operator (operator_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单表';
+```
+
+### 3. inventory_inbound_items — 入库单明细
+
+```sql
+CREATE TABLE IF NOT EXISTS inventory_inbound_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    inbound_order_id BIGINT NOT NULL COMMENT '入库单ID',
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    sku_id BIGINT COMMENT 'SKU ID',
+    quantity INT NOT NULL COMMENT '入库数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_inbound_order (inbound_order_id),
+    INDEX idx_product (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单明细表';
+```
+
+### 4. inventory_counting_records — 盘点记录
+
+```sql
+CREATE TABLE IF NOT EXISTS inventory_counting_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    sku_id BIGINT COMMENT 'SKU ID',
+    expected_stock INT NOT NULL COMMENT '系统库存',
+    actual_stock INT NOT NULL COMMENT '实际库存',
+    difference INT NOT NULL COMMENT '差异(actual - expected)',
+    status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/confirmed',
+    operator_id BIGINT COMMENT '操作人ID',
+    remark VARCHAR(500) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='盘点记录表';
+```
+
+### 5. product_bundle_items — 套餐商品组成(零进整出)
+
+```sql
+CREATE TABLE IF NOT EXISTS product_bundle_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    bundle_product_id BIGINT NOT NULL COMMENT '套餐商品ID,关联 products.id',
+    child_product_id BIGINT NOT NULL COMMENT '子商品ID,关联 products.id',
+    child_sku_id BIGINT COMMENT '子商品SKU ID,NULL=商品级',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '子商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_bundle (bundle_product_id),
+    INDEX idx_child (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成表';
+```
+
+**套餐库存逻辑:**
+- 套餐商品 `product_type='bundle'`,**自身不管理库存**(零进)
+- 卖出时从各子商品扣减库存(整出)
+- 库存检查:遍历所有子商品,任一子商品库存不足则套餐不可售
+- 出库流水:每个子商品各生成一条 `type=outbound` 记录
+
+### 6. 现有表扩展
+
+- `products` 表增加列:`min_stock_alert INT DEFAULT 10 COMMENT '库存预警下限'`
+- `product_skus` 表增加列:`min_stock_alert INT DEFAULT 10 COMMENT '库存预警下限'`
+
+## 后端架构
+
+### 新增文件
+
+```
+entity/
+├── InventoryTransaction.java
+├── InventoryInboundOrder.java
+├── InventoryInboundItem.java
+├── InventoryCountingRecord.java
+
+mapper/
+├── InventoryTransactionMapper.java
+├── InventoryInboundOrderMapper.java
+├── InventoryInboundItemMapper.java
+├── InventoryCountingRecordMapper.java
+
+service/
+└── InventoryService.java
+
+controller/admin/
+└── AdminInventoryController.java
+```
+
+### API 接口
+
+| 方法 | 接口 | 说明 |
+|------|------|------|
+| POST | `/api/admin/inventory/inbound/create` | 创建入库单(含明细,事务提交) |
+| POST | `/api/admin/inventory/inbound/list` | 入库单列表(分页+筛选) |
+| POST | `/api/admin/inventory/inbound/detail` | 入库单详情(含明细) |
+| POST | `/api/admin/inventory/inbound/confirm` | 确认入库(状态→completed,触发库存增加+流水) |
+| POST | `/api/admin/inventory/inbound/cancel` | 取消入库单 |
+| POST | `/api/admin/inventory/transactions/list` | 库存流水(按商品/时间/类型筛选) |
+| POST | `/api/admin/inventory/counting/create` | 创建盘点记录 |
+| POST | `/api/admin/inventory/counting/confirm` | 确认盘点(差异自动调账+流水) |
+| POST | `/api/admin/inventory/counting/list` | 盘点列表 |
+| POST | `/api/admin/inventory/product/stock` | 商品库存详情(含预警状态) |
+| POST | `/api/admin/inventory/alert/list` | 库存预警商品列表 |
+
+### 库存自动出库集成
+
+| 位置 | 事件 | 操作 |
+|------|------|------|
+| `ProductOrderService.handlePaymentSuccess()` | 支付成功扣减库存后 | 追加 `direction=-1, type=outbound` 流水 |
+| `ProductOrderService.cancel()` | 取消订单恢复库存后 | 追加 `direction=1, type=refund` 流水 |
+| `ProductOrderService.approveRefund()` | 退款恢复库存后 | 追加 `direction=1, type=refund` 流水 |
+
+### InventoryService 核心职责
+
+- `createInboundOrder()` — 创建入库单(状态 pending)
+- `confirmInbound()` — 确认入库:逐条增加 products.stock / product_skus.stock,写入流水
+- `recordOutbound()` — 供订单服务调用,记录出库流水
+- `getStockAlerts()` — 查询所有库存低于预警线的商品/SKU
+- `createCounting()` — 创建盘点并自动计算差异
+- `confirmCounting()` — 确认盘点结果,差异自动调账
+
+### 套餐商品(Bundle)库存逻辑
+
+**新增 Entity / Mapper / Service:**
+- `entity/ProductBundleItem.java` — 套餐组成实体
+- `mapper/ProductBundleItemMapper.java` — 套餐组成 Mapper
+- `InventoryService` 中新增 bundle 相关方法
+
+**新增 API:**
+```
+POST /api/admin/inventory/bundle/items   — 配置套餐商品组成(子商品+数量)
+POST /api/admin/inventory/bundle/items/list — 查询套餐组成
+```
+
+**订单集成逻辑:**
+- `ProductOrderService.create()` / `createMultiItem()`: 库存检查时,若商品为 `product_type='bundle'`,遍历 `product_bundle_items` 检查每个子商品库存
+- `ProductOrderService.handlePaymentSuccess()`: 若商品为 bundle,不扣主商品库存,改为逐条扣减子商品库存+记录流水
+- `ProductOrderService.cancel()` / `approveRefund()`: 恢复各子商品库存+记录流水
+
+**库存查询:**
+- `GET /api/admin/inventory/product/stock` 返回 bundle 商品时,`stock` 字段返回各子商品的最低库存,并附带子商品明细
+
+## 管理端 UI
+
+### 新增页面
+
+**InventoryManage.vue** — 库存管理主页,Tabs 布局:
+
+| 标签页 | 内容 |
+|--------|------|
+| 库存流水 | 筛选(商品名/SKU/类型/时间范围),表格展示流水 |
+| 入库单 | 列表 + 查看/确认/取消操作 |
+| 库存预警 | 低于预警线的商品列表,红色高亮 |
+| 盘点记录 | 盘点列表 + 确认操作 |
+
+**InventoryInboundCreate.vue** — 创建入库单:
+- 供应商搜索选择
+- 动态明细行(商品选择器 → 规格选择 → 数量)
+- 备注
+
+### 修改现有页面
+
+**ProductManage.vue** — 商品列表每行增加「库存」按钮,点击弹窗显示:
+- 当前库存、预警下限、最近 10 条流水
+- SKU 列表及各自库存
+- 快捷入库按钮
+
+### 路由
+
+```js
+{ path: 'inventory', component: InventoryManage, meta: { perm: 'commerce:inventory' } }
+{ path: 'inventory/inbound-create', component: InventoryInboundCreate, meta: { perm: 'commerce:inventory' } }
+```
+
+### 侧边栏
+
+在「商品管理」菜单下新增「库存管理」子菜单项。
+
+## 数据库迁移
+
+迁移编号: 168(当前最新 167),在 `DatabaseInitializer.runMigrations()` 末尾追加。
+
+## 范围说明
+
+- 库存预警仅对 `product_type='physical'` 的实物商品生效
+- `min_stock_alert` 默认值为 10,通过管理端 UI 可逐商品/SKU 调整
+- 非实物商品(digital/assessment/coupon/virtual)不参与库存管理
+- 套餐商品 `product_type='bundle'` 零进整出:自身不管理库存,通过子商品扣减实现
+
+## 设计决策
+
+1. **库存快照字段**:流水记录 `before_stock` 和 `after_stock`,即使后续修改,流水也能反映当时库存状态
+2. **入库单两阶段提交**:创建(pending)→ 确认(completed),确认时才实际修改库存,支持撤销
+3. **盘点差异自动调账**:确认盘点时,差异自动生成一条 `type=adjustment` 的流水
+4. **SKU 级 vs 商品级**:有 SKU 的商品按 SKU 管理库存,无 SKU 的直接按商品管理
+5. **套餐零进整出**:`product_type='bundle'` 的商品不自持库存,库存检查/扣减穿透到子商品;入库单也只针对子商品,不针对套餐本身