Przeglądaj źródła

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

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
asus 3 miesięcy temu
rodzic
commit
173598a827
36 zmienionych plików z 1869 dodań i 115 usunięć
  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个测试类)
 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` 验证编译通过
 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() {
     private void initializeDatabase() {
         try {
         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("数据库检查完成");
             log.info("数据库检查完成");
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("创建数据库失败: {}", e.getMessage());
             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)
         // 家庭成员表添加 role_override 列(替换旧 capability_role)
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         try {
         try {
@@ -2860,34 +2857,6 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建member_discount_configs表失败: {}", e.getMessage());
             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 ====================
         // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ====================
         try {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
@@ -5426,12 +5395,7 @@ try {
             log.info("已创建social_circle_member表");
             log.info("已创建social_circle_member表");
         } catch (Exception e) { /* 表已存在忽略 */ }
         } 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 {
     try {
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
             "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
             "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -7006,13 +6970,7 @@ private void runMigration100() {
 	} catch (Exception e) {
 	} catch (Exception e) {
 		// 列已不存在,忽略
 		// 列已不存在,忽略
 	}
 	}
-	try {
-		jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types");
-		log.info("已删除relationship_types表");
-	} catch (Exception e) {
-		// 表已不存在,忽略
 	}
 	}
-}
 
 
 	// 迁移101: product_skus表添加min_quantity列(商品规格最小购买数限制)
 	// 迁移101: product_skus表添加min_quantity列(商品规格最小购买数限制)
 	private void runMigration101() {
 	private void runMigration101() {
@@ -7727,7 +7685,44 @@ private void runMigration100() {
 		// 迁移166: payment_orders 添加 group_no/period_no 列(会员拆单支付,配置驱动拆单)
 		// 迁移166: payment_orders 添加 group_no/period_no 列(会员拆单支付,配置驱动拆单)
 		ensureColumn("payment_orders", "group_no", "VARCHAR(64) COMMENT '拆单组号(同一拆单组共享)'");
 		ensureColumn("payment_orders", "group_no", "VARCHAR(64) COMMENT '拆单组号(同一拆单组共享)'");
 		ensureColumn("payment_orders", "period_no", "INT DEFAULT 1 COMMENT '期数序号(1开始)'");
 		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 {
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_join_requests (" +
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_join_requests (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
 					"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 {
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS knowledge_base_section (" +
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS knowledge_base_section (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
 					"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")
     @PostMapping("/fortune")
     public Result<Map<String, Object>> getTodayFortune(@RequestAttribute("userId") Long userId) {
     public Result<Map<String, Object>> getTodayFortune(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         Long familyId = membershipService.getUserFamilyId(userId);
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         Map<String, Object> result = batchBuildFortuneResponse(fortune);
         Map<String, Object> result = batchBuildFortuneResponse(fortune);
@@ -45,6 +46,7 @@ public class MindFortuneController {
      */
      */
     @PostMapping("/fortune/refresh")
     @PostMapping("/fortune/refresh")
     public Result<FamilyFortune> refreshFortune(@RequestAttribute("userId") Long userId) {
     public Result<FamilyFortune> refreshFortune(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         Long familyId = membershipService.getUserFamilyId(userId);
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         FamilyFortune fortune = fortuneService.getFamilyFortune(familyId, LocalDate.now());
         fortune.setLuckyDirection(fortuneService.calculateLuckyDirection(familyId, LocalDate.now()));
         fortune.setLuckyDirection(fortuneService.calculateLuckyDirection(familyId, LocalDate.now()));
@@ -61,6 +63,7 @@ public class MindFortuneController {
     public Result<Map<String, Object>> exportFortuneReport(
     public Result<Map<String, Object>> exportFortuneReport(
             @RequestAttribute("userId") Long userId,
             @RequestAttribute("userId") Long userId,
             @RequestBody ExportReportRequest request) {
             @RequestBody ExportReportRequest request) {
+        if (userId == null) return Result.error("请先登录");
         Long familyId = membershipService.getUserFamilyId(userId);
         Long familyId = membershipService.getUserFamilyId(userId);
         try {
         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));
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
     }
     }
@@ -214,7 +214,7 @@ public class ArticleControllerTest {
 
 
         doNothing().when(articleService).recordRead(eq(1L), isNull(), eq(10L), eq(60));
         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());
         assertEquals(200, result.getCode());
     }
     }
 
 
@@ -225,7 +225,7 @@ public class ArticleControllerTest {
 
 
         doNothing().when(articleService).recordRead(eq(1L), isNull(), isNull(), eq(0));
         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());
         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);
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -169,7 +169,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getOverview(20L)).thenReturn(mockOverview);
         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(200, result.getCode());
         assertEquals(100, result.getData().get("bodyScore"));
         assertEquals(100, result.getData().get("bodyScore"));
@@ -183,7 +183,7 @@ public class EnergyControllerTest {
     public void getEnergyOverview_NullChildId() {
     public void getEnergyOverview_NullChildId() {
         Map<String, Object> params = new HashMap<>();
         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(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());
         assertEquals("childId不能为空", result.getMessage());
@@ -200,7 +200,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getOverview(30L)).thenReturn(mockOverview);
         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(200, result.getCode());
         assertEquals(0, result.getData().get("totalEnergy"));
         assertEquals(0, result.getData().get("totalEnergy"));
@@ -225,7 +225,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getLogs(10L, null, 1, 10)).thenReturn(page);
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -249,7 +249,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getLogs(10L, "body", 1, 10)).thenReturn(page);
         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(200, result.getCode());
         assertEquals(1, result.getData().getTotal());
         assertEquals(1, result.getData().getTotal());
@@ -267,7 +267,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getLogs(10L, null, 1, 10)).thenReturn(emptyPage);
         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());
         assertEquals(200, result.getCode());
     }
     }
@@ -276,7 +276,7 @@ public class EnergyControllerTest {
     public void getEnergyLogs_NullChildId() {
     public void getEnergyLogs_NullChildId() {
         Map<String, Object> params = new HashMap<>();
         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(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());
         assertEquals("childId不能为空", result.getMessage());
@@ -295,7 +295,7 @@ public class EnergyControllerTest {
 
 
         when(energyService.getLogs(99L, null, 1, 10)).thenReturn(emptyPage);
         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(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
         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;
                 return r;
             });
             });
 
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData().getId());
         assertNotNull(result.getData().getId());
         assertEquals("physical_exam", result.getData().getReportType());
         assertEquals("physical_exam", result.getData().getReportType());
@@ -110,7 +110,7 @@ public class HealthReportControllerTest {
                 return r;
                 return r;
             });
             });
 
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals("gut_flora", result.getData().getReportType());
         assertEquals("gut_flora", result.getData().getReportType());
         assertEquals("25岁", result.getData().getGutAge());
         assertEquals("25岁", result.getData().getGutAge());
@@ -132,7 +132,7 @@ public class HealthReportControllerTest {
                 return r;
                 return r;
             });
             });
 
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData().getId());
         assertNotNull(result.getData().getId());
     }
     }
@@ -142,7 +142,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("reportType", "physical_exam");
         params.put("reportType", "physical_exam");
 
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, null);
         assertEquals(500, result.getCode());
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("childId"));
         assertTrue(result.getMessage().contains("childId"));
     }
     }
@@ -152,7 +152,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5L);
         params.put("childId", 5L);
 
 
-        Result<HealthReport> result = controller.createReport(params, 5L);
+        Result<HealthReport> result = controller.createReport(params, 5L, 5L);
         assertEquals(500, result.getCode());
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("reportType"));
         assertTrue(result.getMessage().contains("reportType"));
     }
     }
@@ -168,7 +168,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
         params.put("childId", 5);
 
 
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(5L, result.getData().getUserId().longValue());
         assertEquals(5L, result.getData().getUserId().longValue());
     }
     }
@@ -180,7 +180,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 999);
         params.put("childId", 999);
 
 
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, 999L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertNull(result.getData());
         assertNull(result.getData());
     }
     }
@@ -188,7 +188,7 @@ public class HealthReportControllerTest {
     @Test
     @Test
     public void getLatestReport_MissingChildId() {
     public void getLatestReport_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
-        Result<HealthReport> result = controller.getLatestReport(params);
+        Result<HealthReport> result = controller.getLatestReport(params, null);
         assertEquals(500, result.getCode());
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("childId"));
         assertTrue(result.getMessage().contains("childId"));
     }
     }
@@ -207,7 +207,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
         params.put("childId", 5);
 
 
-        Result<List<HealthReport>> result = controller.listReports(params);
+        Result<List<HealthReport>> result = controller.listReports(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
         assertEquals(2, result.getData().size());
     }
     }
@@ -219,7 +219,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
         params.put("childId", 5);
 
 
-        Result<List<HealthReport>> result = controller.listReports(params);
+        Result<List<HealthReport>> result = controller.listReports(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertTrue(result.getData().isEmpty());
         assertTrue(result.getData().isEmpty());
     }
     }
@@ -227,7 +227,7 @@ public class HealthReportControllerTest {
     @Test
     @Test
     public void listReports_MissingChildId() {
     public void listReports_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
         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());
         assertEquals(500, result.getCode());
     }
     }
 
 
@@ -273,7 +273,7 @@ public class HealthReportControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("childId", 5);
         params.put("childId", 5);
 
 
-        Result<List<HealthIndicator>> result = controller.getBodyDetail(params);
+        Result<List<HealthIndicator>> result = controller.getBodyDetail(params, 5L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
         assertEquals(2, result.getData().size());
     }
     }
@@ -281,7 +281,7 @@ public class HealthReportControllerTest {
     @Test
     @Test
     public void getBodyDetail_MissingChildId() {
     public void getBodyDetail_MissingChildId() {
         Map<String, Object> params = new HashMap<>();
         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());
         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);
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -66,7 +66,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getBalance(null)).thenReturn(null);
         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());
         assertEquals(200, result.getCode());
         assertNull(result.getData());
         assertNull(result.getData());
@@ -82,7 +82,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getBalance(20L)).thenReturn(mockBalance);
         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(200, result.getCode());
         assertEquals(0, result.getData().get("balance"));
         assertEquals(0, result.getData().get("balance"));
@@ -107,7 +107,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getPointsLogs(10L, 1, 10)).thenReturn(page);
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -126,7 +126,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getPointsLogs(10L, 1, 10)).thenReturn(emptyPage);
         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());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -146,7 +146,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getPointsLogs(99L, 1, 10)).thenReturn(emptyPage);
         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(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
         assertEquals(0, result.getData().getTotal());
@@ -170,7 +170,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getPointsLogsByCategory(10L, "earn", 1, 10)).thenReturn(page);
         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(200, result.getCode());
         assertEquals(2, result.getData().getRecords().size());
         assertEquals(2, result.getData().getRecords().size());
@@ -188,7 +188,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getPointsLogsByCategory(10L, null, 1, 10)).thenReturn(page);
         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());
         assertEquals(200, result.getCode());
     }
     }
@@ -202,7 +202,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getSystemPointsBalance(10L)).thenReturn(500);
         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(200, result.getCode());
         assertEquals(500, result.getData().get("systemPoints"));
         assertEquals(500, result.getData().get("systemPoints"));
@@ -215,7 +215,7 @@ public class PointsControllerTest {
 
 
         when(pointsService.getSystemPointsBalance(20L)).thenReturn(0);
         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(200, result.getCode());
         assertEquals(0, result.getData().get("systemPoints"));
         assertEquals(0, result.getData().get("systemPoints"));
@@ -226,7 +226,7 @@ public class PointsControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         // childId not provided
         // 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(500, result.getCode());
         assertEquals("childId不能为空", result.getMessage());
         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.setTitle("每日阅读");
         dto.setDescription("阅读30分钟");
         dto.setDescription("阅读30分钟");
         dto.setPoints(5);
         dto.setPoints(5);
-        dto.setChildIds(Collections.singletonList(10L));
+        dto.setMemberIds(Collections.singletonList(10L));
 
 
         when(taskService.createTask(1L, dto)).thenReturn(100L);
         when(taskService.createTask(1L, dto)).thenReturn(100L);
 
 
@@ -111,7 +111,7 @@ public class TaskControllerTest {
         );
         );
         when(taskService.getTodayTasks(10L)).thenReturn(tasks);
         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(200, result.getCode());
         assertEquals(2, result.getData().size());
         assertEquals(2, result.getData().size());
     }
     }
@@ -121,7 +121,7 @@ public class TaskControllerTest {
     @Test
     @Test
     public void completeTask_Success() {
     public void completeTask_Success() {
         CompleteTaskDTO dto = new CompleteTaskDTO();
         CompleteTaskDTO dto = new CompleteTaskDTO();
-        dto.setChildId(10L);
+        dto.setMemberId(10L);
         dto.setPhotoUrl("http://example.com/photo.jpg");
         dto.setPhotoUrl("http://example.com/photo.jpg");
 
 
         Map<String, Object> completionResult = new HashMap<>();
         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);
         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(200, result.getCode());
         assertEquals(5, result.getData().get("pointsEarned"));
         assertEquals(5, result.getData().get("pointsEarned"));
     }
     }
@@ -184,7 +184,7 @@ public class TaskControllerTest {
 
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
         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(200, result.getCode());
         assertEquals(2, result.getData().getTotal());
         assertEquals(2, result.getData().getTotal());
     }
     }
@@ -200,7 +200,7 @@ public class TaskControllerTest {
 
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
         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(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
         assertEquals(0, result.getData().getTotal());
     }
     }
@@ -278,7 +278,7 @@ public class TaskControllerTest {
 
 
         when(taskService.completeMinigameTask(1L, 10L, 25, 85)).thenReturn(gameResult);
         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(200, result.getCode());
         assertEquals(7, result.getData().get("pointsEarned"));
         assertEquals(7, result.getData().get("pointsEarned"));
     }
     }
@@ -294,7 +294,7 @@ public class TaskControllerTest {
 
 
         when(taskService.completeMinigameTask(eq(1L), eq(10L), isNull(), isNull())).thenReturn(gameResult);
         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());
         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);
         grantParams.put("dimensionCode", dimensionCode);
 
 
         // === When:触发能量发放 ===
         // === When:触发能量发放 ===
-        Result<EnergyLog> result = energyController.grantEnergy(grantParams);
+        Result<EnergyLog> result = energyController.grantEnergy(grantParams, CHILD_ID);
 
 
         // === Then:Milestone-2 - 能量发放成功 ===
         // === Then:Milestone-2 - 能量发放成功 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -175,7 +175,7 @@ public class EnergySystemFlowTest {
         deductParams.put("amount", energyDeduct);
         deductParams.put("amount", energyDeduct);
 
 
         // === When:使用能量兑换心愿 ===
         // === When:使用能量兑换心愿 ===
-        Result<Boolean> result = energyController.deductForWish(deductParams);
+        Result<Boolean> result = energyController.deductForWish(deductParams, CHILD_ID);
 
 
         // === Then:Milestone-3 - 能量扣减成功 ===
         // === Then:Milestone-3 - 能量扣减成功 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -204,7 +204,7 @@ public class EnergySystemFlowTest {
                 .thenThrow(new RuntimeException("能量不足,当前可用:300,需要:5000"));
                 .thenThrow(new RuntimeException("能量不足,当前可用:300,需要:5000"));
 
 
         // === When:尝试兑换 ===
         // === When:尝试兑换 ===
-        Result<Boolean> result = energyController.deductForWish(deductRequest);
+        Result<Boolean> result = energyController.deductForWish(deductRequest, CHILD_ID);
 
 
         // === Then:返回错误 ===
         // === Then:返回错误 ===
         assertEquals(500, result.getCode());
         assertEquals(500, result.getCode());
@@ -239,7 +239,7 @@ public class EnergySystemFlowTest {
                 .thenReturn(mockPage);
                 .thenReturn(mockPage);
 
 
         // === When:查询能量流水 ===
         // === When:查询能量流水 ===
-        Result<Page<EnergyLog>> result = energyController.getEnergyLogs(request);
+        Result<Page<EnergyLog>> result = energyController.getEnergyLogs(request, CHILD_ID);
 
 
         // === Then:返回分页数据 ===
         // === Then:返回分页数据 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -275,7 +275,7 @@ public class EnergySystemFlowTest {
         when(energyService.getOverview(CHILD_ID)).thenReturn(mockOverview);
         when(energyService.getOverview(CHILD_ID)).thenReturn(mockOverview);
 
 
         // === When:请求能量概览 ===
         // === When:请求能量概览 ===
-        Result<Map<String, Object>> result = energyController.getEnergyOverview(request);
+        Result<Map<String, Object>> result = energyController.getEnergyOverview(request, CHILD_ID);
 
 
         // === Then:Milestone-5 - 返回概览数据包含健康指数 ===
         // === Then:Milestone-5 - 返回概览数据包含健康指数 ===
         assertEquals(200, result.getCode());
         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(growthRecordService.createRecord(any(GrowthRecord.class))).thenReturn(savedRecord);
 
 
         // === When:提交创建档案和购买测评 ===
         // === 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:档案创建成功,订单也创建 ===
         // === Then:档案创建成功,订单也创建 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -252,7 +252,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
 
         // === When:家长请求历史记录 ===
         // === When:家长请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
 
         // === Then:返回历史记录列表 ===
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -274,7 +274,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
 
         // === When:家长通过新接口请求历史记录 ===
         // === When:家长通过新接口请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
 
         // === Then:返回历史记录列表 ===
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -296,7 +296,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
 
         // === Then:返回历史记录列表 ===
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -318,7 +318,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
 
         // === Then:返回历史记录列表 ===
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
@@ -340,7 +340,7 @@ public class GrowthRecordSyncFlowTest {
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
         when(growthRecordService.getRecordsByChild(CHILD_ID)).thenReturn(mockRecords);
 
 
         // === When:家长通过新接口(请求体)请求历史记录 ===
         // === When:家长通过新接口(请求体)请求历史记录 ===
-        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID);
+        Result<List<GrowthRecord>> result = growthRecordController.getRecordsByChild(CHILD_ID, null);
 
 
         // === Then:返回历史记录列表 ===
         // === Then:返回历史记录列表 ===
         assertEquals(200, result.getCode());
         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.setDescription("测试描述");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -61,7 +61,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("积分测试任务");
         dto.setTitle("积分测试任务");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -77,7 +77,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("无效小游戏任务");
         dto.setTitle("无效小游戏任务");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode("invalid_code_xyz");
         dto.setMinigameCode("invalid_code_xyz");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -89,7 +89,7 @@ public class TaskServiceMinigameTest {
         CreateTaskDTO dto = new CreateTaskDTO();
         CreateTaskDTO dto = new CreateTaskDTO();
         dto.setTitle("小游戏类但无code");
         dto.setTitle("小游戏类但无code");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         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.alibaba.fastjson.JSON;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 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() {
     loadHealthAlerts: function() {
       var self = this
       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) {
         if (res.code === 200) {
           self.healthAlerts = Array.isArray(res.data) ? res.data : []
           self.healthAlerts = Array.isArray(res.data) ? res.data : []
         } else {
         } else {
@@ -928,7 +934,13 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
     },
     },
     loadMilestones: function() {
     loadMilestones: function() {
       var self = this
       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) {
         if (res.code === 200) {
           self.milestones = Array.isArray(res.data) ? res.data : []
           self.milestones = Array.isArray(res.data) ? res.data : []
         } else {
         } 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
     rm /tmp/pkgs.tar.gz
 
 
 COPY app/ app/
 COPY app/ app/
+COPY src/ src/
 COPY data/ data/
 COPY data/ data/
 
 
 RUN useradd -m -u 1000 appuser && \
 RUN useradd -m -u 1000 appuser && \
@@ -26,7 +27,7 @@ CMD ["python3", "-m", "gunicorn", "app.main:app", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--worker-class", "uvicorn.workers.UvicornWorker", \
      "--bind", "0.0.0.0:9000", \
      "--bind", "0.0.0.0:9000", \
      "--workers", "2", \
      "--workers", "2", \
-     "--timeout", "60", \
+     "--timeout", "300", \
      "--keep-alive", "10", \
      "--keep-alive", "10", \
      "--access-logfile", "-", \
      "--access-logfile", "-", \
      "--error-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 fastapi import FastAPI, Request
 from app.api import health, recommend, chat, analyze, tongue, adapter
 from app.api import health, recommend, chat, analyze, tongue, adapter
 from app import monitoring
 from app import monitoring
+from src.app import router as questionnaire_router
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -17,6 +18,7 @@ app.include_router(analyze.router)
 app.include_router(tongue.router)
 app.include_router(tongue.router)
 app.include_router(adapter.router)
 app.include_router(adapter.router)
 app.include_router(monitoring.router)
 app.include_router(monitoring.router)
+app.include_router(questionnaire_router)
 
 
 
 
 @app.middleware("http")
 @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
       - .env.production
     environment:
     environment:
       - JAVA_BASE_URL=http://172.17.0.1:9082
       - 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
       - CHROMA_DB_PATH=/data/chroma_db
       - JSON_LOGS=true
       - JSON_LOGS=true
       - LANGCHAIN_TRACING_V2=${LANGCHAIN_TRACING_V2:-false}
       - LANGCHAIN_TRACING_V2=${LANGCHAIN_TRACING_V2:-false}
@@ -39,7 +40,7 @@ services:
       - cfc-net
       - cfc-net
     restart: unless-stopped
     restart: unless-stopped
     healthcheck:
     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
       interval: 30s
       timeout: 5s
       timeout: 5s
       retries: 3
       retries: 3

+ 1 - 0
cfc-langgraph/requirements.txt

@@ -7,3 +7,4 @@ pydantic>=2.0,<3.0
 python-dotenv>=1.0,<2.0
 python-dotenv>=1.0,<2.0
 httpx>=0.27,<1.0
 httpx>=0.27,<1.0
 pytest>=8.0,<9.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",
   "name": "cfc-web",
-  "version": "1.0.799",
+  "version": "1.0.807",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.799",
+      "version": "1.0.807",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.799",
+  "version": "1.0.808",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "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)
 ## v1.0.799 (2026-08-05)
 
 
 ### Bug 修复
 ### 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)
 ## v1.0.799 (2026-08-05)
 
 
 ### Bug 修复
 ### 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` |
 | 家庭成员关系图谱(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` |
 | 家庭成员卡片(跨页展示+可见性) | ✅ 已上线 | `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` |
 | 成员详情页维度专属内容区 | ✅ 已实施(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 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 | ✅ 已实施(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 | 🟡 计划中 | `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-registration-profile-backfill-design.md` | 🟡 设计稿 | 新用户注册引导 + 个人信息按需补充/回写 |
 | `2026-08-04-report-confirm-member-selector-design.md` | 🟡 设计稿 | 健康报告确认页被检测人选择面板整合(基本信息内选/添被检人) |
 | `2026-08-04-report-confirm-member-selector-design.md` | 🟡 设计稿 | 健康报告确认页被检测人选择面板整合(基本信息内选/添被检人) |
 | `2026-08-04-family-challenge-custom.md` | 🟢 已实施 | 家庭挑战自定义管理(创建/编辑/删除/模板) |
 | `2026-08-04-family-challenge-custom.md` | 🟢 已实施 | 家庭挑战自定义管理(创建/编辑/删除/模板) |
+| `2026-08-05-inventory-management-design.md` | 🟡 设计稿 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) |
 
 
 ### 实施计划(plans/)
 ### 实施计划(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'` 的商品不自持库存,库存检查/扣减穿透到子商品;入库单也只针对子商品,不针对套餐本身