Quellcode durchsuchen

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

jiapu vor 2 Wochen
Ursprung
Commit
9bcf53a0a6
76 geänderte Dateien mit 8842 neuen und 511 gelöschten Zeilen
  1. 3 0
      .gitignore
  2. 40 2
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 39 14
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  4. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  5. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java
  6. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java
  7. 13 45
      cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java
  8. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCoupon.java
  9. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCouponGrantLogMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCouponMapper.java
  12. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  13. 19 1
      cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java
  14. 167 105
      cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java
  15. 39 4
      cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java
  16. 193 16
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  17. 2 8
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  18. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java
  19. 40 18
      cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
  20. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  21. 21 2
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  22. 6 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java
  23. 148 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java
  24. 2 109
      cfc-backend/src/main/java/com/etotem/cfc/service/WuxingSourcingService.java
  25. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  26. 4 4
      cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java
  27. 33 1
      cfc-backend/src/main/resources/schema.sql
  28. 57 8
      cfc-frontend/__tests__/api/api.test.js
  29. 233 0
      cfc-frontend/components/MemberChipPicker.vue
  30. 3 0
      cfc-frontend/components/health-knowledge-popup.vue
  31. 14 4
      cfc-frontend/pages/diet/index.vue
  32. 30 10
      cfc-frontend/pages/diet/recommendation.vue
  33. 70 2
      cfc-frontend/pages/family/self-check-entry.vue
  34. 123 48
      cfc-frontend/pages/family/self-check-result.vue
  35. 3 2
      cfc-frontend/pages/health/gut-flora-detail.vue
  36. 315 49
      cfc-frontend/pages/health/gut-flora-risks-detail.vue
  37. 86 2
      cfc-frontend/utils/api.js
  38. 1 0
      cfc-langgraph/app/api/chat.py
  39. 54 0
      cfc-langgraph/app/api/self_check.py
  40. 23 0
      cfc-langgraph/app/graphs/chat_graph.py
  41. 116 0
      cfc-langgraph/app/graphs/self_check_analysis_graph.py
  42. 76 0
      cfc-langgraph/app/graphs/self_check_trend_graph.py
  43. 0 0
      cfc-langgraph/app/llm/__init__.py
  44. 3 0
      cfc-langgraph/app/llm/client.py
  45. 2 1
      cfc-langgraph/app/main.py
  46. 1 0
      cfc-langgraph/app/models/common.py
  47. 1 1
      cfc-web/.last_build_commit
  48. 2 2
      cfc-web/package-lock.json
  49. 1 1
      cfc-web/package.json
  50. 161 0
      cfc-web/public/CHANGELOG-v1.0.md
  51. 162 1
      cfc-web/public/CHANGELOG.md
  52. 8 0
      cfc-web/src/api/coupon.js
  53. 83 1
      cfc-web/src/views/Families.vue
  54. 29 6
      cfc-web/src/views/Users.vue
  55. 19 20
      cfc-web/src/views/admin/CouponGrantLog.vue
  56. 21 11
      cfc-web/src/views/admin/CouponManagement.vue
  57. 1 1
      cfc-web/src/views/admin/MembershipCenter.vue
  58. 6 3
      docs/superpowers/PROJECT-OVERVIEW.md
  59. 25 4
      docs/superpowers/api/API_REFERENCE.md
  60. 126 0
      docs/superpowers/plans/2026-08-31-coupon-family-based.md
  61. 1303 0
      docs/superpowers/plans/2026-08-31-self-check-ai-integration.md
  62. 1389 0
      docs/superpowers/plans/2026-09-01-cf-commission-redesign.md
  63. 756 0
      docs/superpowers/plans/2026-09-01-diet-recipe-fix.md
  64. 252 0
      docs/superpowers/specs/2026-08-31-coupon-family-based-design.md
  65. 385 0
      docs/superpowers/specs/2026-08-31-self-check-ai-integration-design.md
  66. 322 0
      docs/superpowers/specs/2026-09-01-cf-commission-redesign.md
  67. 211 0
      运营文案/opencode_client.py
  68. 329 0
      运营文案/xhs_bot_listener.py
  69. 4 0
      运营文案/xhs_bot_listener.vbs
  70. 2 0
      运营文案/xhs_bot_listener_start.bat
  71. 6 0
      运营文案/xhs_daily_config.json.example
  72. 224 0
      运营文案/xhs_feishu.py
  73. 2 0
      运营文案/xhs_unreplied_comments.bat
  74. 411 0
      运营文案/xhs_unreplied_comments.py
  75. 4 1
      运营文案/小红书内容/D29-假懂事的孩子比叛逆更让人心疼.md
  76. 360 0
      运营文案/小红书发布/_过程脚本/xhs_daily_report.py

+ 3 - 0
.gitignore

@@ -74,3 +74,6 @@ venv/
 
 # Skills temp directory
 .superpowers/sdd/
+
+# 小红书日报推送本地配置(含飞书密钥,禁止提交)
+运营文案/xhs_daily_config.json

+ 40 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9526,10 +9526,10 @@ public class DatabaseInitializer implements CommandLineRunner {
         migratePlanCenterMenu();
         // 迁移268: coupon 表添加 status 列(优惠券停用/启用)
         migrateCouponStatus();
-// 迁移273: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
+// 迁移276: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
         ensureColumn("five_dimension_self_checks", "question_ids_json", "VARCHAR(255) COMMENT '本次自检使用的题号JSON: [1,5,9,...]'");
 
-        // 迁移274: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据)
+        // 迁移277: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS self_check_ignores (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -10067,5 +10067,43 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (SQLException e) {
             log.warn("注册报告类型 gut_flora 失败:{}", e.getMessage());
         }
+
+        // 迁移278: 创建 family_coupon 表 + family_coupon_grant_log 表
+        // (优惠券全链路改为家庭维度:2026-08-31)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
+                    "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
+                    "status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED', " +
+                    "received_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "used_at DATETIME, " +
+                    "order_id BIGINT COMMENT '核销时写入的订单号', " +
+                    "INDEX idx_family_coupon (family_id, coupon_id), " +
+                    "INDEX idx_status (status), " +
+                    "INDEX idx_order_id (order_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表'");
+            log.info("已创建 family_coupon 表");
+        } catch (Exception e) {
+            log.warn("family_coupon 表已存在,跳过创建: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon_grant_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '受赠家庭ID', " +
+                    "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
+                    "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE', " +
+                    "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
+                    "quantity INT DEFAULT 1 COMMENT '发放数量', " +
+                    "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period), " +
+                    "INDEX idx_coupon (coupon_id), " +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表'");
+            log.info("已创建 family_coupon_grant_log 表");
+        } catch (Exception e) {
+            log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
+        }
     }
 }

+ 39 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 
@@ -29,6 +31,9 @@ public class AdminCouponController {
     @Resource
     private CouponGrantLogMapper couponGrantLogMapper;
 
+    @Resource
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
@@ -69,16 +74,36 @@ public class AdminCouponController {
             return Result.error("无权限");
         }
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        Object userIdsObj = params.get("userIds");
-        if (userIdsObj instanceof List) {
-            List<?> userIds = (List<?>) userIdsObj;
-            for (Object uid : userIds) {
-                couponService.issueToUser(couponId, Long.valueOf(uid.toString()));
+        Object familyIdsObj = params.get("familyIds");
+        if (familyIdsObj instanceof List) {
+            List<?> familyIds = (List<?>) familyIdsObj;
+            for (Object fid : familyIds) {
+                couponService.issueToFamily(couponId, Long.valueOf(fid.toString()));
             }
         }
         return Result.success("发放成功");
     }
 
+    /** 单家庭发放(家庭管理页使用) */
+    @PostMapping("/issue-family")
+    public Result<String> issueFamily(@RequestBody Map<String, Object> params,
+                                      @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        Long couponId = ParamUtils.getLong(params.get("couponId"));
+        Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
+        if (familyId == null || couponId == null) {
+            return Result.error("参数错误");
+        }
+        if (quantity == null || quantity < 1) quantity = 1;
+        for (int i = 0; i < quantity; i++) {
+            couponService.issueToFamily(couponId, familyId);
+        }
+        return Result.success("发放成功");
+    }
+
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("role") String role) {
@@ -111,21 +136,21 @@ public class AdminCouponController {
     }
 
     @PostMapping("/grant-log")
-    public Result<List<CouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
-                                                 @RequestAttribute("role") String role) {
+    public Result<List<FamilyCouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
+                                                       @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
             return Result.error("无权限");
         }
-        Long userId = ParamUtils.getLong(params.get("userId"));
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        LambdaQueryWrapper<CouponGrantLog> wrapper = new LambdaQueryWrapper<>();
-        if (userId != null) {
-            wrapper.eq(CouponGrantLog::getUserId, userId);
+        LambdaQueryWrapper<FamilyCouponGrantLog> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(FamilyCouponGrantLog::getFamilyId, familyId);
         }
         if (couponId != null) {
-            wrapper.eq(CouponGrantLog::getCouponId, couponId);
+            wrapper.eq(FamilyCouponGrantLog::getCouponId, couponId);
         }
-        wrapper.orderByDesc(CouponGrantLog::getCreatedAt);
-        return Result.success(couponGrantLogMapper.selectList(wrapper));
+        wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
+        return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
 }

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -62,6 +62,12 @@ public class AIChatController {
     @Resource
     private com.etotem.cfc.service.PortraitService portraitService;
 
+    @Resource
+    private com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
@@ -71,6 +77,7 @@ public class AIChatController {
         String conversationId = params.get("conversationId");
         String reportIdStr = params.get("reportId");
         String surveyIdStr = params.get("surveyId");
+        String selfCheckIdStr = params.get("selfCheckId");
 
         if (query == null || query.trim().isEmpty()) {
             return Result.error("消息不能为空");
@@ -121,6 +128,23 @@ public class AIChatController {
             inputs.put("portrait_prompt", chatPortrait);
         }
 
+        // 注入自检上下文(P1-2)
+        if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) {
+            try {
+                Long selfCheckId = Long.valueOf(selfCheckIdStr);
+                com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId);
+                if (selfCheck != null) {
+                    java.util.Map<String, Object> sc = new java.util.LinkedHashMap<>();
+                    sc.put("totalScore", selfCheck.getTotalScore());
+                    sc.put("scores", selfCheck.getScoresJson());
+                    sc.put("createdAt", selfCheck.getCreatedAt() != null ? selfCheck.getCreatedAt().toString() : "");
+                    inputs.put("self_check_result", objectMapper.writeValueAsString(sc));
+                }
+            } catch (Exception e) {
+                log.warn("注入自检上下文失败: {}", e.getMessage());
+            }
+        }
+
         // AI记忆层注入(会话摘要+关键事实)
         inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
 

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java

@@ -48,4 +48,11 @@ public class DietIngredientController {
             @RequestBody Map<String, Object> request) {
         return Result.success(dietIngredientService.confirmIngredients(familyId, request));
     }
+
+    @PostMapping("/search")
+    public Result<Map> searchFoods(@RequestBody(required = false) Map<String, Object> request) {
+        String keyword = request != null && request.get("keyword") != null
+                ? request.get("keyword").toString() : "";
+        return Result.success(dietIngredientService.searchFoods(keyword));
+    }
 }

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java

@@ -41,6 +41,9 @@ public class FiveDimensionSelfCheckController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private com.etotem.cfc.service.HealthPlanService healthPlanService;
+
     @Operation(summary = "获取自检问卷题目(首次固定 15 题;再次合并题库随机抽)")
     @PostMapping("/questions")
     public Result<List<SelfCheckQuestionVO>> getQuestions(
@@ -132,4 +135,34 @@ public class FiveDimensionSelfCheckController {
                 new Date(ignoredAt.getTime() + 15L * 24 * 60 * 60 * 1000)));
         return Result.success(resp);
     }
+
+    @Operation(summary = "用户点击生成健康计划(基于自检低分维度)")
+    @PostMapping("/generate-plan")
+    public Result<Map<String, Object>> generatePlan(
+            @RequestBody(required = false) Map<String, Object> body,
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long checkId = body != null ? com.etotem.cfc.util.ParamUtils.getLong(body.get("checkId")) : null;
+        try {
+            Long planId = healthPlanService.generateFromSelfCheck(userId, checkId);
+            if (planId == null) return Result.error("计划生成失败,请稍后重试");
+            Map<String, Object> resp = new HashMap<>();
+            resp.put("planId", planId);
+            resp.put("status", "draft");
+            return Result.success(resp);
+        } catch (Exception e) {
+            return Result.error("计划生成失败");
+        }
+    }
+
+    @Operation(summary = "获取自检历史趋势分析")
+    @PostMapping("/trend-analysis")
+    public Result<Map<String, Object>> getTrendAnalysis(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        try {
+            return Result.success(selfCheckService.getTrendAnalysis(userId));
+        } catch (Exception e) {
+            return Result.error("趋势分析失败");
+        }
+    }
 }

+ 13 - 45
cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java

@@ -1,65 +1,33 @@
 package com.etotem.cfc.dto;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
+import java.util.List;
 
-/**
- * 五行相生寻源建议(P0-2 五行相生寻源建议引擎输出)
- * 对应书稿《附录-工具包汇总》附录六五行相生速查表
- */
 @Data
 public class WuxingSourcingAdviceVO {
 
-    /** 低分维度 code(body/wisdom/wealth/action/mind) */
     private String dimension;
-
-    /** 低分维度中文名(身/智/富/行/心) */
     private String dimensionName;
-
-    /** 低分维度五行(土/金/水/木/火) */
     private String element;
-
-    /** 低分维度主题色 */
     private String color;
-
-    /** 自检得分(满分9) */
     private Integer score;
-
-    /** 等级:healthy健康 / attention留意 / tense紧绷 */
     private String level;
-
-    /** 等级中文 */
     private String levelName;
 
-    /** 相生寻源:上游维度 code */
-    private String upstreamDimension;
-
-    /** 相生寻源:上游维度中文名 */
-    private String upstreamName;
-
-    /** 相生寻源:上游五行 */
-    private String upstreamElement;
-
-    /** 相生寻源:上游主题色 */
-    private String upstreamColor;
-
-    /** 相生寻源:为什么补上游更有效 */
-    private String upstreamReason;
-
-    /** 相克寻源:压制维度 code */
-    private String restrainerDimension;
-
-    /** 相克寻源:压制维度中文名 */
-    private String restrainerName;
+    /** AI 解读(替换原 upstreamReason) */
+    private String interpretation;
 
-    /** 相克寻源:压制五行 */
-    private String restrainerElement;
+    /** AI 微行动列表 */
+    private List<String> microActions;
 
-    /** 相克寻源:压制主题色 */
-    private String restrainerColor;
+    /** AI 补充洞察 */
+    private String aiInsight;
 
-    /** 相克寻源:谁在压制它 */
-    private String restrainerReason;
+    /** 家庭整体洞察(仅第一条携带) */
+    private String familyInsight;
 
-    /** 简单应对建议 */
-    private String action;
+    /** 是否使用降级静态建议 */
+    @JsonProperty("fallbackUsed")
+    private Boolean fallbackUsed;
 }

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCoupon.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_coupon")
+public class FamilyCoupon implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** 状态: AVAILABLE / USED */
+    private String status;
+
+    private Date receivedAt;
+
+    private Date usedAt;
+
+    private Long orderId;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_coupon_grant_log")
+public class FamilyCouponGrantLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE */
+    private String grantType;
+
+    /** 周期标识(YYYY-MM或YYYY-Qn), PERIODIC防重用 */
+    private String period;
+
+    private Integer quantity;
+
+    /** 触发来源(订单号/成员ID等) */
+    private String source;
+
+    private Date createdAt;
+}

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

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

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

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

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -256,6 +256,60 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 生成自检 AI 建议(调用 LangGraph self_check_analysis_graph)
+     * @return 含 "advice_json" 和 "fallback_used" 的 Map;失败返回 null
+     */
+    public Map<String, Object> generateSelfCheckAdvice(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.valueToTree(inputs);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/self-check/analysis";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("advice_json", root.has("advice_json") ? root.get("advice_json").asText() : null);
+                result.put("fallback_used", root.has("fallback_used") ? root.get("fallback_used").asBoolean() : true);
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateSelfCheckAdvice 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 生成自检趋势分析(调用 LangGraph self_check_trend_graph)
+     * @return 含 "aiInsight" 和 "trendSummary" 的 Map;失败返回 null
+     */
+    public Map<String, Object> generateSelfCheckTrend(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.valueToTree(inputs);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/self-check/trend";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("aiInsight", root.has("aiInsight") ? root.get("aiInsight").asText() : "");
+                result.put("trendSummary", root.has("trendSummary") ? root.get("trendSummary").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateSelfCheckTrend 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     private List<Map<String, Object>> parseTasks(JsonNode tasksNode) {
         List<Map<String, Object>> tasks = new ArrayList<>();
         if (tasksNode != null && tasksNode.isArray()) {

+ 19 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

@@ -559,6 +559,18 @@ public class BeijingNutritionService {
      * @return 推荐食材列表(Top 15,含推荐理由)
      */
     public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
+        return generateIngredientList(familyId, date, 0);
+    }
+
+    /**
+     * 生成食材推荐列表(支持 seed 偏移,使"换一批"真正返回不同组合)
+     *
+     * @param familyId 家庭ID
+     * @param date     日期
+     * @param seed     偏移种子(0=默认截取Top 15,>0=旋转后截取)
+     * @return 推荐食材列表(Top 15,含推荐理由)
+     */
+    public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
         List<IngredientRecommendation> result = new ArrayList<>();
         
         // 1. 查 meal_configs → participant_member_ids
@@ -711,7 +723,13 @@ public class BeijingNutritionService {
         
         // 6. 排序并返回 Top 15
         result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
-        return result.size() > 15 ? result.subList(0, 15) : result;
+        if (seed != 0 && result.size() > 1) {
+            int offset = Math.abs(seed) % result.size();
+            if (offset > 0) {
+                java.util.Collections.rotate(result, offset);
+            }
+        }
+        return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
     }
     
     private boolean isWeekend(java.time.LocalDate date) {

+ 167 - 105
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -2,11 +2,13 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
-import com.etotem.cfc.entity.UserCoupon;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CouponMapper;
-import com.etotem.cfc.mapper.UserCouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -29,26 +31,79 @@ public class CouponService {
     private CouponMapper couponMapper;
 
     @Resource
-    private UserCouponMapper userCouponMapper;
+    private FamilyCouponMapper familyCouponMapper;
 
     @Resource
-    private CouponGrantLogMapper couponGrantLogMapper;
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    @Resource
+    private UserMapper userMapper;
 
     @Resource
     @Lazy
     private MembershipService membershipService;
 
+    // ----------------------------------------------------------------
+    // 内部辅助方法
+    // ----------------------------------------------------------------
+
+    /** userId → familyId(用于查询当前用户所在家庭) */
+    private Long getFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return null;
+        }
+        return user.getFamilyId();
+    }
+
+    /** 构建与 CouponService.listMyCoupons 相同格式的响应 Map */
+    private Map<String, Object> toCouponVO(FamilyCoupon fc) {
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
+        if (coupon == null) return null;
+        Date now = new Date();
+        String status = fc.getStatus();
+        if ("AVAILABLE".equals(status) && !"ACTIVE".equals(coupon.getStatus())) {
+            status = "DISABLED";
+        } else if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
+            status = "EXPIRED";
+        }
+        Map<String, Object> item = new HashMap<>();
+        // 对外字段名保持 userCouponId / id 等原有契约,前端不感知内部实现变化
+        item.put("userCouponId", fc.getId());
+        item.put("id", fc.getId());
+        item.put("couponId", coupon.getId());
+        item.put("name", coupon.getName());
+        item.put("type", coupon.getType());
+        item.put("value", coupon.getValue());
+        item.put("discountRate", coupon.getDiscountRate());
+        item.put("minSpend", coupon.getMinSpend());
+        item.put("applicableTo", coupon.getApplicableTo());
+        item.put("validFrom", coupon.getValidFrom());
+        item.put("validUntil", coupon.getValidUntil());
+        item.put("status", status);
+        item.put("receivedAt", fc.getReceivedAt());
+        item.put("usedAt", fc.getUsedAt());
+        item.put("orderId", fc.getOrderId());
+        return item;
+    }
+
+    // ----------------------------------------------------------------
+    // 对外保留接口(签名不变,内部改为读 family_coupon)
+    // ----------------------------------------------------------------
+
     public List<Coupon> listAvailable(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
         List<Long> couponIds = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            couponIds.add(uc.getCouponId());
+        for (FamilyCoupon fc : familyCoupons) {
+            couponIds.add(fc.getCouponId());
         }
         Date now = new Date();
         return couponMapper.selectList(
@@ -62,6 +117,8 @@ public class CouponService {
 
     @Transactional
     public String claim(Long userId, Long couponId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return "未加入家庭";
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return "优惠券不存在";
@@ -80,20 +137,15 @@ public class CouponService {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
             return "优惠券已过期";
         }
-        // 非会员也可领取会员券,但在使用时会校验会员身份
-        Long count = userCouponMapper.selectCount(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getCouponId, couponId));
+        // 去重:同一个家庭同一张券只发一次(同 grantType 的幂等也通过 grant 方法保证)
+        Long count = familyCouponMapper.selectCount(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getCouponId, couponId));
         if (count != null && count > 0) {
             return "已领取过该优惠券";
         }
-        UserCoupon uc = new UserCoupon();
-        uc.setUserId(userId);
-        uc.setCouponId(couponId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+        issueToFamily(couponId, familyId);
         coupon.setUsedCount(coupon.getUsedCount() == null ? 1 : coupon.getUsedCount() + 1);
         couponMapper.updateById(coupon);
         return "领取成功";
@@ -109,11 +161,13 @@ public class CouponService {
     }
 
     public Integer apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc == null || !uc.getUserId().equals(userId) || !"AVAILABLE".equals(uc.getStatus())) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return null;
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc == null || !fc.getFamilyId().equals(familyId) || !"AVAILABLE".equals(fc.getStatus())) {
             return null;
         }
-        Coupon coupon = couponMapper.selectById(uc.getCouponId());
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
         if (coupon == null) {
             return null;
         }
@@ -149,23 +203,23 @@ public class CouponService {
 
     @Transactional
     public void markUsed(Long userCouponId, Long orderId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("USED");
-            uc.setUsedAt(new Date());
-            uc.setOrderId(orderId);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("USED");
+            fc.setUsedAt(new Date());
+            fc.setOrderId(orderId);
+            familyCouponMapper.updateById(fc);
         }
     }
 
     @Transactional
     public void revert(Long userCouponId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("AVAILABLE");
-            uc.setUsedAt(null);
-            uc.setOrderId(null);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("AVAILABLE");
+            fc.setUsedAt(null);
+            fc.setOrderId(null);
+            familyCouponMapper.updateById(fc);
         }
     }
 
@@ -177,22 +231,23 @@ public class CouponService {
         return couponMapper.selectById(couponId);
     }
 
+    /** 单条发放到家庭(替代旧 issueToUser) */
     @Transactional
-    public void issueToUser(Long couponId, Long userId) {
-        UserCoupon uc = new UserCoupon();
-        uc.setCouponId(couponId);
-        uc.setUserId(userId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+    public void issueToFamily(Long couponId, Long familyId) {
+        FamilyCoupon fc = new FamilyCoupon();
+        fc.setCouponId(couponId);
+        fc.setFamilyId(familyId);
+        fc.setStatus("AVAILABLE");
+        fc.setReceivedAt(new Date());
+        familyCouponMapper.insert(fc);
     }
 
     /**
-     * 发放优惠券并落流水(幂等)。
+     * 发放优惠券并落流水(幂等,家庭维度)。
      * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
      */
     @Transactional
-    public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
+    public boolean grantFamily(Long familyId, Long couponId, String grantType, String period, String source) {
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return false;
@@ -201,12 +256,12 @@ public class CouponService {
             return false;
         }
         if (period != null) {
-            Long exists = couponGrantLogMapper.selectCount(
-                    new LambdaQueryWrapper<CouponGrantLog>()
-                            .eq(CouponGrantLog::getUserId, userId)
-                            .eq(CouponGrantLog::getCouponId, couponId)
-                            .eq(CouponGrantLog::getGrantType, grantType)
-                            .eq(CouponGrantLog::getPeriod, period));
+            Long exists = familyCouponGrantLogMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyCouponGrantLog>()
+                            .eq(FamilyCouponGrantLog::getFamilyId, familyId)
+                            .eq(FamilyCouponGrantLog::getCouponId, couponId)
+                            .eq(FamilyCouponGrantLog::getGrantType, grantType)
+                            .eq(FamilyCouponGrantLog::getPeriod, period));
             if (exists != null && exists > 0) {
                 return false;
             }
@@ -214,46 +269,64 @@ public class CouponService {
         int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
                 ? coupon.getGrantQuantity() : 1;
         for (int i = 0; i < quantity; i++) {
-            issueToUser(couponId, userId);
+            issueToFamily(couponId, familyId);
         }
-        CouponGrantLog log = new CouponGrantLog();
-        log.setUserId(userId);
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(familyId);
         log.setCouponId(couponId);
         log.setGrantType(grantType);
         log.setPeriod(period);
         log.setQuantity(quantity);
         log.setSource(source);
-        couponGrantLogMapper.insert(log);
+        familyCouponGrantLogMapper.insert(log);
         return true;
     }
 
     /** JOIN: 开通/续费指定等级会员时发放 */
     @Transactional
-    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+    public void grantFamilyJoinCoupons(Long familyId, String levelCode, String source) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getGrantType, "JOIN")
                         .eq(Coupon::getGrantLevelCode, levelCode)
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         if (coupons.isEmpty()) {
-            logger.warn("未找到 JOIN 赠券模板: userId={}, levelCode={}", userId, levelCode);
+            logger.warn("未找到 JOIN 赠券模板: familyId={}, levelCode={}", familyId, levelCode);
             return;
         }
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "JOIN", "JOIN", source);
+            grantFamily(familyId, c.getId(), "JOIN", "JOIN", source);
+        }
+        logger.info("发放 JOIN 赠券完成: familyId={}, levelCode={}, count={}", familyId, levelCode, coupons.size());
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyJoinCoupons(familyId, levelCode, source);
         }
-        logger.info("发放 JOIN 赠券完成: userId={}, levelCode={}, count={}", userId, levelCode, coupons.size());
     }
 
-    /** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
+    /** POPULATION: 新增家庭成员时发放 */
     @Transactional
-    public void grantPopulationCoupons(Long userId, Long memberId) {
+    public void grantFamilyPopulationCoupons(Long familyId, Long memberId) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getGrantType, "POPULATION")
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
+            grantFamily(familyId, c.getId(), "POPULATION", null, "member:" + memberId);
+        }
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantPopulationCoupons(Long userId, Long memberId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyPopulationCoupons(familyId, memberId);
         }
     }
 
@@ -291,16 +364,18 @@ public class CouponService {
     }
 
     /**
-     * 查询用户尚未拥有的可兑换优惠券(用于结算页直接兑换)
+     * 查询家庭尚未拥有的可兑换优惠券(用于结算页直接兑换)
      * @param productId 商品ID,非空时只返回该商品绑定的券
      */
     public List<Coupon> listUnownedExchangeable(Long userId, Long productId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
         Date now = new Date();
-        List<Long> ownedIds = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"))
-                .stream().map(UserCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
+        List<Long> ownedIds = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"))
+                .stream().map(FamilyCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
         LambdaQueryWrapper<Coupon> wrapper = new LambdaQueryWrapper<Coupon>()
                 .gt(Coupon::getPointsPrice, 0)
                 .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
@@ -322,45 +397,32 @@ public class CouponService {
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
     }
 
-    /** 我的优惠券:返回 user_coupon + coupon 联合数据,含 status/receivedAt/usedAt */
+    /**
+     * 我的优惠券:返回 family_coupon + coupon 联合数据,含 status/receivedAt/usedAt
+     * 对外字段名保持不变(userCouponId = family_coupon.id),前端不感知内部变化
+     */
     public List<Map<String, Object>> listMyCoupons(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .orderByDesc(UserCoupon::getReceivedAt));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .orderByDesc(FamilyCoupon::getReceivedAt));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
-        Date now = new Date();
         List<Map<String, Object>> result = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            Coupon coupon = couponMapper.selectById(uc.getCouponId());
-            if (coupon == null) continue;
-            // 过期判断
-            String status = uc.getStatus();
-            if ("AVAILABLE".equals(status) && !"ACTIVE".equals(coupon.getStatus())) {
-                status = "DISABLED";
-            } else if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
-                status = "EXPIRED";
-            }
-            Map<String, Object> item = new HashMap<>();
-            item.put("userCouponId", uc.getId());
-            item.put("id", uc.getId());
-            item.put("couponId", coupon.getId());
-            item.put("name", coupon.getName());
-            item.put("type", coupon.getType());
-            item.put("value", coupon.getValue());
-            item.put("discountRate", coupon.getDiscountRate());
-            item.put("minSpend", coupon.getMinSpend());
-            item.put("applicableTo", coupon.getApplicableTo());
-            item.put("validFrom", coupon.getValidFrom());
-            item.put("validUntil", coupon.getValidUntil());
-            item.put("status", status);
-            item.put("receivedAt", uc.getReceivedAt());
-            item.put("usedAt", uc.getUsedAt());
-            item.put("orderId", uc.getOrderId());
-            result.add(item);
+        for (FamilyCoupon fc : familyCoupons) {
+            Map<String, Object> vo = toCouponVO(fc);
+            if (vo != null) result.add(vo);
         }
         return result;
     }
+
+    /**
+     * 查询指定会员等级的活跃家庭 ID 列表(供 CouponGrantTask 周期补发)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        return membershipService.listActiveMemberFamilyIds(levelCode);
+    }
 }

+ 39 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.dto.IngredientRecommendation;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -15,6 +17,9 @@ public class DietIngredientService {
     @Resource
     private BeijingNutritionService beijingNutritionService;
 
+    @Resource
+    private FoodMapper foodMapper;
+
     // 会话内食材选择(按 familyId 隔离)
     private final Map<Long, List<IngredientRecommendation>> selectedIngredients = new HashMap<>();
     private final Map<Long, Integer> refreshSeed = new HashMap<>();
@@ -33,11 +38,11 @@ public class DietIngredientService {
     public Map<String, Object> refreshIngredients(Long familyId) {
         Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
         refreshSeed.put(familyId, seed);
-        
-        // 重新生成(通过随机种子变化
-        List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now());
+
+        // 重新生成(通过 seed 偏移截取,真正换一批
+        List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
         selectedIngredients.put(familyId, suggestions);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
         result.put("selectedCount", 0);
@@ -88,4 +93,34 @@ public class DietIngredientService {
         
         return result;
     }
+
+    /**
+     * 搜索食材(供"添加食材"弹窗使用)
+     *
+     * @param keyword 关键字(空=返回全部,最多50条)
+     * @return {foods:[{id,name,category}]}
+     */
+    public Map<String, Object> searchFoods(String keyword) {
+        List<Map<String, Object>> foods = new ArrayList<>();
+        List<Food> all = foodMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Food>()
+                        .eq(Food::getStatus, "active")
+                        .orderByAsc(Food::getSortOrder)
+        );
+        String kw = keyword == null ? "" : keyword.trim();
+        int count = 0;
+        for (Food f : all) {
+            if (kw.isEmpty() || (f.getName() != null && f.getName().contains(kw))) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", f.getId());
+                item.put("name", f.getName());
+                item.put("category", f.getCategory());
+                foods.add(item);
+                if (++count >= 50) break;
+            }
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("foods", foods);
+        return result;
+    }
 }

+ 193 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -2,7 +2,11 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MealConfig;
 import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -24,6 +28,12 @@ public class DietRecommendationService {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private MealConfigMapper mealConfigMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr) {
         LocalDate date = LocalDate.parse(dateStr);
         
@@ -57,36 +67,53 @@ public class DietRecommendationService {
         String dateStr = request.get("date");
         String mealType = request.get("meal_type");
         LocalDate date = LocalDate.parse(dateStr);
-        
-        // 查询共餐配置
-        List<Long> participantIds = new ArrayList<>();
-        // 这里需要从 meal_configs 表查询,简化处理
-        
-        // 生成食材推荐
-        List<com.etotem.cfc.dto.IngredientRecommendation> ingredients = 
-            beijingNutritionService.generateIngredientList(familyId, date);
-        
-        // 调用 AI 生成菜单
-        String selectedFoodsJson = "[]";
+
+        // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
+        String selectedFoodsJson = request.get("selected_foods");
+        if (selectedFoodsJson == null || selectedFoodsJson.trim().isEmpty()) {
+            selectedFoodsJson = "[]";
+        }
+
+        // 2. 查询共餐参与者
+        List<Long> participantIds = queryParticipantIds(familyId, date);
+
+        // 3. 调用 AI 生成菜单(参与者数量至少 1)
         String participantsJson = "[]";
+        if (!participantIds.isEmpty()) {
+            try {
+                participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
+            } catch (Exception ignore) {}
+        }
         String menuJson = aiGateway.generateMenu(selectedFoodsJson, participantsJson, dateStr);
-        
-        // 保存推荐
+
+        // 4. AI 失败时降级:用推荐食材拼一个最小菜单,避免空壳
+        if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
+            menuJson = buildFallbackMenu(familyId, date, participantIds.size());
+        }
+
+        // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
+        Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
+        String normalizedMenuJson = safeWrite(normalized);
+        Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
+
+        // 6. 保存推荐
         DietRecommendation record = new DietRecommendation();
         record.setFamilyId(familyId);
         record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
         record.setMealType(mealType != null ? mealType : "all");
-        record.setParticipantMemberIds(new ArrayList<>(participantIds).toString());
-        record.setMenuJson(menuJson != null ? menuJson : "{}");
+        record.setParticipantMemberIds(participantIds.toString());
+        record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
+        record.setNutritionSummary(safeWrite(nutritionSummary));
         record.setStatus("pending");
         record.setVersion(1);
         record.setCreatedAt(new Date());
         record.setUpdatedAt(new Date());
         dietRecommendationMapper.insert(record);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("id", record.getId());
         result.put("menu", record.getMenuJson());
+        result.put("nutritionSummary", record.getNutritionSummary());
         result.put("message", "食谱推荐已生成");
         return result;
     }
@@ -154,4 +181,154 @@ public class DietRecommendationService {
             dietRecommendationMapper.updateById(record);
         }
     }
+
+    // ============================================================
+    // 内部辅助
+    // ============================================================
+
+    /** 查询共餐参与者(按工作日/周末的 meal_configs,未配置则降级为全部家庭成员) */
+    private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
+        boolean weekend = date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY
+                || date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+        String dateType = weekend ? "weekend" : "weekday";
+        Set<Long> ids = new HashSet<>();
+        for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
+            LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
+            w.eq(MealConfig::getFamilyId, familyId)
+             .eq(MealConfig::getConfigDateType, dateType)
+             .eq(MealConfig::getMealType, mealType);
+            MealConfig config = mealConfigMapper.selectOne(w);
+            if (config != null && config.getParticipantMemberIds() != null) {
+                try {
+                    com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper()
+                            .readTree(config.getParticipantMemberIds());
+                    if (nodes.isArray()) {
+                        for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
+                    }
+                } catch (Exception ignore) {}
+            }
+        }
+        if (ids.isEmpty()) {
+            LambdaQueryWrapper<FamilyMember> w = new LambdaQueryWrapper<>();
+            w.eq(FamilyMember::getFamilyId, familyId);
+            List<FamilyMember> members = familyMemberMapper.selectList(w);
+            for (FamilyMember m : members) ids.add(m.getId());
+        }
+        return new ArrayList<>(ids);
+    }
+
+    /** 归一化 LangGraph 菜单 → 前端期望结构(dish.calories / ingredients[].amount / method) */
+    private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> meals = new ArrayList<>();
+        result.put("meals", meals);
+        if (menuJson == null || menuJson.trim().isEmpty()) return result;
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
+            com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
+            if (mealsNode != null && mealsNode.isArray()) {
+                for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
+                    Map<String, Object> meal = new LinkedHashMap<>();
+                    meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
+                    meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
+                    meal.put("participants", participantCount > 0 ? participantCount : 1);
+                    List<Map<String, Object>> dishes = new ArrayList<>();
+                    com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
+                    if (dishesNode != null && dishesNode.isArray()) {
+                        for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
+                            Map<String, Object> dish = new LinkedHashMap<>();
+                            dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
+                            com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
+                            dish.put("calories", nutrition != null && nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
+                            List<Map<String, Object>> ingredients = new ArrayList<>();
+                            com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
+                            if (ingNode != null && ingNode.isArray()) {
+                                for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
+                                    Map<String, Object> item = new LinkedHashMap<>();
+                                    item.put("name", ing.has("name") ? ing.get("name").asText() : "");
+                                    item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
+                                    ingredients.add(item);
+                                }
+                            }
+                            dish.put("ingredients", ingredients);
+                            dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
+                            dishes.add(dish);
+                        }
+                    }
+                    meal.put("dishes", dishes);
+                    meals.add(meal);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("归一化菜单失败: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /** 聚合营养汇总(目前聚合热量,其余字段置 0,供前端 4 宫格渲染) */
+    private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
+        Map<String, Object> summary = new LinkedHashMap<>();
+        int calories = 0;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
+        if (meals != null) {
+            for (Map<String, Object> meal : meals) {
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
+                if (dishes == null) continue;
+                for (Map<String, Object> dish : dishes) {
+                    Object cal = dish.get("calories");
+                    if (cal instanceof Number) calories += ((Number) cal).intValue();
+                }
+            }
+        }
+        summary.put("calories", calories);
+        summary.put("protein", 0);
+        summary.put("carbs", 0);
+        summary.put("fat", 0);
+        return summary;
+    }
+
+    /** AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单 */
+    private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
+        List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
+                beijingNutritionService.generateIngredientList(familyId, date);
+        String[] mealTypes = {"breakfast", "lunch", "dinner"};
+        String[] mealNames = {"早餐", "午餐", "晚餐"};
+        Map<String, Object> root = new LinkedHashMap<>();
+        List<Map<String, Object>> meals = new ArrayList<>();
+        for (int i = 0; i < mealTypes.length; i++) {
+            Map<String, Object> meal = new LinkedHashMap<>();
+            meal.put("type", mealTypes[i]);
+            meal.put("name", mealNames[i]);
+            meal.put("participants", participantCount > 0 ? participantCount : 1);
+            List<Map<String, Object>> dishes = new ArrayList<>();
+            Map<String, Object> dish = new LinkedHashMap<>();
+            dish.put("name", "营养餐");
+            dish.put("calories", 0);
+            List<Map<String, Object>> dishIngredients = new ArrayList<>();
+            for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("name", ingredients.get(j).getName());
+                item.put("amount", 100);
+                dishIngredients.add(item);
+            }
+            dish.put("ingredients", dishIngredients);
+            dish.put("method", "建议以清淡为主");
+            dishes.add(dish);
+            meal.put("dishes", dishes);
+            meals.add(meal);
+        }
+        root.put("meals", meals);
+        return safeWrite(root);
+    }
+
+    private String safeWrite(Object obj) {
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
 }

+ 2 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -153,15 +153,9 @@ public class FamilyMemberService {
         // 为新成员创建与所有现有成员的双向关系
         familyRelationshipService.createRelationsForNewMember(familyId, member.getId(), genLevel.getOffset());
 
-        // 发放家庭人口券(POPULATION):有账号发到成员自己,无账号发到家庭创建者
+        // 发放家庭人口券(POPULATION):发到家庭维度
         try {
-            Long targetUserId = member.getUserId();
-            if (targetUserId == null) {
-                targetUserId = user.getFamilyId() != null
-                        ? familyMapper.selectById(user.getFamilyId()).getCreatorId()
-                        : userId;
-            }
-            couponService.grantPopulationCoupons(targetUserId, member.getId());
+            couponService.grantFamilyPopulationCoupons(familyId, member.getId());
         } catch (Exception e) {
             log.error("发放家庭人口券异常: memberId={}", member.getId(), e);
         }

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -267,8 +267,8 @@ public class FamilyPlatformPointsService {
         spend(familyId, pointsPrice, "coupon_exchange", couponId,
               "兑换优惠券: " + (coupon.getName() != null ? coupon.getName() : "优惠券"));
 
-        // 5. 发券给操作用户
-        couponService.grant(refUserId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
+        // 5. 发券给家庭
+        couponService.grantFamily(familyId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
 
         // 6. 落兑换记录
         FamilyPlatformExchangeRecord record = new FamilyPlatformExchangeRecord();

+ 40 - 18
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java

@@ -35,7 +35,7 @@ import com.etotem.cfc.util.SortUtil;
  * 对应书稿《终章-绘制你家的幸福全景图》15题自检问卷
  *
  * 计分:A=3分,B=2分,C=1分,D=0分,每维度 3 题满分 9 分
- * 结果解读:≥7 健康 / 4-6 留意 / ≤3 紧绷(紧绷维度触发五行相生寻源建议)
+ * 结果解读:≥7 健康 / 4-6 留意 / ≤3 紧绷(紧绷维度触发 AI 建议)
  */
 @Service
 public class FiveDimensionSelfCheckService {
@@ -57,6 +57,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     private SelfCheckIgnoreMapper ignoreMapper;
 
+    @Resource
+    private SelfCheckAnalysisService selfCheckAnalysisService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     /** 维度中文名与五行 */
@@ -467,12 +470,18 @@ public class FiveDimensionSelfCheckService {
         }
         Collections.sort(submittedQuestionIds);
 
-        // 生成寻源建议(仅 ≤3 紧绷维度
+        // 生成 AI 建议(AI 优先,失败降级为空建议
         Map<String, Integer> scoreMap = new LinkedHashMap<>();
         for (SelfCheckResultVO.DimensionScoreVO dvo : dimensions) {
             scoreMap.put(dvo.getDimension(), dvo.getScore());
         }
-        List<WuxingSourcingAdviceVO> advices = wuxingSourcingService.getAdvicesForLowScores(scoreMap);
+        String adviceJson = null;
+        try {
+            Map<String, Object> adviceResult = selfCheckAnalysisService.generateAdvice(userId, scoreMap, submittedQuestionIds);
+            adviceJson = (String) adviceResult.get("adviceJson");
+        } catch (Exception e) {
+            log.warn("AI 建议生成异常,使用空建议: {}", e.getMessage());
+        }
 
         // 保存记录
         FiveDimensionSelfCheck record = new FiveDimensionSelfCheck();
@@ -481,7 +490,7 @@ public class FiveDimensionSelfCheckService {
         record.setAnswersJson(toJson(dto.getAnswers()));
         record.setScoresJson(toJson(scoreMap));
         record.setTotalScore(totalScore);
-        record.setAdviceJson(toJson(advices));
+        record.setAdviceJson(adviceJson != null ? adviceJson : "[]");
         record.setQuestionIdsJson(toJson(submittedQuestionIds));
         record.setCreatedAt(new Date());
         selfCheckMapper.insert(record);
@@ -499,7 +508,7 @@ public class FiveDimensionSelfCheckService {
         vo.setFamilyMemberId(record.getFamilyMemberId());
         vo.setDimensions(dimensions);
         vo.setTotalScore(totalScore);
-        vo.setAdvices(advices);
+        vo.setAdvices(parseAdvices(adviceJson));
         vo.setCreatedAt(record.getCreatedAt());
         return vo;
     }
@@ -602,6 +611,18 @@ public class FiveDimensionSelfCheckService {
         return ignore.getCreatedAt();
     }
 
+    /**
+     * 获取自检趋势分析结果(含历史列表)
+     */
+    public Map<String, Object> getTrendAnalysis(Long userId) {
+        Map<String, Object> trend = selfCheckAnalysisService.generateTrend(userId);
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("history", selfCheckAnalysisService.getRecentHistory(userId, 3));
+        resp.put("aiInsight", trend.getOrDefault("aiInsight", ""));
+        resp.put("trendSummary", trend.getOrDefault("trendSummary", ""));
+        return resp;
+    }
+
     // === 内部方法 ===
 
     private SelfCheckResultVO.DimensionScoreVO buildDimensionScoreVO(String dimension, int score) {
@@ -666,6 +687,19 @@ public class FiveDimensionSelfCheckService {
         }
     }
 
+    private List<WuxingSourcingAdviceVO> parseAdvices(String adviceJson) {
+        if (adviceJson == null || adviceJson.isEmpty() || "[]".equals(adviceJson)) {
+            return new ArrayList<>();
+        }
+        try {
+            return objectMapper.readValue(adviceJson,
+                    objectMapper.getTypeFactory().constructCollectionType(List.class, WuxingSourcingAdviceVO.class));
+        } catch (Exception e) {
+            log.warn("解析 AI 建议 JSON 失败: {}", e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
     @SuppressWarnings("unchecked")
     private SelfCheckResultVO toVO(FiveDimensionSelfCheck record) {
         SelfCheckResultVO vo = new SelfCheckResultVO();
@@ -694,19 +728,7 @@ public class FiveDimensionSelfCheckService {
         vo.setDimensions(dimensions);
 
         // 解析寻源建议
-        if (record.getAdviceJson() != null) {
-            try {
-                List<WuxingSourcingAdviceVO> advices = objectMapper.readValue(
-                        record.getAdviceJson(),
-                        objectMapper.getTypeFactory().constructCollectionType(List.class, WuxingSourcingAdviceVO.class));
-                vo.setAdvices(advices);
-            } catch (Exception e) {
-                log.warn("解析adviceJson失败: {}", e.getMessage());
-                vo.setAdvices(new ArrayList<>());
-            }
-        } else {
-            vo.setAdvices(new ArrayList<>());
-        }
+        vo.setAdvices(parseAdvices(record.getAdviceJson()));
         return vo;
     }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -35,4 +35,7 @@ public interface HealthPlanService {
 
     /** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
     List<Task> getPlanTasks(Long planId);
+
+    /** 基于自检低分维度自动生成 draft 计划 */
+    Long generateFromSelfCheck(Long userId, Long checkId);
 }

+ 21 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -775,7 +775,7 @@ public class MembershipService implements MembershipServiceInterface {
 
             // 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
             try {
-                couponService.grantJoinCoupons(adminUserId, order.getLevelCode(), order.getOrderNo());
+                couponService.grantFamilyJoinCoupons(order.getFamilyId(), order.getLevelCode(), order.getOrderNo());
             } catch (Exception e) {
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
             }
@@ -995,6 +995,25 @@ public class MembershipService implements MembershipServiceInterface {
         return userIds.stream().distinct().collect(Collectors.toList());
     }
 
+    /**
+     * 查询指定会员等级当前有效的家庭 ID 列表(PERIODIC 发券用,按家庭维度)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        Date now = new Date();
+        List<FamilyMembership> memberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getLevelCode, levelCode)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, now));
+        List<Long> familyIds = new ArrayList<>();
+        for (FamilyMembership m : memberships) {
+            if (m.getFamilyId() != null) {
+                familyIds.add(m.getFamilyId());
+            }
+        }
+        return familyIds.stream().distinct().collect(Collectors.toList());
+    }
+
     /**
      * 应用会员折扣,返回折后价格(分)
      */
@@ -1095,7 +1114,7 @@ public class MembershipService implements MembershipServiceInterface {
         memberUpgradeRecordMapper.insert(record);
         // 发放JOIN会员券(赠送会员同样享有加入赠券)
         try {
-            couponService.grantJoinCoupons(adminUserId, levelCode, sourceOrderNo);
+            couponService.grantFamilyJoinCoupons(familyId, levelCode, sourceOrderNo);
         } catch (Exception e) {
             log.error("发放JOIN会员券异常(赠送): userId={}, levelCode={}", adminUserId, levelCode, e);
         }

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java

@@ -275,8 +275,12 @@ public class PointsExchangeService {
             throw new RuntimeException("积分不足");
         }
 
-        // 发券 + 流水(EXCHANGE,period=null)
-        couponService.grant(userId, couponId, "EXCHANGE", null, null);
+        // 发券 + 流水(EXCHANGE,period=null)— 发到用户所在家庭
+        Long targetFamilyId = null;
+        FamilyMember targetMember = familyMemberMapper.selectById(targetFamilyMemberId);
+        if (targetMember != null) targetFamilyId = targetMember.getFamilyId();
+        if (targetFamilyId == null) throw new RuntimeException("未找到目标成员所在家庭");
+        couponService.grantFamily(targetFamilyId, couponId, "EXCHANGE", null, null);
 
         String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
         PointsExchangeRecord record = new PointsExchangeRecord();

+ 148 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java

@@ -0,0 +1,148 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class SelfCheckAnalysisService {
+
+    private static final Logger log = LoggerFactory.getLogger(SelfCheckAnalysisService.class);
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 生成自检建议(AI 优先,失败返回 adviceJson=null + fallbackUsed=true)
+     */
+    public Map<String, Object> generateAdvice(Long userId, Map<String, Integer> scoreMap, List<Integer> questionIds) {
+        try {
+            List<Map<String, Object>> recentHistory = getRecentHistory(userId, 3);
+            Map<String, Object> scoresWithMeta = new LinkedHashMap<>();
+            for (Map.Entry<String, Integer> entry : scoreMap.entrySet()) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("dimension", entry.getKey());
+                m.put("dimensionName", getDimensionName(entry.getKey()));
+                m.put("score", entry.getValue());
+                scoresWithMeta.put(entry.getKey(), m);
+            }
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("scores", scoresWithMeta);
+            inputs.put("questionIds", questionIds);
+            inputs.put("userId", userId);
+            inputs.put("recentHistory", recentHistory);
+            Map<String, Object> result = aiGateway.generateSelfCheckAdvice(inputs);
+            if (result == null) {
+                log.info("AI 自检建议生成失败,fallback");
+                Map<String, Object> fallback = new LinkedHashMap<>();
+                fallback.put("adviceJson", null);
+                fallback.put("fallbackUsed", true);
+                return fallback;
+            }
+            String adviceJson = (String) result.get("advice_json");
+            Boolean fallbackUsed = (Boolean) result.getOrDefault("fallback_used", false);
+            Map<String, Object> advResult = new LinkedHashMap<>();
+            advResult.put("adviceJson", adviceJson);
+            advResult.put("fallbackUsed", fallbackUsed);
+            return advResult;
+        } catch (Exception e) {
+            log.warn("自检建议生成异常: {}", e.getMessage());
+            Map<String, Object> fallback = new LinkedHashMap<>();
+            fallback.put("adviceJson", null);
+            fallback.put("fallbackUsed", true);
+            return fallback;
+        }
+    }
+
+    /**
+     * 生成趋势分析(AI 优先,失败返回空 insight)
+     */
+    public Map<String, Object> generateTrend(Long userId) {
+        try {
+            List<Map<String, Object>> history = getRecentHistory(userId, 3);
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("history", history);
+            inputs.put("userId", userId);
+            Map<String, Object> result = aiGateway.generateSelfCheckTrend(inputs);
+            if (result == null) {
+                Map<String, Object> empty = new LinkedHashMap<>();
+                empty.put("aiInsight", "");
+                empty.put("trendSummary", "");
+                return empty;
+            }
+            Map<String, Object> trendResult = new LinkedHashMap<>();
+            trendResult.put("aiInsight", result.getOrDefault("aiInsight", ""));
+            trendResult.put("trendSummary", result.getOrDefault("trendSummary", ""));
+            return trendResult;
+        } catch (Exception e) {
+            log.warn("趋势分析异常: {}", e.getMessage());
+            Map<String, Object> empty = new LinkedHashMap<>();
+            empty.put("aiInsight", "");
+            empty.put("trendSummary", "");
+            return empty;
+        }
+    }
+
+    /** 获取最近 N 次自检历史(供 AI 和前端使用) */
+    public List<Map<String, Object>> getRecentHistory(Long userId, int limit) {
+        try {
+            List<FiveDimensionSelfCheck> records = selfCheckMapper.selectList(
+                    new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                            .eq(FiveDimensionSelfCheck::getUserId, userId)
+                            .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                            .last("LIMIT " + limit)
+            );
+            List<Map<String, Object>> result = new ArrayList<>();
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            for (FiveDimensionSelfCheck r : records) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("createdAt", r.getCreatedAt() != null ? sdf.format(r.getCreatedAt()) : "");
+                m.put("totalScore", r.getTotalScore());
+                if (r.getScoresJson() != null) {
+                    try {
+                        Map<String, Integer> scores = objectMapper.readValue(r.getScoresJson(),
+                                new TypeReference<Map<String, Integer>>() {});
+                        List<Map<String, Object>> dims = new ArrayList<>();
+                        for (Map.Entry<String, Integer> e : scores.entrySet()) {
+                            Map<String, Object> d = new LinkedHashMap<>();
+                            d.put("dimension", e.getKey());
+                            d.put("name", getDimensionName(e.getKey()));
+                            d.put("score", e.getValue());
+                            dims.add(d);
+                        }
+                        m.put("dimensions", dims);
+                    } catch (Exception ignored) {}
+                }
+                result.add(m);
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("获取自检历史失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private String getDimensionName(String dim) {
+        switch (dim) {
+            case "body": return "身";
+            case "wisdom": return "智";
+            case "wealth": return "富";
+            case "action": return "行";
+            case "mind": return "心";
+            default: return dim;
+        }
+    }
+}

+ 2 - 109
cfc-backend/src/main/java/com/etotem/cfc/service/WuxingSourcingService.java

@@ -1,25 +1,15 @@
 package com.etotem.cfc.service;
 
-import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
 import org.springframework.stereotype.Service;
 
-import java.util.ArrayList;
 import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
 
 /**
- * 五行相生寻源建议引擎(P0-2)
- * 对应书稿《附录-工具包汇总》附录六「五行相生速查表(主动健康寻源表)」
- *
- * 核心逻辑:
- * 1. 当一个维度亮红灯(分数低)时,先别急着只补它,用两步寻源:
- *    - 相生寻源:看它的上游(谁生它)——是不是上游滋养不足?
- *    - 相克寻源:看谁在压制它——是不是哪个维度过度膨胀?
- * 2. 依据速查表输出:上游维度、压制维度、简单应对
+ * 五行维度元信息工具(P0-2)
+ * 保留维度元数据、等级计算等基础工具方法,寻源逻辑已迁移至 AI 服务。
  *
  * 五行相生:身(土)→智(金)→富(水)→行(木)→心(火)→身
- * 五行相克:身克富、智克行、富克心、行克身、心克智
  */
 @Service
 public class WuxingSourcingService {
@@ -34,103 +24,6 @@ public class WuxingSourcingService {
         DIMENSION_META.put("mind", new String[]{"心", "火", "#FF6B9D"});
     }
 
-    /**
-     * 寻源表(附录六):
-     * 症状维度 → [上游维度, 上游原因, 压制维度, 压制原因, 简单应对]
-     */
-    private static final Map<String, String[]> SOURCING_TABLE = new LinkedHashMap<>();
-    static {
-        SOURCING_TABLE.put("body", new String[]{
-                "mind", "情绪安全了,身体才有行动力",
-                "action", "老好人透支自己,累垮身体",
-                "情绪停机坪;先照顾自己,再照顾别人"
-        });
-        SOURCING_TABLE.put("wisdom", new String[]{
-                "body", "身体状态好,学习效率才高",
-                "mind", "爱太多变成控制,压制独立思考",
-                "睡够再学;给孩子留白"
-        });
-        SOURCING_TABLE.put("wealth", new String[]{
-                "wisdom", "能力上去了,财富才跟着来",
-                "body", "用健康换钱,本末倒置",
-                "先投资自己;别省体检钱"
-        });
-        SOURCING_TABLE.put("action", new String[]{
-                "wealth", "经济宽裕了,才有余力经营关系",
-                "wisdom", "算得太清,关系变冷",
-                "先理财务;少算多听"
-        });
-        SOURCING_TABLE.put("mind", new String[]{
-                "action", "关系顺畅了,内心才安定",
-                "wealth", "钱多了,情薄了",
-                "关系周记;家庭夜谈"
-        });
-    }
-
-    /**
-     * 为单个低分维度生成寻源建议
-     *
-     * @param dimension 维度 code
-     * @param score     自检得分(0-9,可为 null)
-     * @param level     等级(healthy/attention/tense,可为 null,由调用方传入)
-     * @return 寻源建议;维度不存在时返回 null
-     */
-    public WuxingSourcingAdviceVO getAdvice(String dimension, Integer score, String level) {
-        String[] meta = DIMENSION_META.get(dimension);
-        String[] row = SOURCING_TABLE.get(dimension);
-        if (meta == null || row == null) {
-            return null;
-        }
-        WuxingSourcingAdviceVO vo = new WuxingSourcingAdviceVO();
-        vo.setDimension(dimension);
-        vo.setDimensionName(meta[0]);
-        vo.setElement(meta[1]);
-        vo.setColor(meta[2]);
-        vo.setScore(score);
-        vo.setLevel(level != null ? level : (score == null ? null : levelOf(score)));
-        vo.setLevelName(levelName(vo.getLevel()));
-
-        String upstream = row[0];
-        String restrainer = row[2];
-        vo.setUpstreamDimension(upstream);
-        vo.setUpstreamName(DIMENSION_META.get(upstream)[0]);
-        vo.setUpstreamElement(DIMENSION_META.get(upstream)[1]);
-        vo.setUpstreamColor(DIMENSION_META.get(upstream)[2]);
-        vo.setUpstreamReason(row[1]);
-
-        vo.setRestrainerDimension(restrainer);
-        vo.setRestrainerName(DIMENSION_META.get(restrainer)[0]);
-        vo.setRestrainerElement(DIMENSION_META.get(restrainer)[1]);
-        vo.setRestrainerColor(DIMENSION_META.get(restrainer)[2]);
-        vo.setRestrainerReason(row[3]);
-
-        vo.setAction(row[4]);
-        return vo;
-    }
-
-    /**
-     * 为多个低分维度批量生成寻源建议
-     *
-     * @param scoreMap 维度 code → 得分(0-9)
-     * @return 所有分数≤3(紧绷)维度的寻源建议列表
-     */
-    public List<WuxingSourcingAdviceVO> getAdvicesForLowScores(Map<String, Integer> scoreMap) {
-        List<WuxingSourcingAdviceVO> result = new ArrayList<>();
-        if (scoreMap == null) {
-            return result;
-        }
-        for (Map.Entry<String, Integer> entry : scoreMap.entrySet()) {
-            Integer score = entry.getValue();
-            if (score != null && score <= 3) {
-                WuxingSourcingAdviceVO vo = getAdvice(entry.getKey(), score, "tense");
-                if (vo != null) {
-                    result.add(vo);
-                }
-            }
-        }
-        return result;
-    }
-
     /**
      * 分数 → 等级:≥7 健康 / 4-6 留意 / ≤3 紧绷
      */

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -3,12 +3,14 @@ package com.etotem.cfc.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -24,6 +26,7 @@ import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Matcher;
@@ -53,6 +56,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private com.etotem.cfc.service.AiGateway aiGateway;
+
     @Value("${langgraph.base-url:}")
     private String langgraphBaseUrl;
 
@@ -754,6 +763,50 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return result;
     }
 
+    @Override
+    public Long generateFromSelfCheck(Long userId, Long checkId) {
+        try {
+            FiveDimensionSelfCheck check;
+            if (checkId != null) {
+                check = selfCheckMapper.selectById(checkId);
+            } else {
+                check = selfCheckMapper.selectOne(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                                .last("LIMIT 1"));
+            }
+            if (check == null) return null;
+            Map<String, Integer> scores = objectMapper.readValue(check.getScoresJson(),
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Integer>>() {});
+            List<String> lowDims = new ArrayList<>();
+            for (Map.Entry<String, Integer> e : scores.entrySet()) {
+                if (e.getValue() != null && e.getValue() <= 6) lowDims.add(e.getKey());
+            }
+            if (lowDims.isEmpty()) return null;
+            User user = userMapper.selectById(userId);
+            if (user == null || user.getFamilyId() == null) return null;
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("familyId", user.getFamilyId());
+            inputs.put("dimensions", String.join(",", lowDims));
+            inputs.put("goal", "基于五维自检结果,针对低分维度制定改善计划");
+            String planContent = aiGateway.generateHealthPlan(inputs);
+            if (planContent == null) return null;
+            HealthPlan plan = new HealthPlan();
+            plan.setFamilyId(user.getFamilyId());
+            plan.setDimensions(String.join(",", lowDims));
+            plan.setGoal("五维自检自动生成");
+            plan.setPlanContent(planContent);
+            plan.setStatus("draft");
+            plan.setCreatedAt(new Date());
+            healthPlanMapper.insert(plan);
+            return plan.getId();
+        } catch (Exception e) {
+            log.warn("generateFromSelfCheck 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
     @Override
     public Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType) {
         if (familyId == null || memberId == null) {

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java

@@ -42,14 +42,14 @@ public class CouponGrantTask {
 
             for (Coupon c : templates) {
                 String periodKey = "QUARTERLY".equals(c.getGrantPeriod()) ? quarterKey : monthKey;
-                List<Long> userIds = membershipService.listActiveMemberUserIds(c.getGrantLevelCode());
+                List<Long> familyIds = membershipService.listActiveMemberFamilyIds(c.getGrantLevelCode());
                 int granted = 0;
-                for (Long uid : userIds) {
-                    if (couponService.grant(uid, c.getId(), "PERIODIC", periodKey, null)) {
+                for (Long familyId : familyIds) {
+                    if (couponService.grantFamily(familyId, c.getId(), "PERIODIC", periodKey, null)) {
                         granted++;
                     }
                 }
-                log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放用户数={}",
+                log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放家庭数={}",
                         c.getId(), c.getGrantLevelCode(), periodKey, granted);
             }
             log.info("周期性会员券补发任务执行完成");

+ 33 - 1
cfc-backend/src/main/resources/schema.sql

@@ -4365,6 +4365,38 @@ CREATE TABLE IF NOT EXISTS coupon_grant_log (
     INDEX idx_coupon (coupon_id),
     INDEX idx_user (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表';
+
+-- =============================================
+-- 家庭优惠券表(全链路改为家庭维度:2026-08-31)
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS family_coupon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME,
+    order_id BIGINT COMMENT '核销时写入的订单号',
+    INDEX idx_family_coupon (family_id, coupon_id),
+    INDEX idx_status (status),
+    INDEX idx_order_id (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
+
+CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE',
+    period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用',
+    quantity INT DEFAULT 1 COMMENT '发放数量',
+    source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period),
+    INDEX idx_coupon (coupon_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
+
 -- 家庭加入请求(邀请人审批,旧数据由家庭管理员兜底)
 CREATE TABLE IF NOT EXISTS family_join_requests (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -5264,7 +5296,7 @@ CREATE TABLE IF NOT EXISTS numsoul_detail_config (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字能量详细配置';
 
 -- ============================================================
--- 五维自检忽略提醒记录表(迁移270
+-- 五维自检忽略提醒记录表(迁移277
 -- ============================================================
 CREATE TABLE IF NOT EXISTS self_check_ignores (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 57 - 8
cfc-frontend/__tests__/api/api.test.js

@@ -25,6 +25,21 @@ function mockFail(err) {
   })
 }
 
+function mockSequence(responses) {
+  var i = 0
+  global.uni.request.mockImplementation((opts) => {
+    var resp = responses[Math.min(i, responses.length - 1)]
+    i++
+    process.nextTick(() => {
+      if (resp.err) {
+        if (opts.fail) opts.fail(resp.err)
+      } else {
+        if (opts.success) opts.success({ data: resp.data })
+      }
+    })
+  })
+}
+
 // --------------- global beforeEach ---------------
 
 beforeEach(() => {
@@ -151,6 +166,38 @@ describe('auth API', () => {
     jest.useRealTimers()
   })
 
+  test('401 with cached openid silently re-logins and retries original request', async () => {
+    global.uni.reLaunch.mockReset()
+    global.uni.showToast.mockReset()
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
+      { data: { code: 200, data: { userId: 42, role: 'parent' } } }
+    ])
+    const res = await api.verifyToken()
+    expect(res.data.userId).toBe(42)
+    expect(global.uni._storage.token).toBe('new-jwt')
+    expect(global.uni.reLaunch).not.toHaveBeenCalled()
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+  })
+
+  test('401 with cached openid falls back to login when auto-login fails', async () => {
+    jest.useFakeTimers()
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 401, message: '用户不存在', data: null } }
+    ])
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+    jest.runAllTimers()
+    expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
+    expect(global.uni.showToast).toHaveBeenCalled()
+    jest.useRealTimers()
+  })
+
   test('Authorization header includes Bearer token', async () => {
     mockSuccess({ code: 200 })
     global.uni._storage.token = 'my-jwt-token'
@@ -290,10 +337,10 @@ describe('user & family API', () => {
     expect(lastRequest().url).toMatch(/\/api\/user\/switch-back-to-parent$/)
   })
 
-  test('getChildren sends POST', async () => {
+  test('getChildren sends POST to unified member list', async () => {
     mockSuccess({ code: 200 })
     await api.getChildren()
-    expect(lastRequest().url).toMatch(/\/api\/family\/user\/children\/list$/)
+    expect(lastRequest().url).toMatch(/\/api\/family\/member\/list$/)
   })
 
   test('createChild sends POST with data', async () => {
@@ -397,11 +444,12 @@ describe('tasks API', () => {
     expect(lastRequest().data.size).toBe(20)
   })
 
-  test('deleteTask sends DELETE', async () => {
+  test('deleteTask sends POST with _method DELETE', async () => {
     mockSuccess({ code: 200 })
     await api.deleteTask(10)
-    expect(lastRequest().method).toBe('DELETE')
+    expect(lastRequest().method).toBe('POST')
     expect(lastRequest().url).toMatch(/\/api\/tasks\/10$/)
+    expect(lastRequest().data._method).toBe('DELETE')
   })
 
   test('batchCompleteTasks sends POST with array', async () => {
@@ -533,10 +581,11 @@ describe('identity parameter hardening', () => {
 // --------------- guide / packages module ---------------
 
 describe('guide & packages API', () => {
-  test('getGuidePackages sends POST to list', async () => {
+  test('getGuidePackages sends POST to my list', async () => {
     mockSuccess({ code: 200 })
     await api.getGuidePackages(1)
-    expect(lastRequest().url).toMatch(/\/api\/guide\/packages$/)
+    expect(lastRequest().url).toMatch(/\/api\/guide\/packages\/my$/)
+    expect(lastRequest().data.guideId).toBe(1)
   })
 
   test('registerGuide sends POST', async () => {
@@ -947,8 +996,8 @@ describe('health report API', () => {
 
   test('getSurveyStatus sends POST with reportId', async () => {
     mockSuccess({ code: 200 })
-    await api.getSurveyStatus(10)
-    expect(lastRequest().url).toMatch(/\/api\/health\/survey\/status$/)
+    await api.getSurveyStatus({ reportId: 10 })
+    expect(lastRequest().url).toMatch(/\/api\/survey\/status$/)
     expect(lastRequest().data.reportId).toBe(10)
   })
 })

+ 233 - 0
cfc-frontend/components/MemberChipPicker.vue

@@ -0,0 +1,233 @@
+<template>
+  <view class="member-chip-picker">
+    <view v-if="loading" class="mcp-loading">
+      <text class="mcp-loading-text">加载成员中...</text>
+    </view>
+
+    <view v-else-if="members.length > 0" class="mcp-chips">
+      <view
+        class="mcp-chip"
+        v-for="(m, i) in members"
+        :key="getMemberKey(m, i)"
+        :class="{ 'mcp-chip-on': isSelected(m.memberId) }"
+        @tap="toggleMember(m.memberId)"
+      >
+        <image
+          v-if="m.avatar"
+          class="mcp-chip-avatar"
+          :src="m.avatar"
+          mode="aspectFill"
+        />
+        <view v-else class="mcp-chip-avatar mcp-chip-avatar-default">
+          <text class="mcp-chip-avatar-text">{{ getInitial(m) }}</text>
+        </view>
+        <text class="mcp-chip-text">{{ m.name || m.nickname || '成员' }}</text>
+      </view>
+
+      <view class="mcp-chip mcp-chip-add" @tap="goAddMember">
+        <text class="mcp-chip-add-icon">+</text>
+        <text class="mcp-chip-text">添加</text>
+      </view>
+    </view>
+
+    <view v-else class="mcp-empty">
+      <text class="mcp-empty-text">暂无家庭成员</text>
+      <view class="mcp-empty-btn" @tap="goAddMember">+ 去添加成员</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList } from '@/utils/api.js'
+
+export default {
+  name: 'MemberChipPicker',
+  props: {
+    // 已选中的成员 id 数组(字符串)
+    selectedIds: {
+      type: Array,
+      default: function() { return [] }
+    }
+  },
+  data: function() {
+    return {
+      members: [],
+      innerSelectedIds: [],
+      loading: false,
+      loaded: false
+    }
+  },
+  watch: {
+    selectedIds: {
+      handler: function(val) {
+        this.innerSelectedIds = (val || []).map(function(id) { return String(id) })
+      },
+      immediate: true
+    }
+  },
+  created: function() {
+    this.reload()
+  },
+  methods: {
+    // ——— :key 方法(避免模板内运算符表达式) ———
+    getMemberKey: function(m, i) {
+      return String(m.memberId || m.id || i)
+    },
+    // ——— 首字(无头像兜底) ———
+    getInitial: function(m) {
+      var name = m.name || m.nickname || ''
+      return name ? name.substring(0, 1) : '?'
+    },
+    isSelected: function(id) {
+      return this.innerSelectedIds.indexOf(String(id)) >= 0
+    },
+    // ——— 加载家庭成员 ———
+    reload: function() {
+      var self = this
+      this.loading = true
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var raw = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
+          var list = []
+          for (var i = 0; i < raw.length; i++) {
+            var m = raw[i]
+            // 同时输出 id/memberId、name/nickname,兼容父组件对成员字段的不同取法
+            list.push({
+              id: m.id,
+              memberId: m.memberId !== undefined ? m.memberId : m.id,
+              name: m.name || m.nickname,
+              nickname: m.nickname || m.name,
+              avatar: m.avatar || '',
+              relationship: m.relationship || m.relativeLabel || m.roleLabel || ''
+            })
+          }
+          self.members = list
+          self.emitState()
+        } else {
+          self.members = []
+          self.emitState()
+        }
+      }).catch(function() {
+        self.loading = false
+        self.members = []
+      })
+    },
+    // ——— 切换选中态 ———
+    toggleMember: function(id) {
+      var sid = String(id)
+      var idx = this.innerSelectedIds.indexOf(sid)
+      if (idx >= 0) {
+        this.innerSelectedIds.splice(idx, 1)
+      } else {
+        this.innerSelectedIds.push(sid)
+      }
+      this.emitState()
+    },
+    // ——— 通知父组件 ———
+    emitState: function() {
+      this.$emit('change', {
+        selectedIds: this.innerSelectedIds.slice(),
+        members: this.members.slice()
+      })
+      if (!this.loaded) {
+        this.loaded = true
+        this.$emit('loaded', {
+          selectedIds: this.innerSelectedIds.slice(),
+          members: this.members.slice()
+        })
+      }
+    },
+    // ——— 添加成员 ———
+    goAddMember: function() {
+      this.$emit('add-member')
+      uni.navigateTo({ url: '/pages/family/add-member' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.member-chip-picker {
+  width: 100%;
+}
+.mcp-loading {
+  padding: 30rpx 0;
+  text-align: center;
+}
+.mcp-loading-text {
+  font-size: 26rpx;
+  color: #9CA3AF;
+}
+.mcp-chips {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.mcp-chip {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 24rpx;
+  margin: 0 16rpx 16rpx 0;
+  background: #F3F4F6;
+  border-radius: 999rpx;
+  border: 2rpx solid transparent;
+}
+.mcp-chip-on {
+  background: #FFEDD5;
+  border-color: #F97316;
+}
+.mcp-chip-avatar {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 50%;
+  margin-right: 10rpx;
+  overflow: hidden;
+}
+.mcp-chip-avatar-default {
+  background: #E5E7EB;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.mcp-chip-avatar-text {
+  font-size: 22rpx;
+  color: #6B7280;
+}
+.mcp-chip-text {
+  font-size: 26rpx;
+  color: #374151;
+}
+.mcp-chip-on .mcp-chip-text {
+  color: #C2410C;
+  font-weight: 500;
+}
+.mcp-chip-add {
+  background: transparent;
+  border: 2rpx dashed #D1D5DB;
+  color: #9CA3AF;
+}
+.mcp-chip-add-icon {
+  font-size: 28rpx;
+  margin-right: 6rpx;
+  color: #9CA3AF;
+}
+.mcp-empty {
+  padding: 40rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.mcp-empty-text {
+  font-size: 26rpx;
+  color: #9CA3AF;
+  margin-bottom: 16rpx;
+}
+.mcp-empty-btn {
+  padding: 14rpx 40rpx;
+  background: #F97316;
+  color: #FFFFFF;
+  font-size: 26rpx;
+  border-radius: 40rpx;
+}
+</style>

+ 3 - 0
cfc-frontend/components/health-knowledge-popup.vue

@@ -75,6 +75,7 @@ export default {
   font-weight: 600;
   color: #333;
   flex: 1;
+  word-break: break-all;
 }
 .kb-popup-close {
   font-size: 40rpx;
@@ -98,6 +99,8 @@ export default {
   font-size: 28rpx;
   color: #333;
   line-height: 1.6;
+  word-break: break-all;
+  white-space: normal;
 }
 .kb-empty {
   text-align: center;

+ 14 - 4
cfc-frontend/pages/diet/index.vue

@@ -158,7 +158,7 @@
 </template>
 
 <script>
-import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig } from '@/utils/api.js'
+import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
 import { parseDate } from '@/utils/format.js'
 
 export default {
@@ -223,6 +223,7 @@ export default {
         self.ingredientsLoading = false
         if (res && res.code === 200 && res.data && res.data.ingredients) {
           self.ingredients = res.data.ingredients
+          self.selectedIngredients = self.ingredients
           if (self.ingredients.length === 0 && !self.uploadPromptShown) {
             self.uploadPromptShown = true
             uni.showModal({
@@ -249,6 +250,7 @@ export default {
         self.ingredientsLoading = false
         if (res && res.code === 200 && res.data && res.data.ingredients) {
           self.ingredients = res.data.ingredients
+          self.selectedIngredients = self.ingredients
         }
       }).catch(function() {
         self.ingredientsLoading = false
@@ -276,11 +278,13 @@ export default {
         meal_type: 'all',
         selected_foods: JSON.stringify(foodIds)
       }).then(function(res) {
-        if (res && res.id) {
+        if (res && res.data && res.data.id) {
           uni.showToast({ title: '食谱生成成功', icon: 'success' })
           setTimeout(function() {
             self.navTo('/pages/diet/recommendation')
           }, 1000)
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
         }
       })
     },
@@ -330,11 +334,17 @@ export default {
       this.searchResults = []
     },
     searchFood: function() {
-      if (!this.searchQuery) {
+      var self = this
+      var kw = (this.searchQuery || '').trim()
+      if (!kw) {
         this.searchResults = []
         return
       }
-      this.searchResults = []
+      searchDietFoods(kw).then(function(res) {
+        if (res && res.code === 200 && res.data && res.data.foods) {
+          self.searchResults = res.data.foods
+        }
+      })
     },
     addFood: function(food) {
       var self = this

+ 30 - 10
cfc-frontend/pages/diet/recommendation.vue

@@ -138,33 +138,51 @@ export default {
       var self = this
       var today = self.formatDate(new Date())
       getDietRecommendation({ date: today }).then(function(res) {
-        if (res && res.id) {
-          self.recommendation = res
-          self.parseMenu(res.menu)
-          self.summary = res.nutritionSummary || {}
+        var d = res && res.data
+        if (d && d.id) {
+          self.recommendation = d
+          self.parseMenu(d.menu)
+          var ns = d.nutritionSummary
+          self.summary = ns ? (typeof ns === 'string' ? JSON.parse(ns) : ns) : {}
+        } else {
+          self.recommendation = null
+          self.meals = []
+          self.summary = {}
         }
       })
     },
     parseMenu: function(menuJson) {
       try {
-        var menu = JSON.parse(menuJson || '{}')
+        var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
         this.meals = []
-        
+
         var mealMap = {
           'breakfast': { name: '早餐', icon: '🌅' },
           'lunch': { name: '午餐', icon: '☀️' },
           'dinner': { name: '晚餐', icon: '🌙' }
         }
-        
+
         if (menu.meals) {
+          var self = this
           menu.meals.forEach(function(meal) {
-            var config = mealMap[meal.type] || { name: meal.type, icon: '🍽️' }
+            var config = mealMap[meal.type] || { name: meal.name || meal.type, icon: '🍽️' }
+            var dishes = (meal.dishes || []).map(function(dish) {
+              var ing = (dish.ingredients || []).map(function(item) {
+                return { name: item.name, amount: item.amount || item.grams || 0 }
+              })
+              return {
+                name: dish.name,
+                calories: dish.calories || (dish.nutrition && dish.nutrition.calories) || 0,
+                ingredients: ing,
+                method: dish.method || dish.cooking_method || ''
+              }
+            })
             self.meals.push({
               name: config.name,
               icon: config.icon,
               type: meal.type,
               participants: meal.participants || 1,
-              dishes: meal.dishes || []
+              dishes: dishes
             })
           })
         }
@@ -180,9 +198,11 @@ export default {
         meal_type: 'all'
       }).then(function(res) {
         self.loading = false
-        if (res && res.id) {
+        if (res && res.data && res.data.id) {
           uni.showToast({ title: '食谱生成成功', icon: 'success' })
           self.loadRecommendation()
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
         }
       }).catch(function() {
         self.loading = false

+ 70 - 2
cfc-frontend/pages/family/self-check-entry.vue

@@ -50,6 +50,18 @@
         <text class="sce-status-text">已满 15 天,可再次自检</text>
       </view>
 
+      <!-- 趋势分析(P1-1) -->
+      <view class="sce-trend" v-if="trendLoaded">
+        <view class="sce-trend-header">
+          <text class="sce-trend-title">📈 自检趋势</text>
+          <text class="sce-trend-summary" v-if="trendSummary">{{ trendSummary }}</text>
+        </view>
+        <text class="sce-trend-insight" v-if="aiInsight">{{ aiInsight }}</text>
+        <view class="sce-trend-loading" v-if="trendLoading">
+          <text>AI 趋势分析中...</text>
+        </view>
+      </view>
+
       <!-- 按钮区域 -->
       <view class="sce-footer">
         <button v-if="canCheck && lastResult" class="sce-btn sce-btn-primary" @click="retake">
@@ -70,7 +82,7 @@
 </template>
 
 <script>
-import { getSelfCheckStatus, ignoreSelfCheck } from '@/utils/api'
+import { getSelfCheckStatus, ignoreSelfCheck, getSelfCheckTrendAnalysis } from '@/utils/api'
 import { parseDate } from '@/utils/format.js'
 
 export default {
@@ -79,7 +91,11 @@ export default {
       loading: true,
       lastResult: null,
       canCheck: false,
-      daysLeft: 0
+      daysLeft: 0,
+      trendLoaded: false,
+      trendLoading: false,
+      aiInsight: '',
+      trendSummary: ''
     }
   },
   computed: {
@@ -95,6 +111,7 @@ export default {
   },
   onLoad: function() {
     this.loadStatus()
+    this.loadTrend()
   },
   methods: {
     loadStatus: function() {
@@ -137,6 +154,21 @@ export default {
       if (!d) return ''
       var pad = function(n) { return n < 10 ? '0' + n : '' + n }
       return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+    },
+    loadTrend: function() {
+      var self = this
+      this.trendLoading = true
+      getSelfCheckTrendAnalysis().then(function(res) {
+        self.trendLoading = false
+        self.trendLoaded = true
+        if (res.code === 200 && res.data) {
+          self.aiInsight = res.data.aiInsight || ''
+          self.trendSummary = res.data.trendSummary || ''
+        }
+      }).catch(function() {
+        self.trendLoading = false
+        self.trendLoaded = true
+      })
     }
   }
 }
@@ -306,4 +338,40 @@ export default {
   color: #999;
   font-weight: 400;
 }
+.sce-trend {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx 26rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+}
+.sce-trend-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.sce-trend-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.sce-trend-summary {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+.sce-trend-insight {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+}
+.sce-trend-loading {
+  padding: 10rpx 0;
+}
+.sce-trend-loading text {
+  font-size: 24rpx;
+  color: #999;
+}
 </style>

+ 123 - 48
cfc-frontend/pages/family/self-check-result.vue

@@ -67,80 +67,60 @@
         </view>
       </view>
 
-      <!-- 五行相生寻源建议(P0-2) -->
+      <!-- AI 健康解读(P0-1) -->
       <view class="scr-card" v-if="result.advices && result.advices.length > 0">
         <view class="scr-card-title">
-          <text class="scr-card-title-text">五行相生 · 寻源建议</text>
-          <text class="scr-card-sub">低分维度 {{ result.advices.length }} 项</text>
+          <text class="scr-card-title-text">AI 健康解读</text>
+          <text class="scr-card-sub">基于你的五维自检结果</text>
         </view>
-        <view class="scr-advice-intro">低于6分的维度,先别硬补——从"生"它的上游借力,同时避开"克"它的压制源:</view>
-        <view class="scr-advice-item" v-for="adv in result.advices" :key="adv.dimension">
-          <!-- 低分维度 -->
+        <view v-for="adv in result.advices" :key="adv.dimension" class="scr-advice-item">
           <view class="scr-advice-dim-row">
             <view class="scr-advice-dim-badge" :style="{ background: adv.color }">
               <text class="scr-advice-dim-name">{{ adv.dimensionName }}</text>
-              <text class="scr-advice-dim-elem">{{ adv.element }}</text>
+              <text class="scr-advice-dim-elem">{{ adv.score }}分</text>
             </view>
-            <text class="scr-advice-dim-score">{{ adv.score }}分</text>
           </view>
-
-          <!-- 相生寻源 -->
-          <view class="scr-advice-flow">
-            <view class="scr-flow-node">
-              <view class="scr-flow-dot" :style="{ background: adv.upstreamColor }"></view>
-              <text class="scr-flow-label">上游 · {{ adv.upstreamName }}({{ adv.upstreamElement }})</text>
-            </view>
-            <text class="scr-flow-arrow">⟶</text>
-            <text class="scr-flow-action">{{ adv.upstreamReason }}</text>
-          </view>
-
-          <!-- 相克寻源 -->
-          <view class="scr-advice-flow">
-            <view class="scr-flow-node">
-              <view class="scr-flow-dot" :style="{ background: adv.restrainerColor }"></view>
-              <text class="scr-flow-label">压制 · {{ adv.restrainerName }}({{ adv.restrainerElement }})</text>
-            </view>
-            <text class="scr-flow-arrow">⛔</text>
-            <text class="scr-flow-action">{{ adv.restrainerReason }}</text>
-          </view>
-
-          <!-- 行动建议 -->
-          <view class="scr-advice-action">
-            <text class="scr-advice-action-label">本周行动</text>
-            <text class="scr-advice-action-text">{{ adv.action }}</text>
-          </view>
-
-          <!-- 一键加入今日微行动(微行动×五维联动) -->
-          <view class="scr-micro-row" v-if="joinedDims[adv.dimension]">
-            <text class="scr-micro-done">✅ 今日微行动已完成,能量已入账</text>
-          </view>
-          <view class="scr-micro-row" v-else>
-            <button class="scr-micro-btn" :loading="joiningDim === adv.dimension" :disabled="joiningDim !== ''" @click="joinMicroAction(adv)">
-              ⚡ 加入今日微行动
-            </button>
+          <text class="scr-advice-interpretation" v-if="adv.interpretation">{{ adv.interpretation }}</text>
+          <view class="scr-advice-actions" v-if="adv.microActions && adv.microActions.length > 0">
+            <text class="scr-advice-action-label">本周行动:</text>
+            <text v-for="(action, i) in adv.microActions" :key="'ma'+i" class="scr-advice-action">{{ i+1 }}. {{ action }}</text>
           </view>
         </view>
+        <view class="scr-family-insight" v-if="result.advices[0] && result.advices[0].familyInsight">
+          <text class="scr-family-insight-label">【家庭整体洞察】</text>
+          <text class="scr-family-insight-text">{{ result.advices[0].familyInsight }}</text>
+        </view>
       </view>
-
       <!-- 全部健康提示 -->
-      <view class="scr-card" v-else>
+      <view class="scr-card" v-else-if="result.totalScore >= 35">
         <view class="scr-all-healthy">
           <text class="scr-all-healthy-icon">🌿</text>
           <text class="scr-all-healthy-text">五维状态均健康,请继续保持这份平衡!</text>
         </view>
       </view>
+      <!-- AI 分析暂不可用提示 -->
+      <view class="scr-card" v-else>
+        <view class="scr-all-healthy">
+          <text class="scr-all-healthy-icon">⏳</text>
+          <text class="scr-all-healthy-text">AI 分析暂时不可用,请稍后再试</text>
+        </view>
+      </view>
 
-      <!-- 历史与重测 -->
+      <!-- 历史与操作 -->
       <view class="scr-footer">
         <button class="scr-btn scr-btn-primary" @click="goHistory">历史记录</button>
         <button class="scr-btn scr-btn-outline" @click="retake">重新自检</button>
+        <button class="scr-btn scr-btn-secondary" v-if="hasLowScore" :loading="planGenerating" @click="generatePlan">
+          生成健康计划
+        </button>
+        <button class="scr-btn scr-btn-ghost" @click="askAI">问问 AI</button>
       </view>
     </template>
   </view>
 </template>
 
 <script>
-import { getSelfCheckLatest, getSelfCheckHistory } from '@/utils/api'
+import { getSelfCheckLatest, getSelfCheckHistory, generateSelfCheckPlan } from '@/utils/api'
 import { microActionToday, microActionComplete } from '@/utils/api'
 import { parseDate } from '@/utils/format.js'
 
@@ -164,7 +144,8 @@ export default {
       },
       // 今日微行动联动状态
       joiningDim: '',
-      joinedDims: {}
+      joinedDims: {},
+      planGenerating: false
     }
   },
   computed: {
@@ -176,6 +157,11 @@ export default {
     },
     overallLevelName: function() {
       return this.levelMap[this.overallLevel] || '留意'
+    },
+    hasLowScore: function() {
+      return this.result && this.result.advices && this.result.advices.some(function(a) {
+        return a.score != null && a.score <= 6
+      })
     }
   },
   onLoad: function(options) {
@@ -393,6 +379,33 @@ export default {
     },
     retake: function() {
       uni.navigateTo({ url: '/pages/family/self-check-entry' })
+    },
+    generatePlan: function() {
+      var self = this
+      if (this.planGenerating) return
+      this.planGenerating = true
+      generateSelfCheckPlan({ checkId: this.result && this.result.id }).then(function(res) {
+        self.planGenerating = false
+        if (res.code === 200 && res.data && res.data.planId) {
+          uni.showToast({ title: '计划已生成,可在健康计划页查看', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateTo({ url: '/pages/health-main/index?planId=' + res.data.planId })
+          }, 1000)
+        } else {
+          uni.showToast({ title: (res && res.message) || '计划生成失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.planGenerating = false
+        uni.showToast({ title: '计划生成失败', icon: 'none' })
+      })
+    },
+    askAI: function() {
+      var selfCheckId = this.result && this.result.id
+      if (!selfCheckId) {
+        uni.showToast({ title: '暂无自检记录', icon: 'none' })
+        return
+      }
+      uni.navigateTo({ url: '/pages/ai/chat?selfCheckId=' + selfCheckId })
     }
   }
 }
@@ -756,4 +769,66 @@ export default {
   color: #F97316;
   border: 2rpx solid #F97316;
 }
+.scr-btn-secondary {
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  font-weight: 600;
+}
+.scr-btn-ghost {
+  background: #fff;
+  color: #666;
+  border: 2rpx solid #D1D5DB;
+}
+.scr-advice-interpretation {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+  margin: 16rpx 0;
+}
+.scr-advice-actions {
+  display: flex;
+  flex-direction: column;
+  margin-top: 12rpx;
+}
+.scr-advice-action-label {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #F97316;
+}
+.scr-advice-action {
+  font-size: 24rpx;
+  color: #555;
+  line-height: 1.5;
+}
+.scr-family-insight {
+  background: #F5FAFE;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-top: 24rpx;
+}
+.scr-family-insight-label {
+  display: block;
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #F97316;
+  margin-bottom: 8rpx;
+}
+.scr-family-insight-text {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+}
+.scr-fallback {
+  background: #FFF7ED;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-top: 16rpx;
+  text-align: center;
+}
+.scr-fallback text {
+  font-size: 24rpx;
+  color: #999;
+}
 </style>

+ 3 - 2
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -133,7 +133,7 @@
       <view class="accordion-section">
         <view class="accordion-header" @tap="toggleAccordion('gut')">
           <text class="accordion-title">肠道功能与代谢物</text>
-          <text class="accordion-stats" v-if="gutStats.total > 0">有效 {{ gutStats.valid }} / 异常 {{ gutStats.abnormal }} / 共 {{ gutStats.total }} 项</text>
+          <text class="accordion-stats" v-if="gutStats.total > 0">异常 <text class="abnormal-num">{{ gutStats.abnormal }}</text> / 共 {{ gutStats.total }} 项</text>
           <text class="accordion-arrow">{{ accordionOpen.gut ? '收起' : '展开' }}</text>
         </view>
         <view class="accordion-body" v-if="accordionOpen.gut">
@@ -168,7 +168,7 @@
       <view class="accordion-section">
         <view class="accordion-header" @tap="toggleAccordion('nutri')">
           <text class="accordion-title">营养指标</text>
-          <text class="accordion-stats" v-if="nutriStats.total > 0">有效 {{ nutriStats.valid }} / 异常 {{ nutriStats.abnormal }} / 共 {{ nutriStats.total }} 项</text>
+          <text class="accordion-stats" v-if="nutriStats.total > 0">异常 <text class="abnormal-num">{{ nutriStats.abnormal }}</text> / 共 {{ nutriStats.total }} 项</text>
           <text class="accordion-arrow">{{ accordionOpen.nutri ? '收起' : '展开' }}</text>
         </view>
         <view class="accordion-body" v-if="accordionOpen.nutri">
@@ -759,6 +759,7 @@ report.gutType = s.gutType || ''
 .accordion-header { display: flex; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
 .accordion-title { font-size: 30rpx; font-weight: 600; color: #333; }
 .accordion-stats { font-size: 22rpx; color: #999; margin-left: 16rpx; }
+.abnormal-num { color: #C62828; font-weight: 700; }
 .accordion-arrow { font-size: 24rpx; color: #999; }
 .accordion-body { padding: 10rpx 30rpx 20rpx; }
 

+ 315 - 49
cfc-frontend/pages/health/gut-flora-risks-detail.vue

@@ -1,20 +1,56 @@
 <template>
   <view class="page">
-<text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
+    <text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
+
+    <!-- 头部摘要 -->
+    <view class="header-summary" v-if="!isEditing && risks.length > 0">
+      <view class="summary-stat">
+        <text class="stat-num stat-abnormal">{{ importantRisks.length }}</text>
+        <text class="stat-label">需关注</text>
+      </view>
+      <view class="summary-stat">
+        <text class="stat-num stat-low">{{ normalRisks.length }}</text>
+        <text class="stat-label">低风险</text>
+      </view>
+      <view class="summary-stat">
+        <text class="stat-num stat-total">{{ risks.length }}</text>
+        <text class="stat-label">总计</text>
+      </view>
+    </view>
 
     <scroll-view class="content" scroll-y>
-      <view class="section-label">重要风险</view>
-      <view class="risk-card" v-for="(item, idx) in importantRisks" :key="idx"
+      <!-- 重要风险 -->
+      <view class="section-header" v-if="importantRisks.length > 0">
+        <view class="section-line"></view>
+        <text class="section-label">⚠ 需重点关注</text>
+      </view>
+      <view class="risk-card important-card" v-for="(item, idx) in importantRisks" :key="'imp-' + idx"
             :class="'risk-level-' + (item._cssClass || 'low')">
-        <view class="risk-header">
-          <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
-          <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
-            {{ riskBadgeText(item.riskLevel) }}
-          </text>
+        <view class="card-top">
+          <view class="card-title-row">
+            <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
+            <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
+              {{ riskBadgeText(item.riskLevel) }}
+            </text>
+          </view>
+          <view class="risk-progress-wrap" v-if="item.riskValue != null">
+            <view class="risk-progress-bg">
+              <view class="risk-progress-fill" :class="'fill-' + (item._cssClass || 'low')"
+                    :style="'width:' + calcProgress(item.riskValue) + '%'"></view>
+            </view>
+            <text class="risk-value-text">{{ item.riskValue }}</text>
+          </view>
+        </view>
+        <view class="card-desc" v-if="item.diseaseDesc">
+          <text class="desc-label">说明:</text>
+          <text class="desc-content">{{ item.diseaseDesc }}</text>
         </view>
         <view class="risk-detail-row" v-if="!isEditing">
-          <text class="risk-value">风险值: {{ item.riskValue || '--' }}</text>
-          <text class="risk-level-text">等级: {{ item.riskLevel || '--' }}</text>
+          <text class="risk-tag" :class="'tag-' + (item._cssClass || 'low')">
+            {{ item.riskLevel || '--' }}
+          </text>
+          <text class="risk-value" v-if="item.riskValue">风险值 {{ item.riskValue }}</text>
+          <text class="risk-more" @tap.stop="showKnowledge('disease', item.diseaseName)">查看知识库 ›</text>
         </view>
         <view class="risk-edit-row" v-if="isEditing">
           <text class="edit-label">风险值:</text>
@@ -26,18 +62,28 @@
         </view>
       </view>
 
-      <view class="section-label">其它风险</view>
-      <view class="risk-card" v-for="(item, idx) in normalRisks" :key="idx"
+      <!-- 其它风险 -->
+      <view class="section-header" v-if="normalRisks.length > 0">
+        <view class="section-line"></view>
+        <text class="section-label">✓ 低风险项</text>
+      </view>
+      <view class="risk-card normal-card" v-for="(item, idx) in normalRisks" :key="'norm-' + idx"
             :class="'risk-level-' + (item._cssClass || 'low')">
-        <view class="risk-header">
-          <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
-          <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
-            {{ riskBadgeText(item.riskLevel) }}
-          </text>
+        <view class="card-top">
+          <view class="card-title-row">
+            <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
+            <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
+              {{ riskBadgeText(item.riskLevel) }}
+            </text>
+          </view>
+        </view>
+        <view class="card-desc" v-if="item.diseaseDesc">
+          <text class="desc-label">说明:</text>
+          <text class="desc-content">{{ item.diseaseDesc }}</text>
         </view>
         <view class="risk-detail-row" v-if="!isEditing">
-          <text class="risk-value">风险值: {{ item.riskValue || '--' }}</text>
-          <text class="risk-level-text">等级: {{ item.riskLevel || '--' }}</text>
+          <text class="risk-tag tag-low">{{ item.riskLevel || '--' }}</text>
+          <text class="risk-more" @tap.stop="showKnowledge('disease', item.diseaseName)">查看知识库 ›</text>
         </view>
         <view class="risk-edit-row" v-if="isEditing">
           <text class="edit-label">风险值:</text>
@@ -48,10 +94,12 @@
           </picker>
         </view>
       </view>
+
       <view class="empty-hint" v-if="risks.length === 0">暂无疾病风险评估</view>
       <view class="bottom-spacer"></view>
     </scroll-view>
 
+    <!-- 疾病知识库弹窗 -->
     <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
 
     <view class="bottom-bar" v-if="isEditing">
@@ -62,7 +110,7 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, queryDiseaseKnowledge } from '../../utils/api.js'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 
 export default {
@@ -106,6 +154,12 @@ export default {
       var map = { '低风险': 'low', '需注意': 'warning', '高风险': 'high', '异常': 'abnormal' }
       return map[level] || 'low'
     },
+    /** 风险值归一化进度(0~1范围映射到百分比) */
+    calcProgress: function(value) {
+      var v = parseFloat(value)
+      if (isNaN(v)) return 0
+      return Math.min(100, Math.max(0, v * 100))
+    },
     getRisks: function() {
       return this.isEditing ? this.editRisks : this.risks
     },
@@ -114,10 +168,29 @@ export default {
       getReportDetail(this.reportId).then(function(res) {
         if (res.code === 200 && res.data) {
           self.reportData = res.data
-          self.risks = (res.data.diseaseRisks || []).map(function(item) {
+          var rawRisks = res.data.diseaseRisks || []
+          self.risks = rawRisks.map(function(item) {
             item._cssClass = self._cssForRiskLevel(item.riskLevel)
             return item
           })
+          // 批量预加载疾病知识库
+          self.preloadDiseaseKnowledge(self.risks)
+        }
+      })
+    },
+    /** 批量加载疾病知识库,补充到 risks 数据中 */
+    preloadDiseaseKnowledge: function(risks) {
+      var self = this
+      risks.forEach(function(item) {
+        if (item.diseaseName) {
+          queryDiseaseKnowledge(item.diseaseName).then(function(res) {
+            if (res.code === 200 && res.data) {
+              var d = res.data
+              // 兼容不同字段名
+              item.diseaseDesc = d.description || d.说明 || d.category || ''
+              item.diseaseRelatedBacteria = d.relatedBacteria || d.关联菌种 || ''
+            }
+          }).catch(function() {})
         }
       })
     },
@@ -151,26 +224,42 @@ export default {
     },
     onRiskLevelChange: function(e, item) {
       item.riskLevel = this.riskLevels[e.detail.value]
+      item._cssClass = this._cssForRiskLevel(item.riskLevel)
     },
     showKnowledge: function(type, name) {
       var self = this
-      // 优先使用 KB v3 查询指标调整建议
-      queryIndicatorKnowledge(name).then(function(res) {
+      // 优先查询疾病知识库
+      queryDiseaseKnowledge(name).then(function(res) {
         if (res.code === 200 && res.data) {
-          var v3 = res.data
-          var high = v3['偏高影响'] || ''
-          var low = v3['偏低影响'] || ''
+          var d = res.data
           self.knowledgeData = {
-            itemName: v3['名称'] || name,
-            category: v3['说明'] || '',
-            description: (high ? '偏高影响:' + high + '\n' : '') + (low ? '偏低影响:' + low : ''),
-            suggestion: v3['调整建议'] || ''
+            itemName: d.name || d.名称 || name,
+            category: d.category || d.分类 || d.description || d.说明 || '',
+            description: d.description || d.说明 || d.relatedBacteria || d.关联菌种 || '',
+            suggestion: d.suggestion || d.调整建议 || ''
           }
           self.knowledgeVisible = true
-        } else {
-          // 回退旧版 DB 查询
-          self.queryOldKnowledgeBase(type, name)
+          return
         }
+        // 回退到指标知识库
+        queryIndicatorKnowledge(name).then(function(res2) {
+          if (res2.code === 200 && res2.data) {
+            var v3 = res2.data
+            var high = v3['偏高影响'] || ''
+            var low = v3['偏低影响'] || ''
+            self.knowledgeData = {
+              itemName: v3['名称'] || name,
+              category: v3['说明'] || '',
+              description: (high ? '偏高影响:' + high + '\n' : '') + (low ? '偏低影响:' + low : ''),
+              suggestion: v3['调整建议'] || ''
+            }
+            self.knowledgeVisible = true
+          } else {
+            self.queryOldKnowledgeBase(type, name)
+          }
+        }).catch(function() {
+          self.queryOldKnowledgeBase(type, name)
+        })
       }).catch(function() {
         self.queryOldKnowledgeBase(type, name)
       })
@@ -196,30 +285,207 @@ export default {
 </script>
 
 <style scoped>
-.page { min-height: 100vh; background: #f8f8f8; }
-.nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 0; }
-.content { padding: 20rpx 30rpx; height: calc(100vh - 100rpx); }
-.section-label { font-size: 26rpx; color: #999; margin: 10rpx 0 16rpx; padding-left: 8rpx; }
-.risk-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; border-left: 8rpx solid #10B981; }
+.page { min-height: 100vh; background: #f5f6fa; }
+.nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 30rpx; text-align: right; display: block; }
+
+/* ===== 头部摘要 ===== */
+.header-summary {
+  display: flex;
+  align-items: center;
+  justify-content: space-around;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 32rpx 30rpx;
+  margin: 0 0 16rpx;
+}
+.summary-stat {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.stat-num {
+  font-size: 44rpx;
+  font-weight: 700;
+  color: #fff;
+}
+.stat-abnormal { color: #FFD6D6; }
+.stat-low { color: #d4edda; }
+.stat-total { color: #fff; }
+.stat-label {
+  font-size: 22rpx;
+  color: rgba(255,255,255,0.8);
+  margin-top: 6rpx;
+}
+
+.content { padding: 0 24rpx; height: calc(100vh - 200rpx); }
+
+/* ===== 区块标题 ===== */
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin: 24rpx 0 16rpx;
+}
+.section-line {
+  width: 6rpx;
+  height: 28rpx;
+  border-radius: 3rpx;
+  background: #4A9BD7;
+}
+.section-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+/* ===== 风险卡片 ===== */
+.risk-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 16rpx rgba(0,0,0,0.06);
+  border-left: 10rpx solid #10B981;
+  transition: all 0.2s;
+}
 .risk-level-warning { border-left-color: #F59E0B; }
-.risk-level-high { border-left-color: #C62828; }
+.risk-level-high { border-left-color: #EF4444; }
 .risk-level-abnormal { border-left-color: #C62828; }
-.risk-header { display: flex; align-items: center; margin-bottom: 12rpx; }
-.risk-name { font-size: 30rpx; font-weight: 600; color: #333; flex: 1; }
-.risk-badge { padding: 4rpx 16rpx; border-radius: 20rpx; font-size: 22rpx; }
+
+/* 重要风险卡片背景微 tint */
+.important-card.risk-level-warning { background: linear-gradient(135deg, #fff 60%, #FFF8E1 100%); }
+.important-card.risk-level-high { background: linear-gradient(135deg, #fff 60%, #FFEBEE 100%); }
+.important-card.risk-level-abnormal { background: linear-gradient(135deg, #fff 60%, #FFEBEE 100%); }
+
+.card-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 12rpx;
+}
+.card-title-row {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  gap: 12rpx;
+  min-width: 0;
+}
+.risk-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.risk-badge {
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  font-size: 22rpx;
+  flex-shrink: 0;
+}
 .badge-low { background: #E8F5E9; color: #2E7D32; }
 .badge-warning { background: #FFF3E0; color: #E65100; }
 .badge-high { background: #FFEBEE; color: #C62828; }
-.badge-abnormal { background: #FFEBEE; color: #C62828; }
-.risk-detail-row { display: flex; gap: 24rpx; }
-.risk-value, .risk-level-text { font-size: 24rpx; color: #999; }
-.risk-edit-row { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
+.badge-abnormal { background: #C62828; color: #fff; }
+
+/* 风险进度条 */
+.risk-progress-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 6rpx;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+  width: 160rpx;
+}
+.risk-progress-bg {
+  width: 100%;
+  height: 10rpx;
+  background: #f0f0f0;
+  border-radius: 5rpx;
+  overflow: hidden;
+}
+.risk-progress-fill {
+  height: 100%;
+  border-radius: 5rpx;
+  transition: width 0.6s ease;
+}
+.fill-low { background: #10B981; }
+.fill-warning { background: #F59E0B; }
+.fill-high { background: #EF4444; }
+.fill-abnormal { background: #C62828; }
+.risk-value-text {
+  font-size: 22rpx;
+  color: #999;
+}
+
+/* 疾病说明 */
+.card-desc {
+  display: flex;
+  gap: 6rpx;
+  margin-bottom: 14rpx;
+  padding: 12rpx 16rpx;
+  background: #f8f9fc;
+  border-radius: 10rpx;
+}
+.desc-label {
+  font-size: 22rpx;
+  color: #999;
+  flex-shrink: 0;
+}
+.desc-content {
+  font-size: 22rpx;
+  color: #666;
+  line-height: 1.5;
+  flex: 1;
+}
+
+/* 底部标签行 */
+.risk-detail-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+.risk-tag {
+  font-size: 22rpx;
+  padding: 4rpx 14rpx;
+  border-radius: 8rpx;
+}
+.tag-low { background: #E8F5E9; color: #2E7D32; }
+.tag-warning { background: #FFF3E0; color: #E65100; }
+.tag-high { background: #FFEBEE; color: #C62828; }
+.tag-abnormal { background: #C62828; color: #fff; }
+.risk-value {
+  font-size: 24rpx;
+  color: #666;
+}
+.risk-more {
+  font-size: 24rpx;
+  color: #4A9BD7;
+  margin-left: auto;
+}
+
+/* 编辑行 */
+.risk-edit-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  flex-wrap: wrap;
+  padding-top: 12rpx;
+  border-top: 1rpx solid #f0f0f0;
+  margin-top: 4rpx;
+}
 .edit-label { font-size: 26rpx; color: #666; }
 .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 120rpx; }
 .edit-picker { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; color: #333; }
-.empty-hint { text-align: center; color: #ccc; padding: 60rpx 0; font-size: 28rpx; }
-.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; }
+
+.empty-hint { text-align: center; color: #ccc; padding: 80rpx 0; font-size: 28rpx; }
+.bottom-spacer { height: 40rpx; }
+
+/* 底栏 */
+.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
 .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
 .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
-.bottom-spacer { height: 40rpx; }
 </style>

+ 86 - 2
cfc-frontend/utils/api.js

@@ -3,6 +3,7 @@ import config from '@/config.js'
 const BASE_URL = config.API_BASE_URL
 
 var _redirectingToLogin = false
+var _reloginLock = false
 
 function _clearAuthAndRedirect() {
   if (_redirectingToLogin) return
@@ -21,6 +22,76 @@ function _clearAuthAndRedirect() {
   }, 1500)
 }
 
+// 静默续期成功后回写登录态(token/角色/familyId/openid),保持与 App.vue saveLoginInfo 一致
+function _applyLoginData(data) {
+  uni.setStorageSync('token', data.token)
+  uni.setStorageSync('userId', data.userId)
+  uni.setStorageSync('role', data.role)
+  uni.setStorageSync('currentRole', data.role)
+  uni.setStorageSync('isSwitchedChild', false)
+  uni.setStorageSync('isSwitchedTeacher', false)
+  if (data.openid) {
+    uni.setStorageSync('openid', data.openid)
+  }
+  uni.setStorageSync('userInfo', {
+    nickname: data.nickname,
+    userId: data.userId,
+    familyId: data.familyId
+  })
+  try {
+    var app = typeof getApp === 'function' ? getApp() : null
+    if (app && app.$store) {
+      app.$store.commit('setToken', data.token)
+      app.$store.commit('setUserInfo', {
+        userId: data.userId,
+        role: data.role,
+        familyId: data.familyId,
+        nickname: data.nickname
+      })
+      app.$store.commit('setFamilyId', data.familyId)
+    }
+  } catch (e) { /* 非页面环境无 Vuex,静默忽略 */ }
+}
+
+// 401 处理:优先用缓存 openid 静默续期并重放原请求;续期失败或无 openid 时回退跳登录页
+function _handle401(url, method, data, options, resolve, reject, resData) {
+  // 续期接口自身 401 / 重试仍 401 时,避免递归续期死循环
+  if (_reloginLock) {
+    _clearAuthAndRedirect()
+    reject(resData)
+    return
+  }
+  var openid = uni.getStorageSync('openid')
+  if (!openid) {
+    _clearAuthAndRedirect()
+    reject(resData)
+    return
+  }
+  _reloginLock = true
+  autoLogin(openid).then(function(res) {
+    if (res && res.data && res.data.token) {
+      console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
+      _applyLoginData(res.data)
+      // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
+      request(url, method, data, options).then(function(r) {
+        _reloginLock = false
+        resolve(r)
+      }, function(e) {
+        _reloginLock = false
+        reject(e)
+      })
+    } else {
+      _reloginLock = false
+      _clearAuthAndRedirect()
+      reject(resData)
+    }
+  }).catch(function() {
+    _reloginLock = false
+    _clearAuthAndRedirect()
+    reject(resData)
+  })
+}
+
 const request = (url, method = 'POST', data = {}, options = {}) => {
   // Append query params from options to URL if provided (e.g., { guideId } for GET requests)
   if (options && Object.keys(options).length > 0) {
@@ -63,8 +134,7 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
       success: (res) => {
         if (res.statusCode === 401 || res.data.code === 401) {
           console.warn('[API] 401 ' + method + ' ' + fullUrl)
-          _clearAuthAndRedirect()
-          reject(res.data)
+          _handle401(url, method, data, options, resolve, reject, res.data)
           return
         }
         // 5001: 用户无家庭 — resolve 带标记,不 reject;由组件层各自渲染 FamilyEmptyState
@@ -2281,6 +2351,14 @@ export const ignoreSelfCheck = (data) => {
   return request('/api/family/self-check/ignore', 'POST', data || {})
 }
 
+export const generateSelfCheckPlan = (data) => {
+  return request('/api/family/self-check/generate-plan', 'POST', data || {})
+}
+
+export const getSelfCheckTrendAnalysis = () => {
+  return request('/api/family/self-check/trend-analysis', 'POST', {})
+}
+
 // ===== 舌诊相关(两阶段流程) =====
 export const parseTonguePreview = (data) => request('/api/health/report/parse-preview?type=tongue', 'POST', data)
 export const confirmTongue = (data) => request('/api/health/report/confirm?type=tongue', 'POST', data)
@@ -2452,6 +2530,11 @@ export const queryIndicatorKnowledge = (name) => {
   return request('/api/health/knowledge/v3/indicator/query', 'POST', { name })
 }
 
+// 查询疾病知识库(统一知识库 disease/query)
+export const queryDiseaseKnowledge = (name) => {
+  return request('/api/health/knowledge/unified/disease/query', 'POST', { name })
+}
+
 // 查询食物营养数据
 export const queryFoodKnowledge = (name) => {
   return request('/api/health/knowledge/v3/food/query', 'POST', { name })
@@ -2521,6 +2604,7 @@ export const getDietPreferences = (data) => request('/api/diet/preferences/curre
 export const saveDietPreferences = (data) => request('/api/diet/preferences/save', 'POST', data)
 export const getDietIngredients = () => request('/api/diet/ingredients/suggest', 'POST', {})
 export const refreshDietIngredients = () => request('/api/diet/ingredients/recommend', 'POST', {})
+export const searchDietFoods = (keyword) => request('/api/diet/ingredients/search', 'POST', { keyword })
 export const addDietIngredient = (data) => request('/api/diet/ingredients/add', 'POST', data)
 export const removeDietIngredient = (data) => request('/api/diet/ingredients/remove', 'POST', data)
 export const confirmDietIngredients = (data) => request('/api/diet/ingredients/confirm', 'POST', data)

+ 1 - 0
cfc-langgraph/app/api/chat.py

@@ -28,6 +28,7 @@ async def chat(req: ChatRequest):
         "child_id": req.context.child_id if req.context else None,
         "intent": None,
         "context": None,
+        "self_check_result": req.context.self_check_result if req.context and req.context.self_check_result else None,
         "messages": None,
         "answer": None,
         "tasks": [],

+ 54 - 0
cfc-langgraph/app/api/self_check.py

@@ -0,0 +1,54 @@
+import logging
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Any, Dict, Optional
+from app.graphs.self_check_analysis_graph import get_graph
+from app.graphs.self_check_trend_graph import get_trend_graph
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["self-check"])
+
+
+class SelfCheckAnalysisRequest(BaseModel):
+    scores: Dict[str, Any]
+    question_ids: list
+    user_id: int
+    recent_history: Optional[list] = None
+
+
+class SelfCheckTrendRequest(BaseModel):
+    history: list
+    user_id: int
+
+
+@router.post("/self-check/analysis")
+async def self_check_analysis(req: SelfCheckAnalysisRequest):
+    graph = get_graph()
+    state = {
+        "scores": req.scores,
+        "question_ids": req.question_ids,
+        "user_id": req.user_id,
+        "recent_history": req.recent_history or [],
+        "advice": None,
+        "error": None,
+    }
+    result = await graph.ainvoke(state)
+    advice = result.get("advice") or {}
+    return {
+        "advice_json": advice.get("advice_json"),
+        "fallback_used": advice.get("fallback_used", True),
+        "error": result.get("error"),
+    }
+
+
+@router.post("/self-check/trend")
+async def self_check_trend(req: SelfCheckTrendRequest):
+    graph = get_trend_graph()
+    state = {"history": req.history, "user_id": req.user_id, "insight": None, "error": None}
+    result = await graph.ainvoke(state)
+    insight = result.get("insight") or {}
+    return {
+        "aiInsight": insight.get("aiInsight", ""),
+        "trendSummary": insight.get("trendSummary", ""),
+        "error": result.get("error"),
+    }

+ 23 - 0
cfc-langgraph/app/graphs/chat_graph.py

@@ -27,6 +27,7 @@ class ChatState(TypedDict):
     child_id: int | None
     intent: Intent | None
     context: dict | None
+    self_check_result: str | None  # P1-2: 自检结果 JSON
     messages: list | None
     answer: str | None
     tasks: list[dict]
@@ -96,6 +97,28 @@ def create_chat_graph():
         if portrait_text:
             messages.insert(1, SystemMessage(content=portrait_text))
 
+        # P1-2: 自检结果注入(如有)
+        self_check_result = state.get("self_check_result")
+        if self_check_result:
+            try:
+                import json as _json
+                sc = _json.loads(self_check_result)
+                sc_text = f"用户最近一次五维自检结果:\n总分 {sc.get('totalScore', 0)}/45,"
+                dims = sc.get('dimensions') or sc.get('scores') or {}
+                if isinstance(dims, list):
+                    dim_lines = [f"{d.get('name','')}: {d.get('score',0)}分" for d in dims]
+                elif isinstance(dims, dict):
+                    dim_lines = [f"{k}: {v}分" for k, v in dims.items()]
+                else:
+                    dim_lines = [str(dims)]
+                sc_text += " | ".join(dim_lines)
+                insight = sc.get('familyInsight') or sc.get('aiInsight')
+                if insight:
+                    sc_text += f"\nAI 洞察: {insight}"
+                messages.insert(1, SystemMessage(content=sc_text))
+            except Exception as _e:
+                logger.warning("解析 self_check_result 失败: %s", _e)
+
         # 注入家庭上下文 + 用户健康现状
         ctx = state.get("context", {})
         if isinstance(ctx, dict):

+ 116 - 0
cfc-langgraph/app/graphs/self_check_analysis_graph.py

@@ -0,0 +1,116 @@
+import json
+import logging
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.llm.client import get_llm
+from app.monitoring import monitor_agent
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一位家庭健康顾问,基于五维自检结果(身·智·富·行·心,每维0-9分,满分45)给出个性化建议。
+
+要求:
+1. 对每个低分维度(≤6分)给出1-2句解读和2-3个具体可执行的微行动
+2. 如有历史数据,简要对比趋势(改善/下滑)
+3. 语气温暖口语化,每条解读不超过80字
+4. 最后给出1句家庭整体洞察(30字以内)
+
+返回 JSON(严格格式,不要额外文字):
+{
+  "advice": [
+    {
+      "dimension": "mind",
+      "dimensionName": "心",
+      "interpretation": "你的情绪能量偏低,可能最近压力较大,建议...",
+      "microActions": ["今晚睡前做10分钟深呼吸", "和伴侣约定每周一次夜谈"],
+      "fallbackUsed": false
+    }
+  ],
+  "familyInsight": "建议从行动维度入手,关系顺畅了内心才能安定"
+}
+"""
+
+
+class GraphState(TypedDict):
+    scores: dict
+    question_ids: list
+    user_id: int
+    recent_history: list
+    advice: Optional[dict]
+    error: Optional[str]
+
+
+class SelfCheckAnalysisAgent:
+    def __init__(self):
+        self.llm = get_llm()
+
+    @monitor_agent("self_check_analysis")
+    async def run(self, scores: dict, question_ids: list, user_id: int, recent_history: list) -> dict:
+        try:
+            score_summary = "\n".join(
+                f"{v.get('dimensionName', k)}({k}): {v.get('score', 0)}分"
+                for k, v in scores.items()
+            )
+            history_summary = ""
+            if recent_history:
+                history_summary = "历史趋势:\n" + "\n".join(
+                    f"- {h.get('createdAt', '')}: 总分{h.get('totalScore', 0)}分"
+                    for h in recent_history[:3]
+                )
+            messages = [
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=f"用户ID: {user_id}\n当前自检得分:\n{score_summary}\n{history_summary}"),
+            ]
+            response = await self.llm.ainvoke(messages)
+            text = response.content.strip()
+            if "```json" in text:
+                text = text.split("```json")[1].split("```")[0].strip()
+            elif "```" in text:
+                text = text.split("```")[1].split("```")[0].strip()
+            data = json.loads(text)
+            advice_list = data.get("advice", [])
+            for item in advice_list:
+                item.setdefault("fallbackUsed", False)
+            if advice_list:
+                advice_list[0]["familyInsight"] = data.get("familyInsight", "")
+            return {"advice": {"advice_json": json.dumps(advice_list, ensure_ascii=False), "fallback_used": False}}
+        except Exception as e:
+            logger.warning("自检建议生成失败: %s", e)
+            return {"advice": {"advice_json": None, "fallback_used": True}}
+
+
+def build_graph():
+    agent = SelfCheckAnalysisAgent()
+
+    def parse_input(state: GraphState) -> GraphState:
+        return state
+
+    async def call_llm(state: GraphState) -> dict:
+        return await agent.run(state["scores"], state["question_ids"], state["user_id"], state["recent_history"])
+
+    def validate(state: GraphState) -> GraphState:
+        adv = state.get("advice")
+        if adv is None or adv.get("advice_json") is None:
+            return {**state, "error": "AI 建议生成失败"}
+        return state
+
+    graph = StateGraph(GraphState)
+    graph.add_node("parse", parse_input)
+    graph.add_node("llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "parse")
+    graph.add_edge("parse", "llm")
+    graph.add_edge("llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_graph = None
+
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = build_graph()
+    return _graph

+ 76 - 0
cfc-langgraph/app/graphs/self_check_trend_graph.py

@@ -0,0 +1,76 @@
+import json
+import logging
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.llm.client import get_llm
+from app.monitoring import monitor_agent
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """分析用户近3次五维自检趋势,输出:
+- aiInsight: 趋势解读(2-3句,指出最大变化维度和可能原因,口语化)
+- trendSummary: 各维度 delta 简写(如"身-2 智+1 富0 行-1 心+2")
+返回 JSON:{"aiInsight": "...", "trendSummary": "..."}
+"""
+
+
+class TrendState(TypedDict):
+    history: list
+    user_id: int
+    insight: Optional[dict]
+    error: Optional[str]
+
+
+class SelfCheckTrendAgent:
+    def __init__(self):
+        self.llm = get_llm()
+
+    @monitor_agent("self_check_trend")
+    async def run(self, history: list, user_id: int) -> dict:
+        try:
+            if len(history) < 2:
+                return {"insight": {"aiInsight": "自检次数不足,建议完成至少2次自检后查看趋势", "trendSummary": ""}, "error": None}
+            history_text = "\n".join(
+                f"{h.get('createdAt', '')}: 总分{h.get('totalScore', 0)}," +
+                " ".join(f"{d.get('name','')}{d.get('score',0)}分" for d in h.get('dimensions', []))
+                for h in history[-3:]
+            )
+            messages = [
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=f"用户{user_id}的自检历史:\n{history_text}"),
+            ]
+            response = await self.llm.ainvoke(messages)
+            text = response.content.strip()
+            if "```json" in text:
+                text = text.split("```json")[1].split("```")[0].strip()
+            elif "```" in text:
+                text = text.split("```")[1].split("```")[0].strip()
+            data = json.loads(text)
+            return {"insight": data, "error": None}
+        except Exception as e:
+            logger.warning("趋势分析失败: %s", e)
+            return {"insight": {"aiInsight": "", "trendSummary": ""}, "error": str(e)}
+
+
+def build_trend_graph():
+    agent = SelfCheckTrendAgent()
+
+    async def call_llm(state: TrendState) -> dict:
+        return await agent.run(state["history"], state["user_id"])
+
+    graph = StateGraph(TrendState)
+    graph.add_node("llm", call_llm)
+    graph.add_edge(START, "llm")
+    graph.add_edge("llm", END)
+    return graph.compile()
+
+
+_trend_graph = None
+
+
+def get_trend_graph():
+    global _trend_graph
+    if _trend_graph is None:
+        _trend_graph = build_trend_graph()
+    return _trend_graph

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


+ 3 - 0
cfc-langgraph/app/llm/client.py

@@ -0,0 +1,3 @@
+from src.llm.client import get_llm
+
+__all__ = ["get_llm"]

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

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -28,6 +28,7 @@ app.include_router(meal.router)
 app.include_router(audio.router)
 app.include_router(logs.router)
 app.include_router(innate_portrait.router)
+app.include_router(self_check.router)
 
 
 @app.on_event("startup")

+ 1 - 0
cfc-langgraph/app/models/common.py

@@ -9,6 +9,7 @@ class UserContext(BaseModel):
     survey_id: Optional[int] = None
     family_id: Optional[int] = None
     mascot_code: Optional[str] = None
+    self_check_result: Optional[str] = None  # P1-2: 自检结果 JSON(五维得分+历史)
 
 
 class SourceInfo(BaseModel):

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-fa0c8af5cb98dfd2aa7d677306817f53cb3d79b2
+d01181d28ae6aeb77c625aa8db05b00b8256312a

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,167 @@
 
 ---
 
+## v1.0.1281 (2026-09-02)
+
+### 其他
+- 内容: 评论通知相关收尾 - opencode_client 过滤纯空白回复; listener 转发消息到 opencode 会话生成回复; vbs 注释编码修正
+- 内容: 评论通知改造: 评论数增量检测+读取新评论+opencode生成针对性回复建议, 无新增不推送; 修复 xhs_feishu/opencode_client import 时劫持 stdout 导致输出关闭
+- 内容: 评论通知按笔记生成针对性回复建议
+- 内容: 评论通知加回复建议 + 三张整页截图 + 字段顺序修正
+- 内容: 三张整页截图(首页/笔记/账号概览) + 评论通知改简洁文本 + 字段顺序修正
+- 内容: 修正笔记列表字段顺序(阅读/评论/点赞/收藏/分享)
+- 内容: 添加评论检测脚本和启动脚本
+- 内容: 修复数据采集双周期 + 截图上传 + 近30日tab点击等待
+- 内容: 修复数据采集近7日/近30日双周期 + 截图上传修复
+- 内容: 添加机器人启动脚本(bat/vbs)供计划任务调用
+- 内容: 修复机器人reply接口多余receive_id_type参数
+- 内容: 小红书数据机器人双向监听(飞书长连接WebSocket版)
+- 内容: 修复日报脚本运营文案目录路径计算
+- 内容: 小红书创作者中心每日日报采集与飞书推送模块
+
+
+## v1.0.1280 (2026-09-02)
+
+### 文档
+- 饮食页面换一批/生成食谱端到端修复实现计划
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 前端生成食谱/换一批取值修复 + 添加食材弹窗搜索
+- 生成食谱使用所选食材+共餐参与者,归一化LangGraph菜单
+- 换一批seed生效 + 食材搜索接口
+
+### Bug 修复
+- 修复知识库弹窗长文本超出右边界
+
+
+## v1.0.1279 (2026-09-02)
+
+### 新功能
+- 美化菌群报告疾病风险页并补充疾病知识库,手风琴标题去掉有效保留异常/共
+
+
+## v1.0.1278 (2026-09-01)
+
+### 文档
+- 修正实现计划占位(getEffectivePpoint 签名/转让幂等键/家庭券库独立Controller)
+
+
+## v1.0.1277 (2026-09-01)
+
+### Bug 修复
+- 迁移编号 269 与 sleep_record 冲突,重编号为 278
+
+### 新功能
+- 优惠券全链路改为家庭维度
+
+### 其他
+- - 6条发放路径全部改为发到家庭(Admin/JOIN/POPULATION/PERIODIC/EXCHANGE/CF_EXCHANGE)
+- - CouponService 内部改为读 family_coupon,保留 API 路径与响应字段不变
+- - AdminCouponController 新增 /issue-family,/issue 改为 familyIds
+- - 家庭管理页新增发放优惠券弹窗,批量发放与发券记录改为家庭维度
+- 
+
+
+## v1.0.1276 (2026-09-01)
+
+### Bug 修复
+- 修复 P1-2 自检上下文在 Pydantic 层被静默丢弃
+
+### 文档
+- CF值分佣体系重构实现计划(cfclub分支)
+
+### 其他
+- - T1 数据库迁移(cf_rate_tier/cf_transfer_record 建表 + promotion_tier 精简 + coupon family_id)
+- - T2 新实体/Mapper(CfRateTier / CfTransferRecord)
+- - T3 PlatformPointsService.earn 幂等补丁
+- - T4 CfReferralService(推荐树物化 + 全层级团队规模 + 阶梯匹配)
+- - T5 CfCommissionService 统一分佣核心
+- - T6 推荐绑定物化 + PromotionTier 精简 + refreshRate 刷等级比例
+- - T7 7 处订单结算迁移点切换
+- - T8 旧佣金体系废弃标记
+- - T9 用户查询接口(团队规模/比例/流水/汇总)
+- - T10 CF 转让接口
+- - T11 优惠券绑定家庭 + 家庭券库
+- - T12 管理端阶梯配置接口
+- - T13 小程序推广中心前端改造
+- - T14 测试与 API 文档更新
+- 
+
+
+## v1.0.1275 (2026-09-01)
+
+### Bug 修复
+- 会员中心补listMembershipLevels导入 - 修复等级配置表加载ReferenceError, 等级标签补PREMIUM
+- 用户管理会员等级下拉改为后台动态加载5级, 优惠券管理等级选项同步动态化
+
+
+## v1.0.1274 (2026-09-01)
+
+### 新功能
+- CF值分佣体系重构设计(cfclub分支)
+
+### 其他
+- 设计范围:
+- - 全部订单统一走 CF 值分佣,废弃旧佣金体系
+- - 纯团队规模阶梯分佣(cf_rate_tier 动态可配置)
+- - 同家庭互推上溯到第一个非同家庭引荐人
+- - 返佣返个人 CF 钱包,兑换优惠券挂家庭
+- - 新增 CfCommissionService + cf_transfer_record
+- - 用户可查团队规模与返佣比例
+- - 订单迁移点 7 处 + 前端改造方案
+- 
+
+
+## v1.0.1273 (2026-09-01)
+
+### 新功能
+- 中间页展示自检趋势 AI 解读(P1-1)
+- 结果页改 AI 解读 + 生成计划按钮 + 问问AI(P0-1/P0-2/P1-2)
+- 新增 generateSelfCheckPlan/getSelfCheckTrendAnalysis API
+- AIChat sendMessage 支持 selfCheckId 注入上下文(P1-2)
+- 新增 /generate-plan + /trend-analysis 接口 + HealthPlanService.generateFromSelfCheck
+- 新增 MemberChipPicker 多选成员选择组件,修复 health-plan-summary 缺失组件编译失败
+
+### Bug 修复
+- 删除 WuxingSourcingService 死 import
+
+
+## v1.0.1272 (2026-09-01)
+
+### 新功能
+- 清理 WuxingSourcingService 静态表 + submitSelfCheck 接 AI
+
+
+## v1.0.1271 (2026-09-01)
+
+### 新功能
+- AiGateway 新增 generateSelfCheckAdvice/generateSelfCheckTrend
+- WuxingSourcingAdviceVO 改造为 AI 建议 VO + SelfCheckAnalysisService
+- 新增 self_check 路由模块并注册到 main
+- 新增 self_check_trend_graph 趋势分析实现
+- 新增 self_check_analysis_graph + llm.client 薄包装
+
+### 文档
+- PROJECT-OVERVIEW 添加 AI 集成实现计划索引
+- 五维自检 AI 结合 P0+P1 实现计划
+- PROJECT-OVERVIEW 添加 AI 集成规格索引
+- 五维自检 AI 集成 P0+P1 设计稿(纯AI建议+计划生成+趋势+Chat注入)
+- 优惠券全链路改为家庭维度设计文档
+
+### Bug 修复
+- 迁移编号 273/274 → 276/277,避开并发 dan_report/users 迁移占用
+
+
 ## v1.0.1270 (2026-08-31)
 
 ### 其他

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1270
+> 当前版本: v1.0.1281
 
 ## 历史版本
 
@@ -8,6 +8,167 @@
 
 ---
 
+## v1.0.1281 (2026-09-02)
+
+### 其他
+- 内容: 评论通知相关收尾 - opencode_client 过滤纯空白回复; listener 转发消息到 opencode 会话生成回复; vbs 注释编码修正
+- 内容: 评论通知改造: 评论数增量检测+读取新评论+opencode生成针对性回复建议, 无新增不推送; 修复 xhs_feishu/opencode_client import 时劫持 stdout 导致输出关闭
+- 内容: 评论通知按笔记生成针对性回复建议
+- 内容: 评论通知加回复建议 + 三张整页截图 + 字段顺序修正
+- 内容: 三张整页截图(首页/笔记/账号概览) + 评论通知改简洁文本 + 字段顺序修正
+- 内容: 修正笔记列表字段顺序(阅读/评论/点赞/收藏/分享)
+- 内容: 添加评论检测脚本和启动脚本
+- 内容: 修复数据采集双周期 + 截图上传 + 近30日tab点击等待
+- 内容: 修复数据采集近7日/近30日双周期 + 截图上传修复
+- 内容: 添加机器人启动脚本(bat/vbs)供计划任务调用
+- 内容: 修复机器人reply接口多余receive_id_type参数
+- 内容: 小红书数据机器人双向监听(飞书长连接WebSocket版)
+- 内容: 修复日报脚本运营文案目录路径计算
+- 内容: 小红书创作者中心每日日报采集与飞书推送模块
+
+
+## v1.0.1280 (2026-09-02)
+
+### 文档
+- 饮食页面换一批/生成食谱端到端修复实现计划
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 前端生成食谱/换一批取值修复 + 添加食材弹窗搜索
+- 生成食谱使用所选食材+共餐参与者,归一化LangGraph菜单
+- 换一批seed生效 + 食材搜索接口
+
+### Bug 修复
+- 修复知识库弹窗长文本超出右边界
+
+
+## v1.0.1279 (2026-09-02)
+
+### 新功能
+- 美化菌群报告疾病风险页并补充疾病知识库,手风琴标题去掉有效保留异常/共
+
+
+## v1.0.1278 (2026-09-01)
+
+### 文档
+- 修正实现计划占位(getEffectivePpoint 签名/转让幂等键/家庭券库独立Controller)
+
+
+## v1.0.1277 (2026-09-01)
+
+### Bug 修复
+- 迁移编号 269 与 sleep_record 冲突,重编号为 278
+
+### 新功能
+- 优惠券全链路改为家庭维度
+
+### 其他
+- - 6条发放路径全部改为发到家庭(Admin/JOIN/POPULATION/PERIODIC/EXCHANGE/CF_EXCHANGE)
+- - CouponService 内部改为读 family_coupon,保留 API 路径与响应字段不变
+- - AdminCouponController 新增 /issue-family,/issue 改为 familyIds
+- - 家庭管理页新增发放优惠券弹窗,批量发放与发券记录改为家庭维度
+- 
+
+
+## v1.0.1276 (2026-09-01)
+
+### Bug 修复
+- 修复 P1-2 自检上下文在 Pydantic 层被静默丢弃
+
+### 文档
+- CF值分佣体系重构实现计划(cfclub分支)
+
+### 其他
+- - T1 数据库迁移(cf_rate_tier/cf_transfer_record 建表 + promotion_tier 精简 + coupon family_id)
+- - T2 新实体/Mapper(CfRateTier / CfTransferRecord)
+- - T3 PlatformPointsService.earn 幂等补丁
+- - T4 CfReferralService(推荐树物化 + 全层级团队规模 + 阶梯匹配)
+- - T5 CfCommissionService 统一分佣核心
+- - T6 推荐绑定物化 + PromotionTier 精简 + refreshRate 刷等级比例
+- - T7 7 处订单结算迁移点切换
+- - T8 旧佣金体系废弃标记
+- - T9 用户查询接口(团队规模/比例/流水/汇总)
+- - T10 CF 转让接口
+- - T11 优惠券绑定家庭 + 家庭券库
+- - T12 管理端阶梯配置接口
+- - T13 小程序推广中心前端改造
+- - T14 测试与 API 文档更新
+- 
+
+
+## v1.0.1275 (2026-09-01)
+
+### Bug 修复
+- 会员中心补listMembershipLevels导入 - 修复等级配置表加载ReferenceError, 等级标签补PREMIUM
+- 用户管理会员等级下拉改为后台动态加载5级, 优惠券管理等级选项同步动态化
+
+
+## v1.0.1274 (2026-09-01)
+
+### 新功能
+- CF值分佣体系重构设计(cfclub分支)
+
+### 其他
+- 设计范围:
+- - 全部订单统一走 CF 值分佣,废弃旧佣金体系
+- - 纯团队规模阶梯分佣(cf_rate_tier 动态可配置)
+- - 同家庭互推上溯到第一个非同家庭引荐人
+- - 返佣返个人 CF 钱包,兑换优惠券挂家庭
+- - 新增 CfCommissionService + cf_transfer_record
+- - 用户可查团队规模与返佣比例
+- - 订单迁移点 7 处 + 前端改造方案
+- 
+
+
+## v1.0.1273 (2026-09-01)
+
+### 新功能
+- 中间页展示自检趋势 AI 解读(P1-1)
+- 结果页改 AI 解读 + 生成计划按钮 + 问问AI(P0-1/P0-2/P1-2)
+- 新增 generateSelfCheckPlan/getSelfCheckTrendAnalysis API
+- AIChat sendMessage 支持 selfCheckId 注入上下文(P1-2)
+- 新增 /generate-plan + /trend-analysis 接口 + HealthPlanService.generateFromSelfCheck
+- 新增 MemberChipPicker 多选成员选择组件,修复 health-plan-summary 缺失组件编译失败
+
+### Bug 修复
+- 删除 WuxingSourcingService 死 import
+
+
+## v1.0.1272 (2026-09-01)
+
+### 新功能
+- 清理 WuxingSourcingService 静态表 + submitSelfCheck 接 AI
+
+
+## v1.0.1271 (2026-09-01)
+
+### 新功能
+- AiGateway 新增 generateSelfCheckAdvice/generateSelfCheckTrend
+- WuxingSourcingAdviceVO 改造为 AI 建议 VO + SelfCheckAnalysisService
+- 新增 self_check 路由模块并注册到 main
+- 新增 self_check_trend_graph 趋势分析实现
+- 新增 self_check_analysis_graph + llm.client 薄包装
+
+### 文档
+- PROJECT-OVERVIEW 添加 AI 集成实现计划索引
+- 五维自检 AI 结合 P0+P1 实现计划
+- PROJECT-OVERVIEW 添加 AI 集成规格索引
+- 五维自检 AI 集成 P0+P1 设计稿(纯AI建议+计划生成+趋势+Chat注入)
+- 优惠券全链路改为家庭维度设计文档
+
+### Bug 修复
+- 迁移编号 273/274 → 276/277,避开并发 dan_report/users 迁移占用
+
+
 ## v1.0.1270 (2026-08-31)
 
 ### 其他

+ 8 - 0
cfc-web/src/api/coupon.js

@@ -20,6 +20,14 @@ export function batchIssueCoupons(data) {
   return request({ url: '/api/admin/coupon/issue', method: 'post', data })
 }
 
+export function issueFamilyCoupon(data) {
+  return request({ url: '/api/admin/coupon/issue-family', method: 'post', data })
+}
+
+export function getFamilyCouponGrantLog(data) {
+  return request({ url: '/api/admin/coupon/grant-log', method: 'post', data })
+}
+
 export function getCouponGrantLog(data) {
   return request({ url: '/api/admin/coupon/grant-log', method: 'post', data })
 }

+ 83 - 1
cfc-web/src/views/Families.vue

@@ -55,6 +55,7 @@
               </el-button>
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
+                <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
             </el-dropdown>
@@ -143,11 +144,42 @@
         <el-empty v-else description="暂无孩子" :image-size="60"></el-empty>
       </div>
     </el-dialog>
+
+    <!-- 发放优惠券弹窗 -->
+    <el-dialog
+      title="发放优惠券"
+      :visible.sync="couponDialogVisible"
+      width="500px"
+    >
+      <el-form :model="couponForm" label-width="100px">
+        <el-form-item label="家庭">
+          <el-input :value="couponForm.familyName" disabled></el-input>
+        </el-form-item>
+        <el-form-item label="优惠券" required>
+          <el-select v-model="couponForm.couponId" placeholder="请选择优惠券" style="width: 100%">
+            <el-option
+              v-for="c in couponOptions"
+              :key="c.id"
+              :label="couponLabel(c)"
+              :value="c.id"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="发放数量">
+          <el-input-number v-model="couponForm.quantity" :min="1" :max="100" :step="1"></el-input-number>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="couponDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
+import { getCouponList, issueFamilyCoupon } from '@/api/coupon'
 
 export default {
   name: 'Families',
@@ -184,7 +216,15 @@ export default {
         family: null,
         parents: [],
         children: []
-      }
+      },
+      couponDialogVisible: false,
+      couponForm: {
+        familyId: null,
+        couponId: null,
+        quantity: 1
+      },
+      couponOptions: [],
+      couponSubmitting: false
     }
   },
   mounted() {
@@ -199,6 +239,7 @@ export default {
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
+        case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
       }
@@ -300,6 +341,47 @@ export default {
         this.loading = false
       }
     },
+    async handleIssueCoupon(row) {
+      this.couponForm = {
+        familyId: row.id,
+        familyName: row.name,
+        couponId: null,
+        quantity: 1
+      }
+      this.couponDialogVisible = true
+      try {
+        const res = await getCouponList({ page: 1, size: 999 })
+        this.couponOptions = res.data.records || res.data.list || res.data || []
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      }
+    },
+    couponLabel(c) {
+      if (c.type === 'DISCOUNT') {
+        return `${c.name}(${(c.discountRate / 100).toFixed(1)}折)`
+      }
+      return `${c.name}(${(c.value / 100).toFixed(2)}元)`
+    },
+    async confirmIssueCoupon() {
+      if (!this.couponForm.couponId) {
+        this.$message.warning('请选择优惠券')
+        return
+      }
+      this.couponSubmitting = true
+      try {
+        await issueFamilyCoupon({
+          familyId: this.couponForm.familyId,
+          couponId: this.couponForm.couponId,
+          quantity: this.couponForm.quantity
+        })
+        this.$message.success('发放成功')
+        this.couponDialogVisible = false
+      } catch (error) {
+        this.$message.error(error.message || '发放失败')
+      } finally {
+        this.couponSubmitting = false
+      }
+    },
     formatDate(dateStr) {
       if (!dateStr) return '-'
       const date = new Date(dateStr)

+ 29 - 6
cfc-web/src/views/Users.vue

@@ -174,6 +174,16 @@
             ></el-option>
           </el-select>
         </el-form-item>
+        <el-form-item label="会员等级">
+          <el-select v-model="editForm.memberLevel" clearable placeholder="选择会员等级" style="width: 100%">
+            <el-option
+              v-for="lv in memberLevelOptions"
+              :key="lv.levelCode"
+              :label="lv.levelName + '(' + lv.levelCode + ')'"
+              :value="lv.levelCode"
+            ></el-option>
+          </el-select>
+        </el-form-item>
         <el-form-item label="角色">
           <el-checkbox-group v-model="editForm.roles">
             <el-checkbox
@@ -228,7 +238,7 @@
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
-import { getUserList, updateUser, deleteUser, resetUserPassword, searchUsers } from '@/api/admin'
+import { getUserList, updateUser, deleteUser, resetUserPassword, searchUsers, listMembershipLevels } from '@/api/admin'
 
 import { getRoleLabel } from '@/utils/permissions'
 
@@ -270,7 +280,8 @@ export default {
         realName: '',
         phone: '',
         roles: [],
-        referrerId: null
+        referrerId: null,
+        memberLevel: ''
       },
       editRules: {
         nickname: [
@@ -290,10 +301,12 @@ export default {
       ],
       userSearchResults: [],
       userSearchLoading: false,
+      memberLevelOptions: [],
     }
   },
   mounted() {
     this.loadUsers()
+    this.loadMemberLevels()
   },
   methods: {
     loadList() {
@@ -327,6 +340,15 @@ const res = await getUserList(params)
         this.loading = false
       }
     },
+    async loadMemberLevels() {
+      try {
+        const res = await listMembershipLevels()
+        const list = res.data || []
+        this.memberLevelOptions = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' && l.levelCode }) : []
+      } catch (e) {
+        this.memberLevelOptions = []
+      }
+    },
     handleSearch() {
       this.pagination.current = 1
       this.loadUsers()
@@ -358,7 +380,8 @@ const res = await getUserList(params)
         realName: row.realName || '',
         phone: row.phone || '',
         roles: row.roles ? [...row.roles] : (row.role ? [row.role] : []),
-        referrerId: row.referrerId || null
+        referrerId: row.referrerId || null,
+        memberLevel: row.memberLevel || ''
       }
       this.editDialogVisible = true
     },
@@ -447,11 +470,11 @@ const res = await getUserList(params)
       return String(dateStr).replace('T', ' ').substring(0, 19)
     },
     memberLevelLabel(level) {
-      const map = { FREE: '免费', FAMILY: '家庭会员', PROVIDER: '服务商' }
-      return map[level] || '免费'
+      const map = { FREE: '免费会员', FAMILY: '家庭会员', PROVIDER: '标准会员', PREMIUM: '高级家庭会员', LIFETIME: '终身会员' }
+      return map[level] || '免费会员'
     },
     memberLevelType(level) {
-      const map = { FREE: 'info', FAMILY: 'success', PROVIDER: 'warning' }
+      const map = { FREE: 'info', FAMILY: 'success', PROVIDER: 'warning', PREMIUM: 'primary', LIFETIME: 'danger' }
       return map[level] || 'info'
     },
     async searchUsersForReferrer(query) {

+ 19 - 20
cfc-web/src/views/admin/CouponGrantLog.vue

@@ -4,7 +4,7 @@
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">发券记录</span>
         <div class="admin-page-actions">
-          <el-input v-model="userId" placeholder="用户ID/用户名" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-input v-model="familyId" placeholder="家庭ID" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
           <el-input v-model="couponId" placeholder="优惠券ID" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
           <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
         </div>
@@ -13,8 +13,8 @@
       <div class="table-scroll-wrap-sm">
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
           <el-table-column prop="id" label="ID" width="80" />
-          <el-table-column label="用户" width="140">
-            <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
+          <el-table-column label="家庭" width="140">
+            <template slot-scope="{ row }">{{ row.familyName || '家庭' + row.familyId }}</template>
           </el-table-column>
           <el-table-column prop="couponId" label="优惠券ID" width="110" />
           <el-table-column label="发放类型" width="120">
@@ -46,8 +46,8 @@
 </template>
 
 <script>
-import { getCouponGrantLog } from '@/api/coupon'
-import { searchUsers } from '@/api/admin'
+import { getFamilyCouponGrantLog } from '@/api/coupon'
+import { getFamilyDetail } from '@/api/admin'
 
 export default {
   name: 'CouponGrantLog',
@@ -63,7 +63,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
-      userId: '',
+      familyId: '',
       couponId: ''
     }
   },
@@ -72,26 +72,26 @@ export default {
   },
   methods: {
     grantTypeLabel(val) {
-      const map = { JOIN: '开通赠券', PERIODIC: '周期补发', POPULATION: '家庭人口券', EXCHANGE: '积分兑换' }
+      const map = { JOIN: '开通赠券', PERIODIC: '周期补发', POPULATION: '家庭人口券', EXCHANGE: '积分兑换', CF_EXCHANGE: 'CF值兑换' }
       return map[val] || val
     },
     grantTypeTagType(val) {
-      const map = { JOIN: 'success', PERIODIC: 'primary', POPULATION: 'warning', EXCHANGE: 'danger' }
+      const map = { JOIN: 'success', PERIODIC: 'primary', POPULATION: 'warning', EXCHANGE: 'danger', CF_EXCHANGE: 'info' }
       return map[val] || 'info'
     },
     async loadData() {
       this.loading = true
       try {
-        const res = await getCouponGrantLog({
+        const res = await getFamilyCouponGrantLog({
           page: this.page,
           size: this.size,
-          userId: this.userId || undefined,
+          familyId: this.familyId || undefined,
           couponId: this.couponId || undefined
         })
         if (res.data) {
           this.list = res.data
           this.total = this.list.length
-          await this.resolveUserNames(this.list, 'userId')
+          await this.resolveFamilyNames(this.list)
         }
       } catch (e) {
         console.error(e)
@@ -99,28 +99,27 @@ export default {
         this.loading = false
       }
     },
-    async resolveUserNames(items, idField) {
+    async resolveFamilyNames(items) {
       if (!items || items.length === 0) return
       var ids = []
       items.forEach(function(item) {
-        if (item[idField] && ids.indexOf(item[idField]) === -1) ids.push(item[idField])
+        if (item.familyId && ids.indexOf(item.familyId) === -1) ids.push(item.familyId)
       })
       var nameMap = {}
       for (var i = 0; i < ids.length; i++) {
         try {
-          var res = await searchUsers({ keyword: String(ids[i]) })
-          if (res.data && res.data.length > 0) {
-            var u = res.data[0]
-            nameMap[ids[i]] = u.nickname || u.realName || '用户' + ids[i]
+          var res = await getFamilyDetail(ids[i])
+          if (res.data && res.data.name) {
+            nameMap[ids[i]] = res.data.name
           } else {
-            nameMap[ids[i]] = '用户' + ids[i]
+            nameMap[ids[i]] = '家庭' + ids[i]
           }
         } catch (e) {
-          nameMap[ids[i]] = '用户' + ids[i]
+          nameMap[ids[i]] = '家庭' + ids[i]
         }
       }
       items.forEach(function(item) {
-        item.userName = nameMap[item[idField]] || '用户' + item[idField]
+        item.familyName = nameMap[item.familyId] || '家庭' + item.familyId
       })
     },
     handleSearch() {

+ 21 - 11
cfc-web/src/views/admin/CouponManagement.vue

@@ -145,9 +145,7 @@
         </el-form-item>
         <el-form-item v-if="form.grantType === 'JOIN' || form.grantType === 'PERIODIC'" label="适用会员等级">
           <el-select v-model="form.grantLevelCode" placeholder="请选择会员等级" style="width: 100%">
-            <el-option label="FREE" value="FREE" />
-            <el-option label="FAMILY" value="FAMILY" />
-            <el-option label="PREMIUM" value="PREMIUM" />
+            <el-option v-for="lv in memberLevelOptions" :key="lv.levelCode" :label="lv.levelName + '(' + lv.levelCode + ')'" :value="lv.levelCode" />
           </el-select>
         </el-form-item>
         <el-form-item v-if="form.grantType === 'PERIODIC'" label="补发周期">
@@ -193,8 +191,8 @@
             <el-option v-for="c in couponOptions" :key="c.id" :label="c.name" :value="c.id" />
           </el-select>
         </el-form-item>
-        <el-form-item label="用户ID列表" required>
-          <el-input v-model="batchUserIds" type="textarea" :rows="6" placeholder="每行一个用户ID&#10;例如:&#10;1001&#10;1002&#10;1003" />
+        <el-form-item label="家庭ID列表" required>
+          <el-input v-model="batchFamilyIds" type="textarea" :rows="6" placeholder="每行一个家庭ID&#10;例如:&#10;1001&#10;1002&#10;1003" />
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -208,6 +206,7 @@
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import { getCouponList, createCoupon, updateCoupon, deleteCoupon, batchIssueCoupons, toggleCouponStatus } from '@/api/coupon'
+import { listMembershipLevels } from '@/api/admin'
 import ProductPicker from '@/components/ProductPicker'
 
 export default {
@@ -233,9 +232,10 @@ export default {
       form: this.getEmptyForm(),
       batchDialogVisible: false,
       batchCouponId: '',
-      batchUserIds: '',
+      batchFamilyIds: '',
       batchSubmitting: false,
       couponOptions: [],
+      memberLevelOptions: [],
       keyword: '',
       sortableColumns: { name: '名称' }
 
@@ -243,8 +243,18 @@ export default {
   },
   created() {
     this.loadData()
+    this.loadMemberLevels()
   },
   methods: {
+    async loadMemberLevels() {
+      try {
+        const res = await listMembershipLevels()
+        const list = res.data || []
+        this.memberLevelOptions = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' && l.levelCode }) : []
+      } catch (e) {
+        this.memberLevelOptions = []
+      }
+    },
     getEmptyForm() {
       return {
         id: null,
@@ -396,7 +406,7 @@ export default {
     },
     async handleBatchIssue() {
       this.batchCouponId = ''
-      this.batchUserIds = ''
+      this.batchFamilyIds = ''
       this.batchDialogVisible = true
       try {
         const res = await getCouponList({ page: 1, size: 999, sort: this.sortState.length > 0 ? this.sortState : undefined })
@@ -411,15 +421,15 @@ export default {
         this.$message.warning('请选择优惠券')
         return
       }
-      const ids = this.batchUserIds.split('\n').map(s => s.trim()).filter(Boolean)
+      const ids = this.batchFamilyIds.split('\n').map(s => s.trim()).filter(Boolean)
       if (ids.length === 0) {
-        this.$message.warning('请输入至少一个用户ID')
+        this.$message.warning('请输入至少一个家庭ID')
         return
       }
       this.batchSubmitting = true
       try {
-        await batchIssueCoupons({ couponId: this.batchCouponId, userIds: ids })
-        this.$message.success(`成功向 ${ids.length} 个用户发放优惠券`)
+        await batchIssueCoupons({ couponId: this.batchCouponId, familyIds: ids })
+        this.$message.success(`成功向 ${ids.length} 个家庭发放优惠券`)
         this.batchDialogVisible = false
         this.loadData()
       } catch (e) {

+ 1 - 1
cfc-web/src/views/admin/MembershipCenter.vue

@@ -268,7 +268,7 @@
 <script>
 import { searchUsers, updateMembershipUser, updateMembershipLevel, getMembershipStats, listMembershipLevels } from '@/api/admin'
 
-const LEVEL_TAG_TYPE = { FREE: 'info', FAMILY: 'success', PROVIDER: 'warning', LIFETIME: 'danger' }
+const LEVEL_TAG_TYPE = { FREE: 'info', FAMILY: 'success', PROVIDER: 'warning', PREMIUM: 'primary', LIFETIME: 'danger' }
 const UPGRADE_TYPE_LABEL = { pay: '付费升级', consumption: '消费升级', admin: '管理员操作', trial: '试用激活', lifetime: '终身会员授予' }
 const UPGRADE_TYPE_TAG = { pay: 'success', consumption: 'primary', admin: 'warning', trial: 'info', lifetime: 'danger' }
 

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

@@ -1,8 +1,8 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.9
-**日期:** 2026-08-23
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)**
+**文档版本:** v2.10
+**日期:** 2026-08-31
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)**
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -364,7 +364,9 @@
 | `2026-08-28-plan-standard-content-design.md` | 🟢 已实施 | 生成方案标准内容格式(plan_json.sections[].tasks 唯一事实源 + LangGraph prompt tasks 输出 + Pydantic 校验 + 正则兜底 + 存量懒加载回填 + 规划师端结构化任务编辑 UI) |
 | `2026-08-28-innate-portrait-mind-dimension-design.md` | ✅ 已实施(迁移270-272/3实体/3Mapper/7DTO/2Controller/Service+Agent/前端2页+路由) | 星座/八字/血型/数字能量引入心维度先天画像体系 — 先天画像聚合层、可配置权重、AI解读+模板降级、成长轨迹打通、管理端配置化 |
 | `2026-08-30-diet-preferences-redesign-design.md` | 🟡 设计稿(3 步引导问卷 + 暖橙主题 + 补全 mustEat/cookingMethods 采集,纯前端零后端改动) | 饮食偏好调研页面重新设计 |
+| `2026-08-31-coupon-family-based-design.md` | 🟡 设计稿 | 优惠券全链路改为家庭维度(新建 family_coupon / family_coupon_grant_log 表,6 条发放路径全部改家庭,消费/核销同步;API 路径与响应字段保持不变) |
 | `2026-08-31-self-check-reminder-design.md` | 🟡 设计稿(15 天复检周期 + 首页入口显示自检分数 + 题目轮换 + 后端忽略记录,纯设计方案) | 五维家庭自检 15 天复检周期与题目轮换设计 |
+| `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 
 ### 实施计划(plans/)
@@ -441,6 +443,7 @@
 | `2026-08-28-plan-standard-content.md` | ✅ 已实施(9 Tasks:LangGraph模型/prompt任务契约/Pydantic校验+正则兜底/regenerate/Java任务生成优先+懒加载回填/Java编译/cfc-web结构化任务编辑/小程序tasks回流/文档验收) | 生成方案标准内容格式实施计划(plan_json.sections[].tasks 唯一事实源;设计稿:2026-08-28-plan-standard-content-design.md) |
 | `2026-08-28-innate-portrait-mind-dimension.md` | ✅ 已实施(15 Tasks 全栈:迁移270-272/实体/Mapper/DTO/NumSoulCalculator TDD/Service/Controller×2/AiGateway/AiAgent/LangGraph/管理端2页/小程序2页/验证) | 先天画像体系实施计划(星座/八字/数字能量引入心维度;设计稿:2026-08-28-innate-portrait-mind-dimension-design.md) |
 | `2026-08-30-dan-report-type-specific-blocks.md` | 🟢 已实施(4 Tasks:assembleDanByType分组/详情重算blocks/前端复用渲染器/文档) | DAN 报告按类型分别处理展示实施计划(A1/A2/B2/B3/B4/B5/B6/C1 八种类型指标分组展示,历史数据生效) |
+| `2026-08-31-self-check-ai-integration.md` | 🟡 待实施(11 Tasks:LangGraph×2 graph + 路由 / Java Service+AiGateway+Controller / 前端结果页+中间页+Chat 注入) | 五维自检 AI 结合 P0+P1 实现计划(设计稿:2026-08-31-self-check-ai-integration-design.md) |
 
 ### 计划与设计文档(specs/)
 

+ 25 - 4
docs/superpowers/api/API_REFERENCE.md

@@ -548,12 +548,14 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 |------|------|
 | `POST /api/diet/preferences/current-member` | 当前成员饮食偏好 |
 | `POST /api/diet/preferences/save` | 保存饮食偏好 |
-| `POST /api/diet/recommendation/today` | 今日推荐 |
-| `POST /api/diet/recommendation/generate` | 生成推荐 |
+| `POST /api/diet/recommendation/today` | 今日推荐(返回 `data.id / data.menu / data.nutritionSummary / data.status`) |
+| `POST /api/diet/recommendation/generate` | 生成推荐(body: `date, meal_type, selected_foods JSON string`;返回 `data.id / data.menu / data.nutritionSummary`) |
 | `POST /api/diet/recommendation/complete` | 完成推荐 |
 | `POST /api/diet/record/save` | 保存饮食记录 |
 | `POST /api/diet/record/daily` | 每日记录 |
-| `POST /api/diet/ingredients/suggest` | 食材推荐 |
+| `POST /api/diet/ingredients/suggest` | 食材推荐(返回 `data.ingredients` 列表) |
+| `POST /api/diet/ingredients/recommend` | 换一批(返回 `data.ingredients`,每次不同) |
+| `POST /api/diet/ingredients/search` | 搜索食材(body `{keyword}`,返回 `data.foods:[{id, name, category}]`) |
 | `POST /api/diet/meals/config` | 餐食配置 |
 
 ### 4.12 内容(`/api/articles`, `/api/content/*`)
@@ -861,6 +863,25 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/butler/my-butler` | 我家当前管家绑定信息(含管家昵称/等级/分配时间) |
 | `POST /api/butler/select` | 绑定/更换管家(校验 L2 订阅、管家容量与接单状态;换绑自动解绑旧关系) |
 
+### 4.32 优惠券(家庭维度,`/api/coupon/*` + `/api/admin/coupon/*`)
+
+> 2026-08-31 起,优惠券由「用户维度」全链路改为「家庭维度」。`user_coupon` 保留历史数据,新发放与消费统一走 `family_coupon`。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/coupon/list` | 优惠券模板列表(admin) |
+| `POST /api/admin/coupon/create` | 新建优惠券模板(admin) |
+| `POST /api/admin/coupon/update` | 更新优惠券模板(admin) |
+| `POST /api/admin/coupon/issue` | 批量发放到家庭,`{ couponId, familyIds: [Long] }`(admin) |
+| `POST /api/admin/coupon/issue-family` | 单家庭发放,`{ familyId, couponId, quantity }`(admin,家庭管理页使用) |
+| `POST /api/admin/coupon/grant-log` | 家庭发券流水查询,`{ familyId?, couponId? }`(admin,返回 family_coupon_grant_log) |
+| `POST /api/admin/coupon/toggle-status` | 启用/停用优惠券模板(admin) |
+| `POST /api/coupon/list` | 当前家庭可用优惠券(小程序) |
+| `POST /api/coupon/my` | 我的(家庭)优惠券(小程序) |
+| `POST /api/coupon/apply` | 下单抵扣(家庭券) |
+| `POST /api/coupon/claim` | 领取优惠券(发到家庭) |
+| `POST /api/coupon/checkout-list` | 结算页可兑换未拥有券 |
+
 ---
 
 ## 六、待清理的废弃接口
@@ -877,4 +898,4 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
-*文档最后更新:2026-08-23*
+*文档最后更新:2026-08-31*

+ 126 - 0
docs/superpowers/plans/2026-08-31-coupon-family-based.md

@@ -0,0 +1,126 @@
+# 优惠券全链路改为家庭维度 — 实施计划
+
+**设计稿:** `specs/2026-08-31-coupon-family-based-design.md`
+**状态:** 待执行
+**预计工时:** ~2h(后端主导,前端轻量)
+
+---
+
+## Task 1 — 数据库迁移 + schema.sql(迁移 269)
+
+**文件:** `DatabaseInitializer.java`, `schema.sql`
+**验证:** `mvn clean compile`
+
+```
+// 迁移269: 创建 family_coupon + family_coupon_grant_log 表
+// (优惠券全链路改为家庭维度:2026-08-31)
+```
+
+**Schema.sql 末尾追加**(在 `coupon_grant_log` CREATE 语句之后):
+```sql
+CREATE TABLE IF NOT EXISTS family_coupon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME,
+    order_id BIGINT COMMENT '核销时写入的订单号',
+    INDEX idx_family_coupon (family_id, coupon_id),
+    INDEX idx_status (status),
+    INDEX idx_order_id (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
+
+CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE',
+    period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用',
+    quantity INT DEFAULT 1 COMMENT '发放数量',
+    source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period),
+    INDEX idx_coupon (coupon_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
+```
+
+---
+
+## Task 2 — Entity + Mapper(4个新文件)
+
+| 文件 | 包 | 说明 |
+|------|----|------|
+| `entity/FamilyCoupon.java` | `com.etotem.cfc.entity` | `@TableName("family_coupon")` |
+| `entity/FamilyCouponGrantLog.java` | `com.etotem.cfc.entity` | `@TableName("family_coupon_grant_log")` |
+| `mapper/FamilyCouponMapper.java` | `com.etotem.cfc.mapper` | 继承 `BaseMapper<FamilyCoupon>`,加 `@Mapper` |
+| `mapper/FamilyCouponGrantLogMapper.java` | `com.etotem.cfc.mapper` | 同上 |
+
+---
+
+## Task 3 — CouponService 重构(核心)
+
+**文件:** `service/CouponService.java`
+
+**改动要点:**
+1. 注入 `FamilyCouponMapper`, `FamilyCouponGrantLogMapper`, `UserMapper`
+2. 保留所有公开方法签名不变
+3. 内部实现全部替换为读 `family_coupon`
+4. 新增私有方法 `getFamilyId(Long userId)` 做 userId→familyId 转换
+5. 新增 public 方法(供外部调用方用):
+   - `grantFamily(Long familyId, Long couponId, String grantType, String period, String source)`
+   - `grantFamilyJoinCoupons(Long familyId, String levelCode, String source)`
+   - `grantFamilyPopulationCoupons(Long familyId, Long memberId)`
+   - `listActiveMemberFamilyIds(String levelCode)`
+
+**不删的旧方法**(保留供降级兼容,但停止使用):
+- `issueToUser` — 标记 `@Deprecated`,不动实现
+
+---
+
+## Task 4 — 6 条发放路径改写
+
+| # | 文件 | 改动 |
+|---|------|------|
+| 1 | `controller/admin/AdminCouponController.java` | `/issue` 参数改为 `familyIds`;新增 `/issue-family` |
+| 2 | `service/MembershipService.java` | `grantJoinCoupons(adminUserId,...)` → `grantFamilyJoinCoupons(order.getFamilyId(),...)` |
+| 3 | `service/FamilyMemberService.java` | `grantPopulationCoupons(targetUserId,...)` → `grantFamilyPopulationCoupons(familyId, member.getId())` |
+| 4 | `task/CouponGrantTask.java` | 改为调用 `listActiveMemberFamilyIds` + `grantFamily` |
+| 5 | `service/PointsExchangeService.java` | 取 `familyMemberMapper.selectOne(userId).getFamilyId()`,调 `grantFamily` |
+| 6 | `service/FamilyPlatformPointsService.java` | 直接调 `grantFamily(familyId, ...)` |
+
+---
+
+## Task 5 — CouponController(小程序端,内部改实现)
+
+**文件:** `controller/CouponController.java`
+
+不改路由、不改响应字段。内部 `userId` 先解析 `userMapper.selectById(userId).getFamilyId()`,再调 CouponService 新方法。
+`apply` 方法中增加归属校验。
+
+---
+
+## Task 6 — 管理端前端(cfc-web)
+
+| 文件 | 改动 |
+|------|------|
+| `api/coupon.js` | 新增 `issueFamilyCoupon`、`getFamilyCouponGrantLog` |
+| `views/Families.vue` | 下拉菜单加「发放优惠券」按钮 + 弹窗 |
+| `views/admin/CouponManagement.vue` | 批量发放弹窗改为家庭ID列表 |
+| `views/admin/CouponGrantLog.vue` | 搜索框加「家庭ID」;列表显示家庭名 |
+
+---
+
+## Task 7 — API_REFERENCE.md 更新
+
+追加 `/api/admin/coupon/issue-family` 和 `/api/coupon/*` 的家庭维度说明。
+
+---
+
+## Task 8 — 编译验证 + 提交
+
+```bash
+cd cfc-backend && mvn clean compile
+git add -A && git commit && git push
+```

+ 1303 - 0
docs/superpowers/plans/2026-08-31-self-check-ai-integration.md

@@ -0,0 +1,1303 @@
+# 五维家庭自检 AI 结合(P0 + P1)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 将五维自检结果从静态五行寻源表(`WuxingSourcingService`)替换为 LangGraph AI 生成建议,并叠加健康计划生成、趋势分析、Chat 上下文注入三个能力。
+
+**架构:** LangGraph 新增 2 个 graph(`self_check_analysis_graph`、`self_check_trend_graph`)+ 1 个 api 模块;Java 新增 `SelfCheckAnalysisService` + 2 个 AiGateway 方法 + 3 个 Controller 接口;前端新增 API 方法 + 改造结果页/中间页/Chat。
+
+**技术栈:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / Python 3.11 + FastAPI + LangGraph + LangChain / uni-app Vue 2
+
+**规格文档:** `docs/superpowers/specs/2026-08-31-self-check-ai-integration-design.md`
+
+---
+
+## 文件结构
+
+| 文件 | 职责 | 变更 |
+|------|------|------|
+| `cfc-langgraph/app/graphs/self_check_analysis_graph.py` | P0-1:自检建议 AI 生成 graph | 新建 |
+| `cfc-langgraph/app/graphs/self_check_trend_graph.py` | P1-1:趋势分析 AI 生成 graph | 新建 |
+| `cfc-langgraph/app/api/self_check.py` | 注册 P0-1 + P1-1 路由 | 新建 |
+| `cfc-langgraph/app/main.py` | 注册 self_check router | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java` | VO 改造(去 upstream/restrainer/action,加 AI 字段) | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java` | P0-1:调 AiGateway 生成建议 | 新建 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/WuxingSourcingService.java` | 删除 SOURCING_TABLE/getAdvice/getAdvicesForLowScores | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java` | submitSelfCheck 调新 Service;新增 trend 方法 | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` | 新增 generateSelfCheckAdvice/generateSelfCheckTrend | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java` | 新增 generatePlan、trendAnalysis 接口;submit 改造 | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java` | 新增 generateFromSelfCheck 接口 | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` | 实现 generateFromSelfCheck | 修改 |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | sendMessage 支持 selfCheckId 参数 | 修改 |
+| `cfc-frontend/utils/api.js` | 新增 3 个 API 方法 | 修改 |
+| `cfc-frontend/pages/family/self-check-result.vue` | P0-1/P0-2/P1-2 展示改造 | 修改 |
+| `cfc-frontend/pages/family/self-check-entry.vue` | P1-1 趋势展示 | 修改 |
+
+---
+
+## 任务 1:LangGraph — self_check_analysis_graph(P0-1 核心)
+
+**文件:**
+- 创建:`cfc-langgraph/app/graphs/self_check_analysis_graph.py`
+- 创建:`cfc-langgraph/app/api/self_check.py`
+
+- [ ] **步骤 1:创建 self_check_analysis_graph.py**
+
+创建文件 `cfc-langgraph/app/graphs/self_check_analysis_graph.py`:
+
+```python
+import json
+import logging
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.llm.client import get_llm
+from app.monitoring import monitor_agent
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一位家庭健康顾问,基于五维自检结果(身·智·富·行·心,每维0-9分,满分45)给出个性化建议。
+
+要求:
+1. 对每个低分维度(≤6分)给出1-2句解读和2-3个具体可执行的微行动
+2. 如有历史数据,简要对比趋势(改善/下滑)
+3. 语气温暖口语化,每条解读不超过80字
+4. 最后给出1句家庭整体洞察(30字以内)
+
+返回 JSON(严格格式,不要额外文字):
+{
+  "advice": [
+    {
+      "dimension": "mind",
+      "dimensionName": "心",
+      "interpretation": "你的情绪能量偏低,可能最近压力较大,建议...",
+      "microActions": ["今晚睡前做10分钟深呼吸", "和伴侣约定每周一次夜谈"],
+      "fallbackUsed": false
+    }
+  ],
+  "familyInsight": "建议从行动维度入手,关系顺畅了内心才能安定"
+}
+"""
+
+
+class GraphState(TypedDict):
+    scores: dict
+    question_ids: list
+    user_id: int
+    recent_history: list
+    advice: Optional[dict]
+    error: Optional[str]
+
+
+class SelfCheckAnalysisAgent:
+    def __init__(self):
+        self.llm = get_llm()
+
+    @monitor_agent("self_check_analysis")
+    async def run(self, scores: dict, question_ids: list, user_id: int, recent_history: list) -> dict:
+        try:
+            score_summary = "\n".join(
+                f"{v.get('dimensionName', k)}({k}): {v.get('score', 0)}分"
+                for k, v in scores.items()
+            )
+            history_summary = ""
+            if recent_history:
+                history_summary = "历史趋势:\n" + "\n".join(
+                    f"- {h.get('createdAt', '')}: 总分{h.get('totalScore', 0)}分"
+                    for h in recent_history[:3]
+                )
+            messages = [
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=f"用户ID: {user_id}\n当前自检得分:\n{score_summary}\n{history_summary}"),
+            ]
+            response = await self.llm.ainvoke(messages)
+            text = response.content.strip()
+            if "```json" in text:
+                text = text.split("```json")[1].split("```")[0].strip()
+            elif "```" in text:
+                text = text.split("```")[1].split("```")[0].strip()
+            data = json.loads(text)
+            advice_list = data.get("advice", [])
+            for item in advice_list:
+                item.setdefault("fallbackUsed", False)
+            if advice_list:
+                advice_list[0]["familyInsight"] = data.get("familyInsight", "")
+            return {"advice": {"advice_json": json.dumps(advice_list, ensure_ascii=False), "fallback_used": False}}
+        except Exception as e:
+            logger.warning("自检建议生成失败: %s", e)
+            return {"advice": {"advice_json": None, "fallback_used": True}}
+
+
+def build_graph():
+    agent = SelfCheckAnalysisAgent()
+
+    def parse_input(state: GraphState) -> GraphState:
+        return state
+
+    async def call_llm(state: GraphState) -> dict:
+        return await agent.run(state["scores"], state["question_ids"], state["user_id"], state["recent_history"])
+
+    def validate(state: GraphState) -> GraphState:
+        adv = state.get("advice")
+        if adv is None or adv.get("advice_json") is None:
+            return {**state, "error": "AI 建议生成失败"}
+        return state
+
+    graph = StateGraph(GraphState)
+    graph.add_node("parse", parse_input)
+    graph.add_node("llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "parse")
+    graph.add_edge("parse", "llm")
+    graph.add_edge("llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_graph = None
+
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = build_graph()
+    return _graph
+```
+
+- [ ] **步骤 2:创建 self_check.py API 模块**
+
+创建文件 `cfc-langgraph/app/api/self_check.py`:
+
+```python
+import logging
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Any, Dict, Optional
+from app.graphs.self_check_analysis_graph import get_graph
+from app.graphs.self_check_trend_graph import get_trend_graph
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["self-check"])
+
+
+class SelfCheckAnalysisRequest(BaseModel):
+    scores: Dict[str, Any]
+    question_ids: list
+    user_id: int
+    recent_history: Optional[list] = None
+
+
+class SelfCheckTrendRequest(BaseModel):
+    history: list
+    user_id: int
+
+
+@router.post("/self-check/analysis")
+async def self_check_analysis(req: SelfCheckAnalysisRequest):
+    graph = get_graph()
+    state = {
+        "scores": req.scores,
+        "question_ids": req.question_ids,
+        "user_id": req.user_id,
+        "recent_history": req.recent_history or [],
+        "advice": None,
+        "error": None,
+    }
+    result = await graph.ainvoke(state)
+    advice = result.get("advice") or {}
+    return {
+        "advice_json": advice.get("advice_json"),
+        "fallback_used": advice.get("fallback_used", True),
+        "error": result.get("error"),
+    }
+
+
+@router.post("/self-check/trend")
+async def self_check_trend(req: SelfCheckTrendRequest):
+    graph = get_trend_graph()
+    state = {"history": req.history, "user_id": req.user_id, "insight": None, "error": None}
+    result = await graph.ainvoke(state)
+    insight = result.get("insight") or {}
+    return {
+        "aiInsight": insight.get("aiInsight", ""),
+        "trendSummary": insight.get("trendSummary", ""),
+        "error": result.get("error"),
+    }
+```
+
+- [ ] **步骤 3:注册路由**
+
+在 `cfc-langgraph/app/main.py` 中,于 `app.include_router(innate_portrait.router)` 之后追加:
+
+```python
+from app.api import self_check
+app.include_router(self_check.router)
+```
+
+- [ ] **步骤 4:语法校验**
+
+```bash
+cd cfc-langgraph && python -m py_compile app/graphs/self_check_analysis_graph.py app/graphs/self_check_trend_graph.py app/api/self_check.py && echo "OK"
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-langgraph/app/graphs/self_check_analysis_graph.py \
+        cfc-langgraph/app/graphs/self_check_trend_graph.py \
+        cfc-langgraph/app/api/self_check.py \
+        cfc-langgraph/app/main.py
+git commit -m "feat(langgraph): 新增自检建议+trend graph + self_check 路由"
+```
+
+---
+
+## 任务 2:LangGraph — self_check_trend_graph(P1-1 核心)
+
+**文件:**
+- 创建:`cfc-langgraph/app/graphs/self_check_trend_graph.py`
+
+- [ ] **步骤 1:创建 self_check_trend_graph.py**
+
+创建文件 `cfc-langgraph/app/graphs/self_check_trend_graph.py`:
+
+```python
+import json
+import logging
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.llm.client import get_llm
+from app.monitoring import monitor_agent
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """分析用户近3次五维自检趋势,输出:
+- aiInsight: 趋势解读(2-3句,指出最大变化维度和可能原因,口语化)
+- trendSummary: 各维度 delta 简写(如"身-2 智+1 富0 行-1 心+2")
+返回 JSON:{"aiInsight": "...", "trendSummary": "..."}
+"""
+
+
+class TrendState(TypedDict):
+    history: list
+    user_id: int
+    insight: Optional[dict]
+    error: Optional[str]
+
+
+class SelfCheckTrendAgent:
+    def __init__(self):
+        self.llm = get_llm()
+
+    @monitor_agent("self_check_trend")
+    async def run(self, history: list, user_id: int) -> dict:
+        try:
+            if len(history) < 2:
+                return {"insight": {"aiInsight": "自检次数不足,建议完成至少2次自检后查看趋势", "trendSummary": ""}, "error": None}
+            history_text = "\n".join(
+                f"{h.get('createdAt', '')}: 总分{h.get('totalScore', 0)}," +
+                " ".join(f"{d.get('name','')}{d.get('score',0)}分" for d in h.get('dimensions', []))
+                for h in history[-3:]
+            )
+            messages = [
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=f"用户{user_id}的自检历史:\n{history_text}"),
+            ]
+            response = await self.llm.ainvoke(messages)
+            text = response.content.strip()
+            if "```json" in text:
+                text = text.split("```json")[1].split("```")[0].strip()
+            elif "```" in text:
+                text = text.split("```")[1].split("```")[0].strip()
+            data = json.loads(text)
+            return {"insight": data, "error": None}
+        except Exception as e:
+            logger.warning("趋势分析失败: %s", e)
+            return {"insight": {"aiInsight": "", "trendSummary": ""}, "error": str(e)}
+
+
+def build_trend_graph():
+    agent = SelfCheckTrendAgent()
+
+    async def call_llm(state: TrendState) -> dict:
+        return await agent.run(state["history"], state["user_id"])
+
+    graph = StateGraph(TrendState)
+    graph.add_node("llm", call_llm)
+    graph.add_edge(START, "llm")
+    graph.add_edge("llm", END)
+    return graph.compile()
+
+
+_trend_graph = None
+
+
+def get_trend_graph():
+    global _trend_graph
+    if _trend_graph is None:
+        _trend_graph = build_trend_graph()
+    return _trend_graph
+```
+
+- [ ] **步骤 2:语法校验**
+
+```bash
+cd cfc-langgraph && python -m py_compile app/graphs/self_check_trend_graph.py && echo "OK"
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-langgraph/app/graphs/self_check_trend_graph.py
+git commit -m "feat(langgraph): 新增 self_check_trend_graph 趋势分析实现"
+```
+
+---
+
+## 任务 3:Java — WuxingSourcingAdviceVO 改造 + SelfCheckAnalysisService
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java`
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java`
+
+- [ ] **步骤 1:改造 WuxingSourcingAdviceVO**
+
+将 `cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java` 的字段替换为:
+
+```java
+package com.etotem.cfc.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class WuxingSourcingAdviceVO {
+
+    private String dimension;
+    private String dimensionName;
+    private String element;
+    private String color;
+    private Integer score;
+    private String level;
+    private String levelName;
+
+    /** AI 解读(替换原 upstreamReason) */
+    private String interpretation;
+
+    /** AI 微行动列表 */
+    private List<String> microActions;
+
+    /** AI 补充洞察 */
+    private String aiInsight;
+
+    /** 家庭整体洞察(仅第一条携带) */
+    private String familyInsight;
+
+    /** 是否使用降级静态建议 */
+    @JsonProperty("fallbackUsed")
+    private Boolean fallbackUsed;
+}
+```
+
+- [ ] **步骤 2:创建 SelfCheckAnalysisService**
+
+创建文件 `cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class SelfCheckAnalysisService {
+
+    private static final Logger log = LoggerFactory.getLogger(SelfCheckAnalysisService.class);
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 生成自检建议(AI 优先,失败返回 adviceJson=null + fallbackUsed=true)
+     */
+    public Map<String, Object> generateAdvice(Long userId, Map<String, Integer> scoreMap, List<Integer> questionIds) {
+        try {
+            List<Map<String, Object>> recentHistory = getRecentHistory(userId, 3);
+            Map<String, Object> scoresWithMeta = new LinkedHashMap<>();
+            for (Map.Entry<String, Integer> entry : scoreMap.entrySet()) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("dimension", entry.getKey());
+                m.put("dimensionName", getDimensionName(entry.getKey()));
+                m.put("score", entry.getValue());
+                scoresWithMeta.put(entry.getKey(), m);
+            }
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("scores", scoresWithMeta);
+            inputs.put("questionIds", questionIds);
+            inputs.put("userId", userId);
+            inputs.put("recentHistory", recentHistory);
+            Map<String, Object> result = aiGateway.generateSelfCheckAdvice(inputs);
+            if (result == null) {
+                log.info("AI 自检建议生成失败,fallback");
+                return Map.of("adviceJson", null, "fallbackUsed", true);
+            }
+            String adviceJson = (String) result.get("advice_json");
+            Boolean fallbackUsed = (Boolean) result.getOrDefault("fallback_used", false);
+            return Map.of("adviceJson", adviceJson, "fallbackUsed", fallbackUsed);
+        } catch (Exception e) {
+            log.warn("自检建议生成异常: {}", e.getMessage());
+            return Map.of("adviceJson", null, "fallbackUsed", true);
+        }
+    }
+
+    /**
+     * 生成趋势分析(AI 优先,失败返回空 insight)
+     */
+    public Map<String, Object> generateTrend(Long userId) {
+        try {
+            List<Map<String, Object>> history = getRecentHistory(userId, 3);
+            Map<String, Object> inputs = Map.of("history", history, "userId", userId);
+            Map<String, Object> result = aiGateway.generateSelfCheckTrend(inputs);
+            if (result == null) {
+                return Map.of("aiInsight", "", "trendSummary", "");
+            }
+            return Map.of(
+                    "aiInsight", result.getOrDefault("aiInsight", ""),
+                    "trendSummary", result.getOrDefault("trendSummary", "")
+            );
+        } catch (Exception e) {
+            log.warn("趋势分析异常: {}", e.getMessage());
+            return Map.of("aiInsight", "", "trendSummary", "");
+        }
+    }
+
+    /** 获取最近 N 次自检历史(供 AI 和前端使用) */
+    public List<Map<String, Object>> getRecentHistory(Long userId, int limit) {
+        try {
+            List<FiveDimensionSelfCheck> records = selfCheckMapper.selectList(
+                    new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                            .eq(FiveDimensionSelfCheck::getUserId, userId)
+                            .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                            .last("LIMIT " + limit)
+            );
+            List<Map<String, Object>> result = new ArrayList<>();
+            for (FiveDimensionSelfCheck r : records) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("createdAt", r.getCreatedAt() != null ? r.getCreatedAt().toString() : "");
+                m.put("totalScore", r.getTotalScore());
+                if (r.getScoresJson() != null) {
+                    try {
+                        Map<String, Integer> scores = objectMapper.readValue(r.getScoresJson(),
+                                new TypeReference<Map<String, Integer>>() {});
+                        List<Map<String, Object>> dims = new ArrayList<>();
+                        for (Map.Entry<String, Integer> e : scores.entrySet()) {
+                            Map<String, Object> d = new LinkedHashMap<>();
+                            d.put("dimension", e.getKey());
+                            d.put("name", getDimensionName(e.getKey()));
+                            d.put("score", e.getValue());
+                            dims.add(d);
+                        }
+                        m.put("dimensions", dims);
+                    } catch (Exception ignored) {}
+                }
+                result.add(m);
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("获取自检历史失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private String getDimensionName(String dim) {
+        switch (dim) {
+            case "body": return "身";
+            case "wisdom": return "智";
+            case "wealth": return "富";
+            case "action": return "行";
+            case "mind": return "心";
+            default: return dim;
+        }
+    }
+}
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java
+git commit -m "feat(self-check): WuxingSourcingAdviceVO 改造为 AI 建议 VO + SelfCheckAnalysisService"
+```
+
+---
+
+## 任务 4:Java — AiGateway 新增方法
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
+
+- [ ] **步骤 1:新增 generateSelfCheckAdvice 方法**
+
+在 `AiGateway.java` 中,于 `generateInnateReading` 方法之后追加:
+
+```java
+/**
+ * 生成自检 AI 建议(调用 LangGraph self_check_analysis_graph)
+ * @return 含 "advice_json" 和 "fallback_used" 的 Map;失败返回 null
+ */
+public Map<String, Object> generateSelfCheckAdvice(Map<String, Object> inputs) {
+    if (!enabled || isCircuitOpen()) return null;
+    try {
+        ObjectNode body = objectMapper.valueToTree(inputs);
+        HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+        String url = baseUrl + "/api/v1/self-check/analysis";
+        ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+        if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+            JsonNode root = objectMapper.readTree(response.getBody());
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("advice_json", root.has("advice_json") ? root.get("advice_json").asText() : null);
+            result.put("fallback_used", root.has("fallback_used") ? root.get("fallback_used").asBoolean() : true);
+            consecutiveFailures.set(0);
+            return result;
+        }
+        return null;
+    } catch (Exception e) {
+        log.warn("AiGateway generateSelfCheckAdvice 调用失败: {}", e.getMessage());
+        recordFailure();
+        return null;
+    }
+}
+
+/**
+ * 生成自检趋势分析(调用 LangGraph self_check_trend_graph)
+ * @return 含 "aiInsight" 和 "trendSummary" 的 Map;失败返回 null
+ */
+public Map<String, Object> generateSelfCheckTrend(Map<String, Object> inputs) {
+    if (!enabled || isCircuitOpen()) return null;
+    try {
+        ObjectNode body = objectMapper.valueToTree(inputs);
+        HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+        String url = baseUrl + "/api/v1/self-check/trend";
+        ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+        if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+            JsonNode root = objectMapper.readTree(response.getBody());
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("aiInsight", root.has("aiInsight") ? root.get("aiInsight").asText() : "");
+            result.put("trendSummary", root.has("trendSummary") ? root.get("trendSummary").asText() : "");
+            consecutiveFailures.set(0);
+            return result;
+        }
+        return null;
+    } catch (Exception e) {
+        log.warn("AiGateway generateSelfCheckTrend 调用失败: {}", e.getMessage());
+        recordFailure();
+        return null;
+    }
+}
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
+git commit -m "feat(self-check): AiGateway 新增 generateSelfCheckAdvice/generateSelfCheckTrend"
+```
+
+---
+
+## 任务 5:Java — WuxingSourcingService 清理 + FiveDimensionSelfCheckService 改造
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/WuxingSourcingService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java`
+
+- [ ] **步骤 1:清理 WuxingSourcingService**
+
+删除 `WuxingSourcingService.java` 中的:
+- `SOURCING_TABLE` 静态字段(第 41-68 行)
+- `getAdvice()` 方法(第 78-109 行)
+- `getAdvicesForLowScores()` 方法(第 117-132 行)
+
+保留:`DIMENSION_META`、`levelOf()`、`levelName()`、`dimensionName()`
+
+- [ ] **步骤 2:FiveDimensionSelfCheckService 改造 submitSelfCheck**
+
+在 `submitSelfCheck` 方法中,将第 472-477 行(`wuxingSourcingService.getAdvicesForLowScores` + 落库 adviceJson)替换为:
+
+```java
+        // 生成 AI 建议(AI 优先,失败降级为空建议)
+        String adviceJson = null;
+        try {
+            Map<String, Object> adviceResult = selfCheckAnalysisService.generateAdvice(userId, scoreMap, submittedQuestionIds);
+            adviceJson = (String) adviceResult.get("adviceJson");
+        } catch (Exception e) {
+            log.warn("AI 建议生成异常,使用空建议: {}", e.getMessage());
+        }
+        record.setAdviceJson(adviceJson != null ? adviceJson : "[]");
+```
+
+同时新增注入:
+
+```java
+    @Resource
+    private SelfCheckAnalysisService selfCheckAnalysisService;
+```
+
+并将 `vo.setAdvices(...)` 改为根据 adviceJson 解析(若无 AI 结果则空列表):
+
+```java
+        vo.setAdvices(parseAdvices(adviceJson));
+```
+
+- [ ] **步骤 3:新增 parseAdvices + trend 方法**
+
+在 Service 中新增:
+
+```java
+    private List<WuxingSourcingAdviceVO> parseAdvices(String adviceJson) {
+        if (adviceJson == null || adviceJson.isEmpty() || "[]".equals(adviceJson)) {
+            return new ArrayList<>();
+        }
+        try {
+            return objectMapper.readValue(adviceJson,
+                    objectMapper.getTypeFactory().constructCollectionType(List.class, WuxingSourcingAdviceVO.class));
+        } catch (Exception e) {
+            log.warn("解析 AI 建议 JSON 失败: {}", e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
+    /** 获取自检趋势分析结果(含历史列表) */
+    public Map<String, Object> getTrendAnalysis(Long userId) {
+        Map<String, Object> trend = selfCheckAnalysisService.generateTrend(userId);
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("history", selfCheckAnalysisService.getRecentHistory(userId, 3));
+        resp.put("aiInsight", trend.getOrDefault("aiInsight", ""));
+        resp.put("trendSummary", trend.getOrDefault("trendSummary", ""));
+        return resp;
+    }
+```
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/WuxingSourcingService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
+git commit -m "feat(self-check): 清理 WuxingSourcingService 静态表 + submitSelfCheck 接 AI"
+```
+
+---
+
+## 任务 6:Java — Controller 新增接口 + HealthPlanService
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java`
+
+- [ ] **步骤 1:Controller 新增 generatePlan 接口**
+
+在 `FiveDimensionSelfCheckController.java` 中追加:
+
+```java
+    @Resource
+    private com.etotem.cfc.service.HealthPlanService healthPlanService;
+
+    @Operation(summary = "用户点击生成健康计划(基于自检低分维度)")
+    @PostMapping("/generate-plan")
+    public Result<Map<String, Object>> generatePlan(
+            @RequestBody(required = false) Map<String, Object> body,
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long checkId = body != null ? com.etotem.cfc.util.ParamUtils.getLong(body.get("checkId")) : null;
+        try {
+            Long planId = healthPlanService.generateFromSelfCheck(userId, checkId);
+            if (planId == null) return Result.error("计划生成失败,请稍后重试");
+            Map<String, Object> resp = new HashMap<>();
+            resp.put("planId", planId);
+            resp.put("status", "draft");
+            return Result.success(resp);
+        } catch (Exception e) {
+            return Result.error("计划生成失败");
+        }
+    }
+
+    @Operation(summary = "获取自检历史趋势分析")
+    @PostMapping("/trend-analysis")
+    public Result<Map<String, Object>> getTrendAnalysis(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        try {
+            return Result.success(selfCheckService.getTrendAnalysis(userId));
+        } catch (Exception e) {
+            return Result.error("趋势分析失败");
+        }
+    }
+```
+
+- [ ] **步骤 2:HealthPlanService 接口新增 generateFromSelfCheck**
+
+在 `HealthPlanService.java` 中追加:
+
+```java
+    /** 基于自检低分维度自动生成 draft 计划 */
+    Long generateFromSelfCheck(Long userId, Long checkId);
+```
+
+- [ ] **步骤 3:HealthPlanServiceImpl 实现 generateFromSelfCheck**
+
+在 `HealthPlanServiceImpl.java` 中追加(需新增 `@Resource private com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper selfCheckMapper;`):
+
+```java
+    @Override
+    public Long generateFromSelfCheck(Long userId, Long checkId) {
+        try {
+            com.etotem.cfc.entity.FiveDimensionSelfCheck check;
+            if (checkId != null) {
+                check = selfCheckMapper.selectById(checkId);
+            } else {
+                check = selfCheckMapper.selectOne(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionSelfCheck>()
+                                .eq(com.etotem.cfc.entity.FiveDimensionSelfCheck::getUserId, userId)
+                                .orderByDesc(com.etotem.cfc.entity.FiveDimensionSelfCheck::getCreatedAt)
+                                .last("LIMIT 1"));
+            }
+            if (check == null) return null;
+            Map<String, Integer> scores = objectMapper.readValue(check.getScoresJson(),
+                    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Integer>>() {});
+            List<String> lowDims = new ArrayList<>();
+            for (Map.Entry<String, Integer> e : scores.entrySet()) {
+                if (e.getValue() != null && e.getValue() <= 6) lowDims.add(e.getKey());
+            }
+            if (lowDims.isEmpty()) return null;
+            com.etotem.cfc.entity.User user = userMapper.selectById(userId);
+            if (user == null || user.getFamilyId() == null) return null;
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("familyId", user.getFamilyId());
+            inputs.put("dimensions", String.join(",", lowDims));
+            inputs.put("goal", "基于五维自检结果,针对低分维度制定改善计划");
+            String planContent = aiGateway.generateHealthPlan(inputs);
+            if (planContent == null) return null;
+            com.etotem.cfc.entity.HealthPlan plan = new com.etotem.cfc.entity.HealthPlan();
+            plan.setFamilyId(user.getFamilyId());
+            plan.setDimensions(String.join(",", lowDims));
+            plan.setGoal("五维自检自动生成");
+            plan.setPlanContent(planContent);
+            plan.setStatus("draft");
+            plan.setCreatedAt(new Date());
+            healthPlanMapper.insert(plan);
+            return plan.getId();
+        } catch (Exception e) {
+            log.warn("generateFromSelfCheck 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+```
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
+git commit -m "feat(self-check): 新增 /generate-plan + /trend-analysis 接口 + HealthPlanService.generateFromSelfCheck"
+```
+
+---
+
+## 任务 7:Java — AIChatController P1-2 context 注入
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+
+- [ ] **步骤 1:sendMessage 支持 selfCheckId**
+
+在 `AIChatController.java` 的 `sendMessage` 方法中,于第 72 行(`String surveyIdStr = params.get("surveyId");`)之后追加:
+
+```java
+        String selfCheckIdStr = params.get("selfCheckId");
+```
+
+在第 122 行(`inputs.put("portrait_prompt", chatPortrait);`)之后追加:
+
+```java
+        // 注入自检上下文(P1-2)
+        if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) {
+            try {
+                Long selfCheckId = Long.valueOf(selfCheckIdStr);
+                com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId);
+                if (selfCheck != null) {
+                    inputs.put("self_check_result", objectMapper.writeValueAsString(selfCheck));
+                }
+            } catch (Exception e) {
+                log.warn("注入自检上下文失败: {}", e.getMessage());
+            }
+        }
+```
+
+需要新增注入(如未已有):
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
+git commit -m "feat(self-check): AIChat sendMessage 支持 selfCheckId 注入上下文(P1-2)"
+```
+
+---
+
+## 任务 8:前端 — utils/api.js 新增方法
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+
+- [ ] **步骤 1:追加 2 个 API 方法**
+
+在 `cfc-frontend/utils/api.js` 中,于 `ignoreSelfCheck` 方法之后追加:
+
+```js
+export const generateSelfCheckPlan = (data) => {
+  return request('/api/family/self-check/generate-plan', 'POST', data || {})
+}
+
+export const getSelfCheckTrendAnalysis = () => {
+  return request('/api/family/self-check/trend-analysis', 'POST', {})
+}
+```
+
+- [ ] **步骤 2:语法校验**
+
+```bash
+node --check cfc-frontend/utils/api.js && echo "api.js OK"
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat(self-check): 新增 generateSelfCheckPlan/getSelfCheckTrendAnalysis API"
+```
+
+---
+
+## 任务 9:前端 — self-check-result.vue 改造(P0-1/P0-2/P1-2)
+
+**文件:**
+- 修改:`cfc-frontend/pages/family/self-check-result.vue`
+
+- [ ] **步骤 1:替换寻源建议区块(P0-1)**
+
+将第 70-131 行的 `<view class="scr-card" v-if="result.advices...">` 区块(五行相生寻源建议 + 全部健康提示)整体替换为:
+
+```html
+      <!-- AI 健康解读(P0-1) -->
+      <view class="scr-card" v-if="result.advices && result.advices.length > 0">
+        <view class="scr-card-title">
+          <text class="scr-card-title-text">AI 健康解读</text>
+          <text class="scr-card-sub">基于你的五维自检结果</text>
+        </view>
+        <view v-for="adv in result.advices" :key="adv.dimension" class="scr-advice-item">
+          <view class="scr-advice-dim-row">
+            <view class="scr-advice-dim-badge" :style="{ background: adv.color }">
+              <text class="scr-advice-dim-name">{{ adv.dimensionName }}</text>
+              <text class="scr-advice-dim-elem">{{ adv.score }}分</text>
+            </view>
+          </view>
+          <text class="scr-advice-interpretation" v-if="adv.interpretation">{{ adv.interpretation }}</text>
+          <view class="scr-advice-actions" v-if="adv.microActions && adv.microActions.length > 0">
+            <text class="scr-advice-action-label">本周行动:</text>
+            <text v-for="(action, i) in adv.microActions" :key="'ma'+i" class="scr-advice-action">{{ i+1 }}. {{ action }}</text>
+          </view>
+        </view>
+        <view class="scr-family-insight" v-if="result.advices[0] && result.advices[0].familyInsight">
+          <text class="scr-family-insight-label">💡 家庭整体洞察</text>
+          <text class="scr-family-insight-text">{{ result.advices[0].familyInsight }}</text>
+        </view>
+        <view class="scr-fallback" v-if="result.advices[0] && result.advices[0].fallbackUsed">
+          <text>AI 分析暂时不可用,请稍后再试</text>
+        </view>
+      </view>
+      <!-- 全部健康提示 -->
+      <view class="scr-card" v-else-if="result.totalScore >= 35">
+        <view class="scr-all-healthy">
+          <text class="scr-all-healthy-icon">🌿</text>
+          <text class="scr-all-healthy-text">五维状态均健康,请继续保持这份平衡!</text>
+        </view>
+      </view>
+```
+
+- [ ] **步骤 2:底部按钮区改造(P0-2 + P1-2)**
+
+将第 134-137 行的底部按钮区替换为:
+
+```html
+      <!-- 历史与操作 -->
+      <view class="scr-footer">
+        <button class="scr-btn scr-btn-primary" @click="goHistory">历史记录</button>
+        <button class="scr-btn scr-btn-outline" @click="retake">重新自检</button>
+        <button class="scr-btn scr-btn-secondary" v-if="hasLowScore" :loading="planGenerating" @click="generatePlan">
+          生成健康计划
+        </button>
+        <button class="scr-btn scr-btn-ghost" @click="askAI">问问 AI</button>
+      </view>
+```
+
+- [ ] **步骤 3:script 改造**
+
+在 `data()` 中追加 `planGenerating: false`;在 `computed` 中追加 `hasLowScore`;在 `methods` 中追加 `generatePlan` / `askAI`:
+
+```javascript
+      planGenerating: false,   // data() 内
+    // computed 内
+    hasLowScore: function() {
+      return this.result && this.result.advices && this.result.advices.some(function(a) {
+        return a.score != null && a.score <= 6
+      })
+    },
+    // methods 内
+    generatePlan: function() {
+      var self = this
+      if (this.planGenerating) return
+      this.planGenerating = true
+      generateSelfCheckPlan({ checkId: this.result && this.result.id }).then(function(res) {
+        self.planGenerating = false
+        if (res.code === 200 && res.data && res.data.planId) {
+          uni.showToast({ title: '计划已生成,可在健康计划页查看', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateTo({ url: '/pages/health-main/index?planId=' + res.data.planId })
+          }, 1000)
+        } else {
+          uni.showToast({ title: (res && res.message) || '计划生成失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.planGenerating = false
+        uni.showToast({ title: '计划生成失败', icon: 'none' })
+      })
+    },
+    askAI: function() {
+      var selfCheckId = this.result && this.result.id
+      if (!selfCheckId) {
+        uni.showToast({ title: '暂无自检记录', icon: 'none' })
+        return
+      }
+      uni.navigateTo({ url: '/pages/ai/chat?selfCheckId=' + selfCheckId })
+    }
+```
+
+- [ ] **步骤 4:import 追加**
+
+在文件头部 import 区追加:
+
+```javascript
+import { generateSelfCheckPlan } from '@/utils/api'
+```
+
+- [ ] **步骤 5:样式追加**
+
+在 `<style>` 块末尾追加(复用 `.scr-btn` 模式):
+
+```css
+.scr-btn-secondary {
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  font-weight: 600;
+}
+.scr-btn-ghost {
+  background: #fff;
+  color: #666;
+  border: 2rpx solid #D1D5DB;
+}
+.scr-advice-interpretation {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+  margin: 16rpx 0;
+}
+.scr-advice-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+  margin-top: 12rpx;
+}
+.scr-advice-action-label {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #F97316;
+}
+.scr-advice-action {
+  font-size: 24rpx;
+  color: #555;
+  line-height: 1.5;
+}
+.scr-family-insight {
+  background: #F5FAFE;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-top: 24rpx;
+}
+.scr-family-insight-label {
+  display: block;
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #F97316;
+  margin-bottom: 8rpx;
+}
+.scr-family-insight-text {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+}
+.scr-fallback {
+  background: #FFF7ED;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-top: 16rpx;
+  text-align: center;
+}
+.scr-fallback text {
+  font-size: 24rpx;
+  color: #999;
+}
+```
+
+- [ ] **步骤 6:语法校验**
+
+提取 script 块语法校验:
+
+```bash
+node -e "
+var fs = require('fs');
+var content = fs.readFileSync('cfc-frontend/pages/family/self-check-result.vue', 'utf8');
+var m = content.match(/<script>([\s\S]*?)<\/script>/);
+if (m) { new Function(m[1]); console.log('script OK'); } else { console.log('no script'); }
+"
+```
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-frontend/pages/family/self-check-result.vue
+git commit -m "feat(self-check): 结果页改 AI 解读 + 生成计划按钮 + 问问AI(P0-1/P0-2/P1-2)"
+```
+
+---
+
+## 任务 10:前端 — self-check-entry.vue 趋势展示(P1-1)
+
+**文件:**
+- 修改:`cfc-frontend/pages/family/self-check-entry.vue`
+
+- [ ] **步骤 1:追加趋势区块**
+
+在 `self-check-entry.vue` 的状态提示(`sce-status`)之后、按钮区(`sce-footer`)之前,插入:
+
+```html
+      <!-- 趋势分析(P1-1) -->
+      <view class="sce-trend" v-if="trendLoaded">
+        <view class="sce-trend-header">
+          <text class="sce-trend-title">📈 自检趋势</text>
+          <text class="sce-trend-summary" v-if="trendSummary">{{ trendSummary }}</text>
+        </view>
+        <text class="sce-trend-insight" v-if="aiInsight">{{ aiInsight }}</text>
+        <view class="sce-trend-loading" v-if="trendLoading">
+          <text>AI 趋势分析中...</text>
+        </view>
+      </view>
+```
+
+- [ ] **步骤 2:script 追加**
+
+在 `data()` 追加 `trendLoaded: false, trendLoading: false, aiInsight: '', trendSummary: ''`;在 `onLoad` 中追加 `this.loadTrend()`;在 `methods` 追加:
+
+```javascript
+    loadTrend: function() {
+      var self = this
+      this.trendLoading = true
+      getSelfCheckTrendAnalysis().then(function(res) {
+        self.trendLoading = false
+        self.trendLoaded = true
+        if (res.code === 200 && res.data) {
+          self.aiInsight = res.data.aiInsight || ''
+          self.trendSummary = res.data.trendSummary || ''
+        }
+      }).catch(function() {
+        self.trendLoading = false
+        self.trendLoaded = true
+      })
+    }
+```
+
+- [ ] **步骤 3:import 追加**
+
+```javascript
+import { getSelfCheckTrendAnalysis } from '@/utils/api'
+```
+
+- [ ] **步骤 4:样式追加**
+
+```css
+.sce-trend {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx 26rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+}
+.sce-trend-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.sce-trend-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.sce-trend-summary {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+.sce-trend-insight {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+}
+.sce-trend-loading {
+  padding: 10rpx 0;
+}
+.sce-trend-loading text {
+  font-size: 24rpx;
+  color: #999;
+}
+```
+
+- [ ] **步骤 5:语法校验**
+
+提取 script 块校验(同任务 9 步骤 6 方法)。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/pages/family/self-check-entry.vue
+git commit -m "feat(self-check): 中间页展示自检趋势 AI 解读(P1-1)"
+```
+
+---
+
+## 任务 11:最终验证
+
+- [ ] **步骤 1:后端编译**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 2:LangGraph 语法校验**
+
+```bash
+cd cfc-langgraph && python -m py_compile app/graphs/self_check_analysis_graph.py app/graphs/self_check_trend_graph.py app/api/self_check.py app/main.py && echo "OK"
+```
+
+- [ ] **步骤 3:前端语法校验**
+
+```bash
+node --check cfc-frontend/utils/api.js && echo "api.js OK"
+```
+
+- [ ] **步骤 4:提交最终 commit(如有遗漏)**
+
+```bash
+git add -A
+git diff --cached --stat
+git commit -m "feat(self-check): 五维自检 AI 结合 P0+P1 全栈实现"
+```
+
+---
+
+## 自检清单
+
+**规格覆盖度:**
+- [x] P0-1 纯 AI 替换 → 任务 1(LangGraph graph)+ 任务 3/4/5(Java Service/AiGateway/submit)
+- [x] P0-2 用户点击生成计划 → 任务 6(Controller + HealthPlanService)+ 任务 9(前端按钮)
+- [x] P1-1 历史趋势 + AI 解读 → 任务 2(trend graph)+ 任务 6(trend-analysis)+ 任务 10(前端展示)
+- [x] P1-2 自检注入 Chat → 任务 7(AIChatController)+ 任务 9(askAI 按钮)
+
+**占位符扫描:** 无"待定"/"TODO"/"后续实现"
+
+**类型一致性:**
+- `WuxingSourcingAdviceVO` 新字段(interpretation/microActions/aiInsight/familyInsight/fallbackUsed)— 任务 3 定义,任务 5 解析、任务 9 渲染
+- `SelfCheckAnalysisService.generateAdvice/generateTrend/getRecentHistory` — 任务 3 定义,任务 5/6 调用
+- `AiGateway.generateSelfCheckAdvice/generateSelfCheckTrend` — 任务 4 定义,任务 3 调用
+- `HealthPlanService.generateFromSelfCheck` — 任务 6 接口+实现,Controller 调用
+- `generateSelfCheckPlan/getSelfCheckTrendAnalysis` — 任务 8 前端定义,任务 9/10 调用

+ 1389 - 0
docs/superpowers/plans/2026-09-01-cf-commission-redesign.md

@@ -0,0 +1,1389 @@
+# CF 值分佣体系重构 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 将全部订单的返利/分佣统一迁移到 CF 值体系(个人钱包),废弃旧佣金体系写入;返佣比例由全层级团队规模阶梯决定;支持成员间转让 CF 与家庭共享优惠券。
+
+**架构:** 新建 `CfCommissionService` 作为唯一分佣入口,按订单类型区分「双返(当前人+推荐人)」与「只返推荐人(会员/订阅)」。推荐关系以 `referral_tree`(物化路径)为全层级唯一事实源,绑定即物化并回填存量;比例由 `cf_rate_tier` 阶梯配置按 `total_team_size` 匹配。个人 CF 钱包复用 `user_platform_balance`。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus + Java 8;验证命令 `mvn clean compile`。
+
+**前置事实(实现须知):**
+- `referral_tree` 表当前**无任何写入代码**(仅 `CommissionDistService`/`EnergyService` 读取),推荐关系实际存于 `users.referrer_id`(单层)。本计划需:① `bindReferral` 时物化整条祖先链到 `referral_tree`;② 提供存量回填。
+- `PlatformPointsService.earn` **当前无幂等检查**,需补 `(ref_type, ref_id)` 去重。
+- `promotion_tier_config` 表列(`min_team_size_1st/2nd + commission_rate_l1/l2`)与实体(`minTeamSize/profitSharePercent/enabled`)**不一致**,本计划整体废弃该表与实体,改用 `cf_rate_tier`。
+- `CouponService.grant(Long userId, Long couponId, String grantType, String period, String source)` 已存在(line 195)。
+- 后端唯一验证方式:`cd cfc-backend && mvn clean compile`(无运行时验证)。
+
+**规格文档:** `docs/superpowers/specs/2026-09-01-cf-commission-redesign.md`
+
+---
+
+## 文件结构
+
+**新建后端:**
+- `cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java` — 阶梯配置实体
+- `cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java` — 阶梯配置 Mapper
+- `cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java` — CF 流转记录实体
+- `cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java` — 流转记录 Mapper
+- `cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java` — 统一分佣核心
+- `cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java` — 推荐树物化 + 团队规模统计 + 转让
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java` — 用户查询接口
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java` — CF 转让接口
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java` — 管理端阶梯配置
+
+**修改后端:**
+- `service/CommissionService.java` — `bindReferral` 物化推荐树;`settle/settleTwoLevel` 标 `@Deprecated`(调用点摘除后)
+- `service/PlatformPointsService.java` — `earn` 补幂等
+- `service/PromotionTierService.java` — 删除 3 层字段逻辑,加 `refreshRate`
+- `entity/PromotionTier.java` / `entity/PromotionTierConfig.java` — 精简/废弃
+- `service/PromotionTierEvalService.java` — 改读 `cf_rate_tier`
+- `task/PromotionTierCheckScheduledTask.java` — 改调 `refreshRate`
+- `service/AssessmentOrderService.java` / `PackagePaymentService.java` / `PaymentService.java` / `MembershipService.java` / `MemberSubscriptionService.java` / `ProductOrderService.java` — 迁移结算调用点
+- `service/CouponService.java` / `service/FamilyPlatformPointsService.java` — 券挂 `family_id`
+- `config/DatabaseInitializer.java` — 迁移脚本
+- `resources/schema.sql` — 同步 DDL
+
+**新建前端(小程序 `cfc-frontend/`):**
+- `pages/promotion/team.vue`(修改)— 展示全层级团队规模 + 返佣比例
+- `pages/promotion/commission.vue`(修改)— CF 分润流水
+- `pages/promotion/index.vue`(修改)— 个人 CF 钱包汇总
+- `pages/profile-extra/*`(修改)— 家庭券库 + CF 转让入口
+- `utils/api.js`(修改)— 新增接口封装
+
+---
+
+### 任务 1:数据库迁移(DatabaseInitializer + schema.sql)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+
+- [ ] **步骤 1:确认最新迁移编号**
+
+运行:`grep -n "// 迁移" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java | tail -5`
+预期:记录最大编号 `迁移N`(当前应 ≥113)。后续迁移编号从 N+1 递增。
+
+- [ ] **步骤 2:在 `runMigrations()` 末尾追加 cf_rate_tier 建表+种子**
+
+```java
+// 迁移N+1: 创建 cf_rate_tier 表(CF值返佣阶梯配置,替代 promotion_tier_config)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_rate_tier (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "tier_name VARCHAR(50) NOT NULL COMMENT '档位名称', " +
+            "min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)', " +
+            "rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%)', " +
+            "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高', " +
+            "enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "INDEX idx_min_size (min_team_size)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置'");
+    log.info("已创建cf_rate_tier表");
+    Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cf_rate_tier", Integer.class);
+    if (cnt == null || cnt == 0) {
+        jdbcTemplate.execute("INSERT INTO cf_rate_tier (tier_name, min_team_size, rate_percent, sort_order) VALUES " +
+                "('铜牌', 0, 5, 1), ('银牌', 3, 10, 2), ('金牌', 10, 15, 3), ('铂金', 30, 20, 4), ('钻石', 100, 25, 5)");
+        log.info("cf_rate_tier 种子数据已初始化");
+    }
+} catch (Exception e) {
+    log.warn("创建cf_rate_tier表失败: {}", e.getMessage());
+}
+```
+
+- [ ] **步骤 3:追加 cf_transfer_record 建表**
+
+```java
+// 迁移N+2: 创建 cf_transfer_record 表(CF值流转记录)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_transfer_record (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "from_user_id BIGINT COMMENT '转出用户ID(null=平台)', " +
+            "to_user_id BIGINT COMMENT '转入用户ID', " +
+            "family_id BIGINT COMMENT '所属家庭ID', " +
+            "amount INT NOT NULL COMMENT 'CF值数量', " +
+            "type VARCHAR(16) NOT NULL COMMENT 'transfer/allocate/refund', " +
+            "ref_type VARCHAR(50) COMMENT '关联业务类型', " +
+            "ref_id BIGINT COMMENT '关联业务ID', " +
+            "remark VARCHAR(255), " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_from (from_user_id), " +
+            "INDEX idx_to (to_user_id), " +
+            "INDEX idx_family (family_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录'");
+    log.info("已创建cf_transfer_record表");
+} catch (Exception e) {
+    log.warn("创建cf_transfer_record表失败: {}", e.getMessage());
+}
+```
+
+- [ ] **步骤 4:promotion_tier 表精简 + coupon 加 family_id**
+
+```java
+// 迁移N+3: promotion_tier 精简列(删三层,加 rate_percent)
+try {
+    jdbcTemplate.execute("ALTER TABLE promotion_tier DROP COLUMN team_size_1st, DROP COLUMN team_size_2nd, DROP COLUMN team_size_3rd");
+    log.info("promotion_tier 已删除三层团队列");
+} catch (Exception e) {
+    log.warn("promotion_tier 三层列删除失败(可能不存在): {}", e.getMessage());
+}
+try {
+    jdbcTemplate.execute("ALTER TABLE promotion_tier ADD COLUMN rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)'");
+    log.info("promotion_tier 已添加 rate_percent");
+} catch (Exception e) {
+    log.warn("promotion_tier.rate_percent 已存在: {}", e.getMessage());
+}
+
+// 迁移N+4: coupon 表添加 family_id
+try {
+    jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)'");
+    log.info("coupon 已添加 family_id");
+} catch (Exception e) {
+    log.warn("coupon.family_id 已存在: {}", e.getMessage());
+}
+```
+
+- [ ] **步骤 5:同步 schema.sql**
+
+在 `schema.sql` 末尾追加三张新表的 `CREATE TABLE IF NOT EXISTS`(与任务 1 步骤 2/3 SQL 一致);修改 `promotion_tier` 的 CREATE TABLE 去掉 `team_size_1st/2nd/3rd` 并加 `rate_percent`;`coupon` 的 CREATE TABLE 加 `family_id` 列。保持 schema.sql 为完整快照。
+
+- [ ] **步骤 6:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(cf): 数据库迁移:cf_rate_tier/cf_transfer_record 建表 + promotion_tier 精简 + coupon 加 family_id"
+```
+
+---
+
+### 任务 2:`CfRateTier` / `CfTransferRecord` 实体与 Mapper
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java`
+
+- [ ] **步骤 1:创建 `CfRateTier` 实体**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("cf_rate_tier")
+public class CfRateTier implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String tierName;
+    private Integer minTeamSize;
+    private Integer ratePercent;
+    private Integer sortOrder;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **步骤 2:创建 `CfRateTierMapper`**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfRateTier;
+
+public interface CfRateTierMapper extends BaseMapper<CfRateTier> {
+}
+```
+
+- [ ] **步骤 3:创建 `CfTransferRecord` 实体**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("cf_transfer_record")
+public class CfTransferRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long fromUserId;
+    private Long toUserId;
+    private Long familyId;
+    private Integer amount;
+    private String type;      // transfer/allocate/refund
+    private String refType;
+    private Long refId;
+    private String remark;
+    private Date createdAt;
+}
+```
+
+- [ ] **步骤 4:创建 `CfTransferRecordMapper`**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfTransferRecord;
+
+public interface CfTransferRecordMapper extends BaseMapper<CfTransferRecord> {
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java
+git commit -m "feat(cf): 新增 cf_rate_tier 与 cf_transfer_record 实体/Mapper"
+```
+
+---
+
+### 任务 3:`PlatformPointsService.earn` 补幂等
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java`
+
+- [ ] **步骤 1:为 `earn` 增加幂等检查**
+
+在 `PlatformPointsService.earn`(当前 line 73-84)开头,`amount <= 0` 校验之后、余额变更之前插入:
+
+```java
+// 幂等:按 (userId, ref_type, ref_id) 去重
+if (refId != null) {
+    Long existing = logMapper.selectCount(
+            new LambdaQueryWrapper<PlatformBalanceLog>()
+                    .eq(PlatformBalanceLog::getUserId, userId)
+                    .eq(PlatformBalanceLog::getRefType, refType)
+                    .eq(PlatformBalanceLog::getRefId, refId));
+    if (existing != null && existing > 0) {
+        return;
+    }
+}
+```
+
+确认文件已 import `PlatformBalanceLog` 与 `LambdaQueryWrapper`(`PlatformBalanceLog` 当前未 import,需补 `import com.etotem.cfc.entity.PlatformBalanceLog;`;`LambdaQueryWrapper` 已在 line 3 存在)。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java
+git commit -m "feat(cf): PlatformPointsService.earn 增加 (ref_type, ref_id) 幂等去重"
+```
+
+---
+
+### 任务 4:`CfReferralService` — 推荐树物化 + 团队规模统计
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java`
+
+**背景:** `referral_tree` 表当前无写入。本任务实现:绑定推荐时物化整条祖先链;全层级团队规模统计;按团队规模匹配阶梯比例。
+
+- [ ] **步骤 1:创建 `CfReferralService`**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 推荐关系物化与团队规模统计(全层级)
+ */
+@Service
+public class CfReferralService {
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private CfRateTierMapper cfRateTierMapper;
+
+    /**
+     * 绑定推荐关系时物化整条祖先链(含本人 L1)到 referral_tree。
+     * 调用方:CommissionService.bindReferral() 设置 user.referrerId 之后。
+     */
+    @Transactional
+    public void materializeReferralTree(Long childId) {
+        // 防止重复物化
+        Long cnt = referralTreeMapper.selectCount(
+                new LambdaQueryWrapper<ReferralTree>().eq(ReferralTree::getChildId, childId));
+        if (cnt != null && cnt > 0) {
+            return;
+        }
+        // 沿 referrerId 链收集祖先(child 本人算 level 0)
+        List<Long> chain = new ArrayList<>();
+        User cur = userMapper.selectById(childId);
+        while (cur != null && cur.getReferrerId() != null && !chain.contains(cur.getReferrerId())) {
+            chain.add(cur.getReferrerId());
+            cur = userMapper.selectById(cur.getReferrerId());
+        }
+        // 写 referral_tree:level = 1..n,path = 从根到该祖先
+        // path 格式: /ancestorId/.../directReferrerId/
+        StringBuilder path = new StringBuilder("/");
+        for (int i = chain.size() - 1; i >= 0; i--) {
+            Long parentId = chain.get(i);
+            path.append(parentId).append("/");
+            ReferralTree node = new ReferralTree();
+            node.setParentId(parentId);
+            node.setChildId(childId);
+            node.setLevel(chain.size() - i);
+            node.setPath(path.toString());
+            node.setCreatedAt(new Date());
+            referralTreeMapper.insert(node);
+        }
+    }
+
+    /**
+     * 全层级团队总人数(所有下线,含间接)。
+     * 统计 referral_tree 中 parent_id = userId 的所有记录数。
+     */
+    public int getTotalTeamSize(Long userId) {
+        Long cnt = referralTreeMapper.selectCount(
+                new LambdaQueryWrapper<ReferralTree>().eq(ReferralTree::getParentId, userId));
+        return cnt == null ? 0 : cnt.intValue();
+    }
+
+    /**
+     * 按团队规模匹配阶梯比例:cf_rate_tier WHERE enabled=1 AND min_team_size <= size ORDER BY min_team_size DESC LIMIT 1
+     * 无匹配返回 0。
+     */
+    public CfRateTier matchRateTier(int teamSize) {
+        List<CfRateTier> tiers = cfRateTierMapper.selectList(
+                new LambdaQueryWrapper<CfRateTier>()
+                        .eq(CfRateTier::getEnabled, 1)
+                        .le(CfRateTier::getMinTeamSize, teamSize)
+                        .orderByDesc(CfRateTier::getMinTeamSize)
+                        .last("LIMIT 1"));
+        return tiers.isEmpty() ? null : tiers.get(0);
+    }
+}
+```
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java
+git commit -m "feat(cf): CfReferralService 推荐树物化 + 全层级团队规模 + 阶梯比例匹配"
+```
+
+---
+
+### 任务 5:`CfCommissionService` — 统一分佣核心
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java`
+
+- [ ] **步骤 1:创建 `CfCommissionService`**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 统一 CF 值分佣服务。
+ * 规则:
+ *  - 普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人按团队规模阶梯比例分润
+ *  - 会员/订阅订单:只返推荐人,不返当前人
+ *  - 同家庭互推:跳过本人,上溯到第一个非同家庭引荐人
+ */
+@Service
+public class CfCommissionService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfCommissionService.class);
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private CfReferralService cfReferralService;
+    @Resource
+    private PlatformPointsService platformPointsService;
+    @Resource
+    private CfTransferRecordMapper cfTransferRecordMapper;
+    @Resource
+    private PpointConfigService ppointConfigService;
+    @Resource
+    private SysConfigService sysConfigService;
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 通用分佣(双返):当前人 + 推荐人
+     */
+    @Transactional
+    public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                       Integer orderAmountCent, Long productId) {
+        // 当前消费人返 CF(个人钱包)
+        int buyerReturn = calcBuyerReturn(orderType, orderAmountCent, productId, buyerUserId);
+        if (buyerReturn > 0) {
+            try {
+                platformPointsService.earn(buyerUserId, buyerReturn, "order_consume", orderId,
+                        "消费返CF:" + orderType);
+                record(buyerUserId, null, null, buyerReturn, "allocate", "order_consume", orderId, "消费返CF");
+            } catch (Exception e) {
+                log.error("当前人返CF失败: buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
+            }
+        }
+        // 推荐人分润
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
+    }
+
+    /**
+     * 会员/订阅专用:只返推荐人
+     */
+    @Transactional
+    public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                   Integer orderAmountCent) {
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
+    }
+
+    /**
+     * 计算当前消费人返 CF。
+     * 商品:floor(orderAmount/100) × P点 × shareBps/10000
+     * 非商品:floor(orderAmount/100) × serviceRateBps/10000
+     */
+    private int calcBuyerReturn(String orderType, Integer orderAmountCent, Long productId, Long buyerUserId) {
+        if (orderAmountCent == null || orderAmountCent <= 0) return 0;
+        int amountYuan = orderAmountCent / 100;
+        if ("product".equals(orderType) && productId != null) {
+            try {
+                Product p = productMapper.selectById(productId);
+                if (p == null) return 0;
+                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
+                if (effectivePpoint <= 0) return 0;
+                int shareBps = getSysBps("product_platform_points_share", 1000);
+                return amountYuan * effectivePpoint / 100 * shareBps / 10000;
+            } catch (Exception e) {
+                return 0;
+            }
+        }
+        int serviceRateBps = getSysBps("commission_service_rate", 1000);
+        return amountYuan * serviceRateBps / 10000;
+    }
+
+    /**
+     * 推荐人分润:查全链路 → 同家庭跳过上溯 → 按阶梯比例
+     */
+    private void distributeToReferrers(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                       Integer orderAmountCent, Long productId) {
+        if (orderAmountCent == null || orderAmountCent <= 0) return;
+        int amountYuan = orderAmountCent / 100;
+
+        // 取买家全链路推荐人(referral_tree,含全部层级)
+        List<ReferralTree> referrals = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getChildId, buyerUserId)
+                        .orderByAsc(ReferralTree::getLevel));
+        if (referrals == null || referrals.isEmpty()) return;
+
+        List<Long> processed = new ArrayList<>();
+        for (ReferralTree ref : referrals) {
+            Long referrerId = ref.getParentId();
+            if (processed.contains(referrerId)) continue;
+            processed.add(referrerId);
+
+            User referrer = userMapper.selectById(referrerId);
+            if (referrer == null) continue;
+            // 同家庭跳过(D5):不返,继续上溯(循环继续)
+            if (buyerFamilyId != null && buyerFamilyId.equals(referrer.getFamilyId())) {
+                continue;
+            }
+            // 按团队规模匹配阶梯比例
+            int teamSize = cfReferralService.getTotalTeamSize(referrerId);
+            CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+            int ratePercent = tier == null ? 0 : tier.getRatePercent();
+            if (ratePercent <= 0) continue;
+
+            // 分润基数:商品按 P点,非商品按服务费率
+            int base = calcReferrerBase(orderType, amountYuan, productId);
+            if (base <= 0) continue;
+            int share = base * ratePercent / 100;
+            if (share <= 0) continue;
+
+            try {
+                platformPointsService.earn(referrerId, share, "referral_dist", orderId,
+                        "推荐分润:" + orderType);
+                record(referrerId, null, referrer.getFamilyId(), share, "allocate", "referral_dist", orderId, "推荐分润");
+            } catch (Exception e) {
+                log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
+            }
+        }
+    }
+
+    private int calcReferrerBase(String orderType, int amountYuan, Long productId) {
+        if ("product".equals(orderType) && productId != null) {
+            try {
+                Product p = productMapper.selectById(productId);
+                if (p == null) return 0;
+                return ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
+            } catch (Exception e) {
+                return 0;
+            }
+        }
+        int serviceRateBps = getSysBps("commission_service_rate", 1000);
+        return amountYuan * serviceRateBps / 10000;
+    }
+
+    private int getSysBps(String key, int def) {
+        try {
+            String v = sysConfigService.getValue(key);
+            if (v != null && !v.isEmpty()) return Integer.parseInt(v);
+        } catch (Exception ignored) {}
+        return def;
+    }
+
+    private void record(Long fromUserId, Long toUserId, Long familyId, int amount, String type,
+                        String refType, Long refId, String remark) {
+        CfTransferRecord r = new CfTransferRecord();
+        r.setFromUserId(fromUserId);
+        r.setToUserId(toUserId);
+        r.setFamilyId(familyId);
+        r.setAmount(amount);
+        r.setType(type);
+        r.setRefType(refType);
+        r.setRefId(refId);
+        r.setRemark(remark);
+        r.setCreatedAt(new Date());
+        try { cfTransferRecordMapper.insert(r); } catch (Exception e) { log.warn("写流转记录失败: {}", e.getMessage()); }
+    }
+}
+```
+
+> **签名已确认:** `PpointConfigService.getEffectivePpoint(Long productId, Long categoryId)`(line 75)。商品订单需先 `productMapper.selectById(productId)` 取 `getCategoryId()` 再调用。上述 `calcBuyerReturn`/`calcReferrerBase` 已按此修正。`Product` 实体有 `getCategoryId()`(line 62)。
+
+- [ ] **步骤 2:确认 `PpointConfigService.getEffectivePpoint` 签名(已核对,无需重复 grep)**
+
+签名 `getEffectivePpoint(Long productId, Long categoryId)` 已确认;`ProductMapper`、`Product` 已在步骤 1 注入/引用。
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
+git commit -m "feat(cf): CfCommissionService 统一分佣核心(双返/只返推荐人/同家庭上溯/阶梯比例)"
+```
+
+---
+
+### 任务 6:`CommissionService.bindReferral` 物化推荐树 + 团队规模更新
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`(line 108-141 `bindReferral`)
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java`
+
+- [ ] **步骤 1:`bindReferral` 末尾调用物化**
+
+在 `CommissionService.bindReferral()`(当前 `userMapper.updateById(user)` 与 `onboardingService.completeTask` 之间)插入:
+
+```java
+// 物化推荐树(全层级)
+try { cfReferralService.materializeReferralTree(userId); } catch (Exception e) { log.warn("推荐树物化失败 userId={}", userId, e); }
+```
+
+在类中新增注入:
+
+```java
+@Resource
+private CfReferralService cfReferralService;
+```
+
+- [ ] **步骤 2:`PromotionTier` 实体精简**
+
+删除 `teamSize1st/teamSize2nd/teamSize3rd` 三个字段(含 `@TableField` 注解),新增:
+
+```java
+private Integer ratePercent;
+```
+
+- [ ] **步骤 3:`PromotionTierService` 重写 `updateTeamSize` + 新增 `refreshRate`**
+
+将 `updateTeamSize(Long userId, int level, int delta)` 替换为:
+
+```java
+/**
+ * 团队规模 +delta(新推荐绑定后对所有祖先增量)。level 参数已废弃(改为全层级)。
+ */
+public void updateTeamSize(Long userId, int delta) {
+    PromotionTier tier = getCurrentTier(userId);
+    if (tier == null) {
+        tier = new PromotionTier();
+        tier.setUserId(userId);
+        tier.setTier("R0");
+        tier.setTotalTeamSize(0);
+        tier.setTotalReferralEarnings(0);
+        tier.setTotalShareEarnings(0);
+        tier.setCreatedAt(new Date());
+        tier.setUpdatedAt(new Date());
+        tierMapper.insert(tier);
+    }
+    tier.setTotalTeamSize((tier.getTotalTeamSize() == null ? 0 : tier.getTotalTeamSize()) + delta);
+    tier.setLastChangeAt(new Date());
+    tier.setUpdatedAt(new Date());
+    tierMapper.updateById(tier);
+    refreshRate(userId);
+}
+
+/**
+ * 按全层级团队规模刷新等级与返佣比例(读 cf_rate_tier)
+ */
+public void refreshRate(Long userId) {
+    try {
+        int teamSize = cfReferralService.getTotalTeamSize(userId);
+        CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+        PromotionTier pt = getCurrentTier(userId);
+        if (pt == null) return;
+        if (tier != null) {
+            pt.setTier(tier.getTierName());
+            pt.setRatePercent(tier.getRatePercent());
+        } else {
+            pt.setRatePercent(0);
+        }
+        pt.setUpdatedAt(new Date());
+        tierMapper.updateById(pt);
+    } catch (Exception e) {
+        log.warn("refreshRate失败 userId={}", userId, e);
+    }
+}
+```
+
+需要注入 `CfReferralService cfReferralService` 与 `CfRateTier` import。同时删除 `addReferralEarnings/addShareEarnings` 中对 `teamSize1st/2nd/3rd` 的依赖(现有实现未用它们,可直接保留,但确认无编译错误)。
+
+- [ ] **步骤 4:`PromotionTierEvalService` 改读 cf_rate_tier**
+
+将 `evaluateTier` 方法体替换为:
+
+```java
+public String evaluateTier(Long userId) {
+    try {
+        int teamSize = cfReferralService.getTotalTeamSize(userId);
+        CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+        return tier == null ? "R0" : tier.getTierName();
+    } catch (Exception e) {
+        return "R0";
+    }
+}
+```
+
+删除对 `promotion_tier_config` / `PromotionTierConfig` / `configMapper` 的依赖(或保留但不再查询)。`PromotionTierConfig` 实体标记 `@Deprecated` 并在类上加注释"已废弃,改用 cf_rate_tier"。
+
+- [ ] **步骤 5:`PromotionTierCheckScheduledTask` 改调 `refreshRate`**
+
+将定时任务改为批量 `refreshRate`(替代 `evaluateAllUsers`):
+
+```java
+@Scheduled(cron = "0 0 1 * * ?")
+public void checkAndUpdateAllTiers() {
+    log.info("开始执行推广等级定时刷新任务");
+    try {
+        List<PromotionTier> allTiers = promotionTierMapper.selectList(null);
+        for (PromotionTier pt : allTiers) {
+            try { promotionTierService.refreshRate(pt.getUserId()); } catch (Exception e) { /* 单用户失败不影响 */ }
+        }
+        log.info("推广等级定时刷新完成,共{}个用户", allTiers.size());
+    } catch (Exception e) {
+        log.error("推广等级定时刷新失败", e);
+    }
+}
+```
+
+需要注入 `PromotionTierMapper`。
+
+- [ ] **步骤 6:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS(若 `CommissionDistService` 或 `EnergyService` 引用了已删除字段需一并修正)
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierEvalService.java cfc-backend/src/main/java/com/etotem/cfc/task/PromotionTierCheckScheduledTask.java
+git commit -m "feat(cf): 推荐绑定物化推荐树 + 团队规模全层级统计 + 阶梯比例刷新(替代 promotion_tier_config)"
+```
+
+---
+
+### 任务 7:订单结算迁移点切换(7 处)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java:76`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java:220`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java:240`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java:784`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java:174,255`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java:633-648`
+
+- [ ] **步骤 1:测评订单迁移(AssessmentOrderService:76)**
+
+将 `commissionService.settle(order.getId(), "assessment", order.getUserId(), ..., null)` 替换为:
+
+```java
+cfCommissionService.settle(order.getId(), "assessment", order.getUserId(),
+        userMapper.selectById(order.getUserId()).getFamilyId(),
+        order.getActualPrice() != null ? order.getActualPrice().intValue() : 0, null);
+```
+
+注入 `CfCommissionService cfCommissionService` 与 `UserMapper userMapper`(如已存在则复用)。注意 `orderType` 传 `"assessment"`。
+
+- [ ] **步骤 2:套餐订单迁移(PackagePaymentService:220 + PaymentService:240)**
+
+将 `commissionService.settle(..., "package", ...)` 替换为:
+
+```java
+cfCommissionService.settle(order.getId(), "package", order.getUserId(),
+        order.getFamilyId(), order.getPrice(), null);
+```
+
+若 `order` 无 `getFamilyId()`,改用 `userMapper.selectById(order.getUserId()).getFamilyId()`。`orderType` 传 `"package"`。
+
+> **注意:** `PackagePaymentService:220` 与 `PaymentService:240` 都处理 package 结算。因 `CfCommissionService` 依赖 `platformPointsService.earn` 的 `(ref_type, ref_id)` 幂等(任务 3 已实现),重复调用会自动跳过,无需额外去重逻辑。
+
+- [ ] **步骤 3:会员订单迁移(MembershipService:784)**
+
+将 `commissionService.settleTwoLevel(..., "membership", adminUserId, ...)` 替换为:
+
+```java
+cfCommissionService.settleReferrerOnly(order.getId(), "membership", adminUserId,
+        userMapper.selectById(adminUserId).getFamilyId(), order.getAmount());
+```
+
+注入 `CfCommissionService`。`orderType` 传 `"membership"`。
+
+- [ ] **步骤 4:订阅订单迁移(MemberSubscriptionService:174,255)**
+
+两处 `commissionService.settleTwoLevel(..., "subscription", family.getCreatorId(), amount, null)` 替换为:
+
+```java
+cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
+        family.getId(), amount);
+```
+
+注入 `CfCommissionService`。`orderType` 传 `"subscription"`。
+
+- [ ] **步骤 5:商品订单统一入口(ProductOrderService:633-648)**
+
+将 `:633` 个人 CF 返现块与 `:645` 推荐人分润块**整体替换**为单次 `cfCommissionService.settle(...)` 调用:
+
+```java
+// CF值:统一分佣(当前人 + 推荐人)
+try {
+    cfCommissionService.settle(order.getId(), "product", order.getBuyerId(),
+            userMapper.selectById(order.getBuyerId()).getFamilyId(),
+            order.getTotalAmount(), order.getProductId());
+} catch (Exception e) {
+    log.error("CF值分佣失败: orderId={}, error={}", order.getId(), e.getMessage());
+}
+```
+
+删除原 `:622-641` 的 `platformPointsService.earn(...)` 块与 `:643-648` 的 `commissionDistService.distribute(...)` 块。保留 `:827 confirmReceive` 家庭池返 CF(任务外,不改)。
+
+- [ ] **步骤 6:活动订单确认**
+
+确认 `ActivityOrderService` 无结算调用(现有即无),无需改动。
+
+- [ ] **步骤 7:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS(需处理旧 `commissionService` 字段可能变为未使用——若不再被引用可保留注入或移除)
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git commit -m "feat(cf): 全部订单结算迁移到 CfCommissionService(统一 CF 值分佣),废弃旧佣金调用"
+```
+
+---
+
+### 任务 8:旧佣金体系废弃标记 + 清理
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`
+
+- [ ] **步骤 1:确认无残留调用点**
+
+运行:`grep -rn "commissionService.settle\|commissionService.settleTwoLevel" cfc-backend/src/main/java/com/etotem/cfc`
+预期:仅 `CommissionService.java` 内部定义(`settle` → `settleTwoLevel` 委托),无其他业务调用。
+
+- [ ] **步骤 2:`settle`/`settleTwoLevel` 加废弃注释**
+
+确认 `settle`(line 180)与 `settleTwoLevel`(line 194)已有 `@Deprecated` 注解。在方法 javadoc 补充:
+
+```java
+/**
+ * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
+ */
+```
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "chore(cf): 旧佣金 settle/settleTwoLevel 确认废弃(无业务调用方)"
+```
+
+---
+
+### 任务 9:用户查询接口(团队规模 + 返佣比例 + CF 流水)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java`
+
+- [ ] **步骤 1:创建 `CfCommissionController`**
+
+```java
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.PlatformBalanceLog;
+import com.etotem.cfc.service.CfReferralService;
+import com.etotem.cfc.service.PlatformPointsService;
+import com.etotem.cfc.service.PromotionTierService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/commission/cf")
+public class CfCommissionController {
+
+    @Resource
+    private CfReferralService cfReferralService;
+    @Resource
+    private PlatformPointsService platformPointsService;
+    @Resource
+    private PromotionTierService promotionTierService;
+
+    /** 我的团队规模 + 当前返佣比例 + 档位名 */
+    @PostMapping("/rate")
+    public Result<Map<String, Object>> rate(@RequestAttribute("userId") Long userId) {
+        int teamSize = cfReferralService.getTotalTeamSize(userId);
+        CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+        Map<String, Object> data = new HashMap<>();
+        data.put("totalTeamSize", teamSize);
+        data.put("ratePercent", tier == null ? 0 : tier.getRatePercent());
+        data.put("tierName", tier == null ? "未入档" : tier.getTierName());
+        return Result.success(data);
+    }
+
+    /** 我的 CF 钱包汇总 */
+    @PostMapping("/summary")
+    public Result<Map<String, Object>> summary(@RequestAttribute("userId") Long userId) {
+        return Result.success(platformPointsService.getBalance(userId));
+    }
+
+    /** CF 流水(分页) */
+    @PostMapping("/list")
+    public Result<Page<PlatformBalanceLog>> list(@RequestAttribute("userId") Long userId,
+                                                 @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        return Result.success(platformPointsService.getLogs(userId, page, size));
+    }
+}
+```
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java
+git commit -m "feat(cf): 用户查询接口(团队规模/返佣比例/CF汇总/流水)"
+```
+
+---
+
+### 任务 10:CF 转让接口
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java`
+
+- [ ] **步骤 1:创建 `CfTransferController`**
+
+```java
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PlatformPointsService;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/cf/transfer")
+public class CfTransferController {
+
+    @Resource
+    private PlatformPointsService platformPointsService;
+    @Resource
+    private CfTransferRecordMapper cfTransferRecordMapper;
+    @Resource
+    private UserMapper userMapper;
+
+    /** 成员间转让 CF(需同家庭) */
+    @PostMapping("/send")
+    @Transactional
+    public Result<String> send(@RequestAttribute("userId") Long fromUserId,
+                               @RequestBody Map<String, Object> params) {
+        Long toUserId = ((Number) params.get("toUserId")).longValue();
+        int amount = ((Number) params.get("amount")).intValue();
+        if (amount <= 0) {
+            return Result.error("CF值必须为正数");
+        }
+        User from = userMapper.selectById(fromUserId);
+        User to = userMapper.selectById(toUserId);
+        if (from == null || to == null) {
+            return Result.error("用户不存在");
+        }
+        if (from.getFamilyId() == null || !from.getFamilyId().equals(to.getFamilyId())) {
+            return Result.error("仅限同一家庭成员间转让");
+        }
+        // 每次转让使用唯一 refId(避免 earn 幂等键 (ref_type, ref_id) 碰撞)
+        Long transferRefId = java.util.UUID.randomUUID().getMostSignificantBits();
+        if (transferRefId == null || transferRefId == 0) transferRefId = System.currentTimeMillis();
+        // 转出扣减 + 转入增加(同一事务)
+        platformPointsService.spend(fromUserId, amount, "cf_transfer_out", transferRefId,
+                "转给成员: " + (to.getNickname() == null ? toUserId : to.getNickname()));
+        platformPointsService.earn(toUserId, amount, "cf_transfer_in", transferRefId,
+                "收到成员转让: " + (from.getNickname() == null ? fromUserId : from.getNickname()));
+
+        CfTransferRecord record = new CfTransferRecord();
+        record.setFromUserId(fromUserId);
+        record.setToUserId(toUserId);
+        record.setFamilyId(from.getFamilyId());
+        record.setAmount(amount);
+        record.setType("transfer");
+        record.setRemark("成员间转让");
+        record.setCreatedAt(new Date());
+        cfTransferRecordMapper.insert(record);
+        return Result.success("转让成功");
+    }
+
+    /** 转让记录(分页,按当前用户家庭) */
+    @PostMapping("/list")
+    public Result<Page<CfTransferRecord>> list(@RequestAttribute("userId") Long userId,
+                                               @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        User user = userMapper.selectById(userId);
+        Long familyId = user == null ? null : user.getFamilyId();
+        LambdaQueryWrapper<CfTransferRecord> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(CfTransferRecord::getFamilyId, familyId);
+        }
+        wrapper.orderByDesc(CfTransferRecord::getCreatedAt);
+        return Result.success(cfTransferRecordMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+}
+```
+
+> **幂等说明:** 每次转让生成唯一 `transferRefId`(`UUID.randomUUID().getMostSignificantBits()`),`spend`/`earn` 共用该 refId。`spend` 无幂等检查(始终扣减),`earn` 用 `(ref_type='cf_transfer_in', ref_id=transferRefId)` 去重——因每次 transferRefId 唯一,多次转让互不影响。注意 `PlatformPointsService.earn` 幂等检查要求 `refId != null`(任务 3 实现),若 `UUID.getMostSignificantBits()` 返回 0(极小概率)则退化为 `System.currentTimeMillis()`。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java
+git commit -m "feat(cf): CF 成员间转让接口(同家庭校验 + 双账本 + 流水)"
+```
+
+---
+
+### 任务 11:优惠券绑定家庭(家庭券库)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java`(新增家庭券库接口)
+
+- [ ] **步骤 1:`UserCoupon` 实体加 `familyId`**
+
+`cfc-backend/src/main/java/com/etotem/cfc/entity/UserCoupon.java` 新增字段:
+
+```java
+private Long familyId;
+```
+
+- [ ] **步骤 2:`grant` 方法支持传 familyId**
+
+`CouponService.grant`(line 195)增加重载:
+
+```java
+public boolean grant(Long userId, Long couponId, String grantType, String period, String source, Long familyId) {
+    UserCoupon uc = new UserCoupon();
+    uc.setUserId(userId);
+    uc.setCouponId(couponId);
+    uc.setStatus("AVAILABLE");
+    uc.setReceivedAt(new Date());
+    uc.setFamilyId(familyId);
+    userCouponMapper.insert(uc);
+    return true;
+}
+```
+
+原 5 参 `grant` 保留并委托新方法(传 null)。核对原 `grant` 现有实现(line 195-231),保持 `CouponGrantLog` 写入等原有逻辑不丢失。
+
+- [ ] **步骤 3:`exchangeCouponByCf` 兑换后券挂家庭**
+
+`FamilyPlatformPointsService.exchangeCouponByCf`(line 271)调用改为:
+
+```java
+couponService.grant(refUserId, couponId, "CF_EXCHANGE", null, "family:" + familyId, familyId);
+```
+
+- [ ] **步骤 4:家庭券库查询接口(新建独立 Controller)**
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/controller/CfCouponController.java`(避免在 `CfCommissionController` 注入券相关 Mapper 导致职责混杂):
+
+```java
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.UserCoupon;
+import com.etotem.cfc.mapper.UserCouponMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/coupon")
+public class CfCouponController {
+
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private UserCouponMapper userCouponMapper;
+
+    /** 家庭共享券列表(family_id 匹配当前用户家庭,status=AVAILABLE) */
+    @PostMapping("/family/list")
+    public Result<Page<UserCoupon>> familyList(@RequestAttribute("userId") Long userId,
+                                               @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        User user = userMapper.selectById(userId);
+        Long familyId = user == null ? null : user.getFamilyId();
+        LambdaQueryWrapper<UserCoupon> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(UserCoupon::getFamilyId, familyId);
+        } else {
+            wrapper.eq(UserCoupon::getFamilyId, -1L); // 无家庭则不返回任何券
+        }
+        wrapper.eq(UserCoupon::getStatus, "AVAILABLE").orderByDesc(UserCoupon::getReceivedAt);
+        return Result.success(userCouponMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+}
+```
+
+> 说明:本接口与规格 6.3 中的 `/api/coupon/family/list` 一致;原计划将接口塞进 `CfCommissionController` 的写法废弃,改为独立 `CfCouponController`,避免职责混杂。
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/UserCoupon.java cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java cfc-backend/src/main/java/com/etotem/cfc/controller/CfCouponController.java
+git commit -m "feat(cf): 优惠券绑定家庭 + 家庭券库查询(CF兑换券挂family_id)"
+```
+
+---
+
+### 任务 12:管理端阶梯配置接口
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java`
+
+- [ ] **步骤 1:创建 `AdminCfRateTierController`**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/cf-rate-tier")
+public class AdminCfRateTierController {
+
+    @Resource
+    private CfRateTierMapper cfRateTierMapper;
+
+    @PostMapping("/list")
+    public Result<List<CfRateTier>> list() {
+        return Result.success(cfRateTierMapper.selectList(
+                new LambdaQueryWrapper<CfRateTier>().orderByAsc(CfRateTier::getSortOrder)));
+    }
+
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody CfRateTier tier) {
+        if (tier.getId() != null) {
+            tier.setUpdatedAt(new Date());
+            cfRateTierMapper.updateById(tier);
+        } else {
+            tier.setCreatedAt(new Date());
+            tier.setUpdatedAt(new Date());
+            if (tier.getEnabled() == null) tier.setEnabled(1);
+            cfRateTierMapper.insert(tier);
+        }
+        return Result.success("已保存");
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = ((Number) params.get("id")).longValue();
+        cfRateTierMapper.deleteById(id);
+        return Result.success("已删除");
+    }
+}
+```
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java
+git commit -m "feat(cf): 管理端 CF 返佣阶梯配置接口(list/save/delete)"
+```
+
+---
+
+### 任务 13:前端改造(小程序)
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+- 修改:`cfc-frontend/pages/promotion/team.vue`
+- 修改:`cfc-frontend/pages/promotion/commission.vue`
+- 修改:`cfc-frontend/pages/promotion/index.vue`
+
+- [ ] **步骤 1:`utils/api.js` 新增接口封装**
+
+```js
+// CF 分佣
+export const getCfRate = () => request('/api/commission/cf/rate', 'POST')
+export const getCfSummary = () => request('/api/commission/cf/summary', 'POST')
+export const getCfList = (page, size) => request('/api/commission/cf/list', 'POST', { page, size })
+export const getFamilyCoupons = (page, size) => request('/api/coupon/family/list', 'POST', { page, size })
+// CF 转让
+export const sendCfTransfer = (toUserId, amount) => request('/api/cf/transfer/send', 'POST', { toUserId, amount })
+export const getCfTransferList = (page, size) => request('/api/cf/transfer/list', 'POST', { page, size })
+```
+
+- [ ] **步骤 2:`pages/promotion/team.vue` 改造**
+
+将 stats-card 改为展示 `getCfRate()` 返回的 `totalTeamSize` / `ratePercent` / `tierName`(替换原 L1/L2/团队佣金),模板与 methods 同步替换。删除 `getCommissionTeam` 依赖(或保留兼容)。
+
+- [ ] **步骤 3:`pages/promotion/commission.vue` 改造**
+
+数据源从旧 `/api/commission/list` 切换为 `getCfList()`,展示 CF 流水(金额/类型/时间),注意时间用 `parseDate()` 格式化。
+
+- [ ] **步骤 4:`pages/promotion/index.vue` 改造**
+
+头部汇总改用 `getCfSummary()`(available/frozen/totalEarned),保留推广二维码/邀请等原有功能。
+
+- [ ] **步骤 5:前端语法校验**
+
+运行:`node -e "require('@babel/parser')"` 不可用则用 `node --check` 提取 script 块逐文件校验。小程序由 HBuilderX 打包(Agent 不执行 build)。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/promotion/team.vue cfc-frontend/pages/promotion/commission.vue cfc-frontend/pages/promotion/index.vue
+git commit -m "feat(cf): 小程序推广中心切换到 CF 分佣接口(团队规模/比例/流水/钱包)"
+```
+
+---
+
+### 任务 14:测试与收尾
+
+**文件:**
+- 创建:`cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java`(可选,若测试基建可用)
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+
+- [ ] **步骤 1:单元测试(阶梯匹配边界)**
+
+若后端测试基建可用,创建测试:
+
+```java
+// 验证 matchRateTier 边界:0/2/3/9/10/29/30/99/100
+// 0 → 铜牌5;3 → 银牌10;10 → 金牌15;30 → 铂金20;100 → 钻石25
+```
+
+- [ ] **步骤 2:全量编译 + 测试**
+
+运行:`cd cfc-backend && mvn clean compile && mvn test`
+预期:BUILD SUCCESS;测试通过(或记录预存失败)
+
+- [ ] **步骤 3:更新 API_REFERENCE.md**
+
+在 `docs/superpowers/api/API_REFERENCE.md` 新增章节记录:`/api/commission/cf/*`(rate/summary/list)、`/api/cf/transfer/*`(send/list)、`/api/coupon/family/list`、`/api/admin/cf-rate-tier/*`(list/save/delete)。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java docs/superpowers/api/API_REFERENCE.md
+git commit -m "docs(cf): API 文档补充 CF 分佣/转让/阶梯配置接口 + 单元测试"
+```
+
+---
+
+## 自检记录
+
+**1. 规格覆盖度:**
+- 数据模型(cf_rate_tier / cf_transfer_record / 复用 user_platform_balance / coupon+family_id)→ 任务 1、2、11 ✓
+- 统一分佣服务 + 双返/只返推荐人 + 同家庭上溯 + 阶梯比例 → 任务 5 ✓
+- 推荐树物化 + 团队规模全层级 → 任务 4、6 ✓
+- 等级评估链路修复(updateTeamSize 调用 + refreshRate + 定时任务)→ 任务 6 ✓
+- 订单迁移点 7 处 → 任务 7 ✓
+- 旧佣金废弃 → 任务 8 ✓
+- 用户查询(团队规模/比例/流水)→ 任务 9 ✓
+- CF 转让 → 任务 10 ✓
+- 家庭券库 → 任务 11 ✓
+- 管理端阶梯配置 → 任务 12 ✓
+- 前端 → 任务 13 ✓
+- 测试/文档 → 任务 14 ✓
+
+**2. 占位符扫描:** 无"待定/TODO";任务 5 的 `getEffectivePpoint` 已用真实签名 `(productId, categoryId)` 修正;任务 10 转让幂等键已改为唯一 `transferRefId`;任务 11 家庭券库已改为独立 `CfCouponController` 完整实现。
+
+**3. 类型一致性:** `CfRateTier`/`CfTransferRecord`/`CfReferralService`/`CfCommissionService` 名称在后续任务中一致;`refreshRate` 在任务 6 定义、任务 6 定时任务引用一致;`updateTeamSize(userId, delta)` 新签名在任务 6 统一。
+
+---
+
+## 执行交接
+
+计划已完成并保存到 `docs/superpowers/plans/2026-09-01-cf-commission-redesign.md`。两种执行方式:
+
+**1. 子代理驱动(推荐)** — 每个任务调度一个新的子代理,任务间进行审查,快速迭代
+
+**2. 内联执行** — 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点

+ 756 - 0
docs/superpowers/plans/2026-09-01-diet-recipe-fix.md

@@ -0,0 +1,756 @@
+# 饮食页面「换一批」&「生成食谱」端到端修复 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让饮食首页「🔄 换一批」与「✨ 生成食谱」、食谱推荐页「生成食谱/重新生成」真实可用:后端真正使用用户所选食材与共餐参与者生成 AI 菜单,前端正确解析 Result 结构与 LangGraph 菜单数据契约,并修复「添加食材」弹窗搜索空壳与按钮禁用态。
+
+**Architecture:** 后端(`DietRecommendationService` / `DietIngredientService` / `BeijingNutritionService` / `DietIngredientController`)补齐真实逻辑:解析 `selected_foods`、查询 `meal_configs` 参与者、给 `generateIngredientList` 传 seed 实现换一批、把 LangGraph 返回的菜单 JSON 归一化为前端期望结构并聚合营养汇总、新增食材搜索接口。前端(`index.vue` / `recommendation.vue`)修复 `res.data.*` 取值、实现弹窗搜索、同步 `selectedIngredients`。
+
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue 2(Options API)微信小程序 / FastAPI LangGraph(仅消费,不改)
+
+**Spec:**
+- `cfc-backend/docs/superpowers/specs/2026-08-06-diet-module-design.md`(数据模型/接口/流程契约)
+- `cfc-frontend/docs/superpowers/specs/2026-08-28-diet-redesign.md`(仅样式,本次不改视觉)
+
+## Global Constraints
+
+- 后端接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- DI 用 `@Resource`,字段名与类型默认 Bean Name 一致
+- 响应统一 `Result<T>`(code/message/data)
+- 新增接口前查 `docs/superpowers/api/API_REFERENCE.md`,确认无重复,新增后同步记录
+- 后端验证唯一方式:`mvn clean compile`(在 `cfc-backend/` 下)
+- 前端验证:`node -e` 提取 `.vue` 的 `<script>` 块做语法校验(**禁止** `npm run build:mp-weixin`,打包由用户 HBuilderX 完成)
+- 前端禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid、禁止 `:key` 表达式、禁止中文类名、禁止 `new Date(string)`(用 `parseDate()`)、Vue 2 Options API
+- 前端 `request` 包装器 resolve 的是整个 `Result` 对象 `{code, message, data}`——所有业务字段在 `res.data.*` 下
+- LangGraph 菜单契约(后端已实现,不改 Python):`{meals:[{type, name, dishes:[{name, ingredients:[{name, grams}], cooking_method, nutrition:{calories,protein,carbs,fat}, notes}]}]}`
+- 前端 `parseMenu` 期望(保持不变,由后端归一化):`{meals:[{type, name, participants, dishes:[{name, calories, ingredients:[{name, amount}], method}]}]}`
+
+---
+
+## 文件结构
+
+| 文件 | 职责 | 变更类型 |
+|------|------|----------|
+| `cfc-backend/.../service/BeijingNutritionService.java` | `generateIngredientList` 加 seed 参数,换一批真正不同 | Modify |
+| `cfc-backend/.../service/DietIngredientService.java` | `refreshIngredients` 传 seed;新增 `searchFoods(keyword)` | Modify |
+| `cfc-backend/.../controller/diet/DietIngredientController.java` | 新增 `POST /api/diet/ingredients/search` | Modify |
+| `cfc-backend/.../service/DietRecommendationService.java` | `generateRecommendation` 解析食材/参与者/调 AI/归一化菜单/聚合营养 | Modify |
+| `cfc-frontend/pages/diet/index.vue` | `generateRecipe` 取值、`searchFood` 实现、`selectedIngredients` 同步、`addFood` | Modify |
+| `cfc-frontend/pages/diet/recommendation.vue` | `loadRecommendation`/`generateRecipe` 用 `res.data.*` | Modify |
+| `cfc-frontend/utils/api.js` | 新增 `searchDietFoods(keyword)` | Modify |
+| `docs/superpowers/api/API_REFERENCE.md` | 记录新增搜索接口 | Modify |
+
+> 后端两个 Service + 一个 Controller 相互独立可并行;前端两个页面相互独立可并行。同一文件多处改动时避免并发冲突,按任务 commit 边界操作。
+
+## 全局校验命令(每个任务提交前必跑)
+
+```bash
+# 1) 后端编译(在 cfc-backend 下)
+mvn clean compile
+
+# 2) 前端 .vue script 语法校验(在 cfc-frontend 下)
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync(process.argv[1],'utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+if(!m){console.log('no script');process.exit(0)}
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+" pages/diet/index.vue
+
+# 3) 前端禁止模式扫描(输出应为空)
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue pages/diet/recommendation.vue
+```
+
+---
+
+## Task 1: 后端「换一批」seed 生效 + 食材搜索接口
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java`(`generateIngredientList` 签名)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java`
+
+**Interfaces:**
+- Consumes: `IngredientRecommendation`(foodId/name/reason/score/category)、`FoodService.listAll` 或 `FoodMapper`
+- Produces:
+  - `List<IngredientRecommendation> generateIngredientList(Long familyId, LocalDate date, int seed)`(重载保持旧签名 `(familyId, date)` 兼容)
+  - `Map<String, Object> refreshIngredients(Long familyId)`(返回 `{ingredients, selectedCount}`)
+  - `Map<String, Object> searchFoods(String keyword)`(返回 `{foods:[{id,name,category}]}`)
+  - `POST /api/diet/ingredients/search` body `{keyword}`
+
+- [ ] **步骤 1:`generateIngredientList` 加 seed 参数并实现偏移截取**
+
+`BeijingNutritionService.java` 现有 `generateIngredientList(Long familyId, LocalDate date)`(第 561 行)。改造:
+
+```java
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
+    return generateIngredientList(familyId, date, 0);
+}
+
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
+    // 现有逻辑不变……(查 meal_configs → 参与者 → 禁忌 → 打分 → 排序)
+    // 仅将方法体末尾的截取逻辑替换为:
+    // 6. 排序后按 seed 偏移旋转再截取 Top 15(保证"换一批"返回不同组合)
+    result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
+    if (seed != 0 && result.size() > 1) {
+        int offset = Math.abs(seed) % result.size();
+        if (offset > 0) {
+            java.util.Collections.rotate(result, offset);
+        }
+    }
+    return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
+}
+```
+
+注意:`Collections.rotate(list, distance)` 会就地旋转;旋转后再取前 15,可让每次换一批取到不同起始位置的食材组合。
+
+- [ ] **步骤 2:`DietIngredientService.refreshIngredients` 传 seed**
+
+`DietIngredientService.java` 第 33-45 行,改为:
+
+```java
+public Map<String, Object> refreshIngredients(Long familyId) {
+    Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
+    refreshSeed.put(familyId, seed);
+
+    // 重新生成(通过 seed 偏移截取,真正换一批)
+    List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
+    selectedIngredients.put(familyId, suggestions);
+
+    Map<String, Object> result = new HashMap<>();
+    result.put("ingredients", suggestions);
+    result.put("selectedCount", 0);
+    return result;
+}
+```
+
+(`suggestIngredients` 保持调用无参重载 `generateIngredientList(familyId, LocalDate.now())` 即可。)
+
+- [ ] **步骤 3:`DietIngredientService` 新增 `searchFoods`**
+
+在 `DietIngredientService.java` 注入 `FoodMapper`(或复用现有 service)并新增方法。需要先确认注入方式——若 `BeijingNutritionService` 已有 `foodMapper`,本类新增注入:
+
+```java
+@Resource
+private com.etotem.cfc.mapper.FoodMapper foodMapper;
+
+public Map<String, Object> searchFoods(String keyword) {
+    List<Map<String, Object>> foods = new ArrayList<>();
+    List<com.etotem.cfc.entity.Food> all = foodMapper.selectList(
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.Food>()
+                    .eq(com.etotem.cfc.entity.Food::getStatus, "active")
+                    .orderByAsc(com.etotem.cfc.entity.Food::getSortOrder)
+    );
+    String kw = keyword == null ? "" : keyword.trim();
+    int count = 0;
+    for (com.etotem.cfc.entity.Food f : all) {
+        if (kw.isEmpty() || (f.getName() != null && f.getName().contains(kw))) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", f.getId());
+            item.put("name", f.getName());
+            item.put("category", f.getCategory());
+            foods.add(item);
+            if (++count >= 50) break; // 限制 50 条
+        }
+    }
+    Map<String, Object> result = new HashMap<>();
+    result.put("foods", foods);
+    return result;
+}
+```
+
+> 若 `Food` 实体已有 `status`/`sortOrder` 字段(已确认存在),无需迁移。全量查询仅 50 条上限,内存过滤,符合"食材库不大"的现状。
+
+- [ ] **步骤 4:`DietIngredientController` 新增搜索接口**
+
+```java
+@PostMapping("/search")
+public Result<Map> searchFoods(@RequestBody(required = false) Map<String, Object> request) {
+    String keyword = request != null && request.get("keyword") != null
+            ? request.get("keyword").toString() : "";
+    return Result.success(dietIngredientService.searchFoods(keyword));
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+Run: `mvn clean compile`(`cfc-backend/` 下)
+Expected: BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java
+git commit -m "feat(diet): 换一批seed生效 + 食材搜索接口 /api/diet/ingredients/search"
+```
+
+---
+
+## Task 2: 后端「生成食谱」真实实现
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java`
+
+**Interfaces:**
+- Consumes:
+  - `Map<String, String> request`(date / meal_type / selected_foods 为 `[{food_id, name}]` JSON 字符串)
+  - `BeijingNutritionService.generateIngredientList(familyId, date)`(降级食材源)
+  - `MealConfigMapper`(查共餐参与者,参考 `BeijingNutritionService` 第 564-603 行逻辑)
+  - `FamilyMemberMapper`(无共餐配置时降级为全部成员,参考第 590-603 行)
+  - `AiGateway.generateMenu(String selectedFoodsJson, String participantsJson, String date)` → `menu_json` 字符串(LangGraph 契约见 Global Constraints)
+  - `com.fasterxml.jackson.databind.ObjectMapper`
+- Produces: `Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request)` 返回 `{id, menu, nutritionSummary, message}`,且落库 `diet_recommendations`(menu_json 已归一化、nutrition_summary 已聚合、participant_member_ids 已写入)
+
+- [ ] **步骤 1:重写 `generateRecommendation` 核心逻辑**
+
+`DietRecommendationService.java` 第 56-92 行替换为(保持方法签名):
+
+```java
+public Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request) {
+    String dateStr = request.get("date");
+    String mealType = request.get("meal_type");
+    LocalDate date = LocalDate.parse(dateStr);
+
+    // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
+    List<Map<String, Object>> selectedFoods = new ArrayList<>();
+    String selectedFoodsJson = request.get("selected_foods");
+    if (selectedFoodsJson != null && !selectedFoodsJson.isEmpty()) {
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode arr = om.readTree(selectedFoodsJson);
+            if (arr.isArray()) {
+                for (com.fasterxml.jackson.databind.JsonNode node : arr) {
+                    Map<String, Object> item = new HashMap<>();
+                    item.put("food_id", node.has("food_id") ? node.get("food_id").asLong() : null);
+                    item.put("name", node.has("name") ? node.get("name").asText() : "");
+                    selectedFoods.add(item);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 selected_foods 失败: {}", e.getMessage());
+        }
+    }
+
+    // 2. 查询共餐参与者(参考 BeijingNutritionService.generateIngredientList 的 meal_configs 逻辑)
+    List<Long> participantIds = queryParticipantIds(familyId, date);
+
+    // 3. 调用 AI 生成菜单(参与者数量至少 1)
+    String participantsJson = "[]";
+    if (!participantIds.isEmpty()) {
+        try {
+            participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
+        } catch (Exception ignore) {}
+    }
+    String menuJson = aiGateway.generateMenu(
+            selectedFoodsJson != null ? selectedFoodsJson : "[]",
+            participantsJson,
+            dateStr
+    );
+
+    // 4. AI 失败时降级:用规则推荐食材拼一个最小菜单,避免空壳
+    if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
+        menuJson = buildFallbackMenu(familyId, date, selectedFoods, participantIds.size());
+    }
+
+    // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
+    Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
+    String normalizedMenuJson = safeWrite(normalized);
+    Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
+
+    // 6. 保存推荐
+    DietRecommendation record = new DietRecommendation();
+    record.setFamilyId(familyId);
+    record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
+    record.setMealType(mealType != null ? mealType : "all");
+    record.setParticipantMemberIds(participantIds.toString());
+    record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
+    record.setNutritionSummary(safeWrite(nutritionSummary));
+    record.setStatus("pending");
+    record.setVersion(1);
+    record.setCreatedAt(new Date());
+    record.setUpdatedAt(new Date());
+    dietRecommendationMapper.insert(record);
+
+    Map<String, Object> result = new HashMap<>();
+    result.put("id", record.getId());
+    result.put("menu", record.getMenuJson());
+    result.put("nutritionSummary", record.getNutritionSummary());
+    result.put("message", "食谱推荐已生成");
+    return result;
+}
+```
+
+- [ ] **步骤 2:新增私有辅助方法 `queryParticipantIds` / `normalizeMenu` / `aggregateNutrition` / `buildFallbackMenu` / `safeWrite`**
+
+在 `DietRecommendationService.java` 类内新增(需要 `@Resource MealConfigMapper mealConfigMapper; @Resource FamilyMemberMapper familyMemberMapper;`,Bean Name 冲突检查:`DietRecommendationService` 内注入与 `BeijingNutritionService` 相同 mapper 类不冲突,因为是不同实例注入):
+
+```java
+private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
+    boolean weekend = date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY
+            || date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+    String dateType = weekend ? "weekend" : "weekday";
+    java.util.Set<Long> ids = new java.util.HashSet<>();
+    for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.MealConfig> w =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        w.eq(com.etotem.cfc.entity.MealConfig::getFamilyId, familyId)
+         .eq(com.etotem.cfc.entity.MealConfig::getConfigDateType, dateType)
+         .eq(com.etotem.cfc.entity.MealConfig::getMealType, mealType);
+        com.etotem.cfc.entity.MealConfig config = mealConfigMapper.selectOne(w);
+        if (config != null && config.getParticipantMemberIds() != null) {
+            try {
+                com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper()
+                        .readTree(config.getParticipantMemberIds());
+                if (nodes.isArray()) {
+                    for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
+                }
+            } catch (Exception ignore) {}
+        }
+    }
+    if (ids.isEmpty()) {
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.FamilyMember> w =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        w.eq(com.etotem.cfc.entity.FamilyMember::getFamilyId, familyId);
+        List<com.etotem.cfc.entity.FamilyMember> members = familyMemberMapper.selectList(w);
+        for (com.etotem.cfc.entity.FamilyMember m : members) ids.add(m.getId());
+    }
+    return new ArrayList<>(ids);
+}
+
+private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
+    // LangGraph: {meals:[{type,name,dishes:[{name,ingredients:[{name,grams}],cooking_method,nutrition:{calories,...},notes}]}]}
+    // 前端期望: {meals:[{type,name,participants,dishes:[{name,calories,ingredients:[{name,amount}],method}]}]}
+    Map<String, Object> result = new LinkedHashMap<>();
+    List<Map<String, Object>> meals = new ArrayList<>();
+    result.put("meals", meals);
+    if (menuJson == null || menuJson.trim().isEmpty()) return result;
+    try {
+        com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+        com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
+        com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
+        if (mealsNode != null && mealsNode.isArray()) {
+            for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
+                Map<String, Object> meal = new LinkedHashMap<>();
+                meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
+                meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
+                meal.put("participants", participantCount > 0 ? participantCount : 1);
+                List<Map<String, Object>> dishes = new ArrayList<>();
+                com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
+                if (dishesNode != null && dishesNode.isArray()) {
+                    for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
+                        Map<String, Object> dish = new LinkedHashMap<>();
+                        dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
+                        com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
+                        if (nutrition != null) {
+                            dish.put("calories", nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
+                        } else {
+                            dish.put("calories", 0);
+                        }
+                        List<Map<String, Object>> ingredients = new ArrayList<>();
+                        com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
+                        if (ingNode != null && ingNode.isArray()) {
+                            for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
+                                Map<String, Object> item = new LinkedHashMap<>();
+                                item.put("name", ing.has("name") ? ing.get("name").asText() : "");
+                                item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
+                                ingredients.add(item);
+                            }
+                        }
+                        dish.put("ingredients", ingredients);
+                        dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
+                        dishes.add(dish);
+                    }
+                }
+                meal.put("dishes", dishes);
+                meals.add(meal);
+            }
+        }
+    } catch (Exception e) {
+        log.warn("归一化菜单失败: {}", e.getMessage());
+    }
+    return result;
+}
+
+private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
+    Map<String, Object> summary = new LinkedHashMap<>();
+    int calories = 0, protein = 0, carbs = 0, fat = 0;
+    @SuppressWarnings("unchecked")
+    List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
+    if (meals != null) {
+        for (Map<String, Object> meal : meals) {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
+            if (dishes == null) continue;
+            for (Map<String, Object> dish : dishes) {
+                Object cal = dish.get("calories");
+                if (cal instanceof Number) calories += ((Number) cal).intValue();
+            }
+        }
+    }
+    summary.put("calories", calories);
+    summary.put("protein", protein);
+    summary.put("carbs", carbs);
+    summary.put("fat", fat);
+    return summary;
+}
+
+private String buildFallbackMenu(Long familyId, LocalDate date, List<Map<String, Object>> selectedFoods, int participantCount) {
+    // AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单
+    List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
+            beijingNutritionService.generateIngredientList(familyId, date);
+    String[] mealDefs = {"breakfast", "lunch", "dinner"};
+    String[] mealNames = {"早餐", "午餐", "晚餐"};
+    Map<String, Object> root = new LinkedHashMap<>();
+    List<Map<String, Object>> meals = new ArrayList<>();
+    for (int i = 0; i < mealDefs.length; i++) {
+        Map<String, Object> meal = new LinkedHashMap<>();
+        meal.put("type", mealDefs[i]);
+        meal.put("name", mealNames[i]);
+        meal.put("participants", participantCount > 0 ? participantCount : 1);
+        List<Map<String, Object>> dishes = new ArrayList<>();
+        Map<String, Object> dish = new LinkedHashMap<>();
+        dish.put("name", "营养餐");
+        dish.put("calories", 0);
+        List<Map<String, Object>> dishIngredients = new ArrayList<>();
+        for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", ingredients.get(j).getName());
+            item.put("amount", 100);
+            dishIngredients.add(item);
+        }
+        dish.put("ingredients", dishIngredients);
+        dish.put("method", "建议以清淡为主");
+        dishes.add(dish);
+        meal.put("dishes", dishes);
+        meals.add(meal);
+    }
+    root.put("meals", meals);
+    return safeWrite(root);
+}
+
+private String safeWrite(Object obj) {
+    try {
+        return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+    } catch (Exception e) {
+        return "{}";
+    }
+}
+```
+
+> 说明:`aggregateNutrition` 目前只聚合 calories(LangGraph 未返回 protein/carbs/fat 结构一致字段时置 0);若 `nutrition` 含 protein/carbs/fat 字段可在后续补充,本期保证 calories 与前端 4 宫格可正常渲染(其余为 0)。
+
+- [ ] **步骤 3:新增 mapper 注入**
+
+在 `DietRecommendationService` 类顶部 `@Resource` 区新增:
+
+```java
+@Resource
+private com.etotem.cfc.mapper.MealConfigMapper mealConfigMapper;
+
+@Resource
+private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+```
+
+> Bean Name 冲突检查:`MealConfigMapper` / `FamilyMemberMapper` 在多个 Service 中已注入(如 `BeijingNutritionService`、`DietMealConfigService`),Spring 单例按类型注入,无命名冲突。
+
+- [ ] **步骤 4:编译验证**
+
+Run: `mvn clean compile`(`cfc-backend/` 下)
+Expected: BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
+git commit -m "feat(diet): 生成食谱使用所选食材+共餐参与者,归一化LangGraph菜单并聚合营养"
+```
+
+---
+
+## Task 3: 前端「生成食谱」+「换一批」+ 弹窗搜索(index.vue + api.js)
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`(新增 `searchDietFoods`)
+- Modify: `cfc-frontend/pages/diet/index.vue`
+
+**Interfaces:**
+- Consumes: `searchDietFoods(keyword)` → `POST /api/diet/ingredients/search` 返回 `{foods:[{id,name,category}]}`;`generateDietRecommendation` 返回 `{code,message,data:{id,...}}`
+- Produces: 修复后的 `index.vue`(generateRecipe 成功跳转、弹窗可搜索、按钮禁用态正确)
+
+- [ ] **步骤 1:`api.js` 新增搜索接口封装**
+
+在 `api.js` 现有 `getDietIngredients` 附近(第 2493-2494 行)新增:
+
+```js
+export const searchDietFoods = (keyword) => request('/api/diet/ingredients/search', 'POST', { keyword })
+```
+
+- [ ] **步骤 2:`index.vue` `generateRecipe` 取值修复**
+
+第 271-286 行,`if (res && res.id)` 改为:
+
+```js
+generateRecipe: function() {
+  var self = this
+  var foodIds = this.ingredients.map(function(item) { return { food_id: item.foodId, name: item.name } })
+  generateDietRecommendation({
+    date: self.formatDate(new Date()),
+    meal_type: 'all',
+    selected_foods: JSON.stringify(foodIds)
+  }).then(function(res) {
+    if (res && res.data && res.data.id) {
+      uni.showToast({ title: '食谱生成成功', icon: 'success' })
+      setTimeout(function() {
+        self.navTo('/pages/diet/recommendation')
+      }, 1000)
+    } else {
+      uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
+    }
+  })
+}
+```
+
+- [ ] **步骤 3:`index.vue` `selectedIngredients` 同步**
+
+`loadIngredients`(第 219-244 行)和 `refreshIngredients`(第 245-256 行)成功分支里,在 `self.ingredients = res.data.ingredients` 后追加:
+
+```js
+self.selectedIngredients = self.ingredients
+```
+
+同时 `removeIngredient` 成功回调里 `self.loadIngredients()` 已会重新同步;`addFood` 成功回调 `self.loadIngredients()` 同理。
+
+- [ ] **步骤 4:`index.vue` `searchFood` 实现 + `addFood` 修复**
+
+第 332-345 行替换为:
+
+```js
+searchFood: function() {
+  var self = this
+  var kw = (this.searchQuery || '').trim()
+  if (!kw) {
+    this.searchResults = []
+    return
+  }
+  searchDietFoods(kw).then(function(res) {
+    if (res && res.code === 200 && res.data && res.data.foods) {
+      self.searchResults = res.data.foods
+    }
+  })
+},
+addFood: function(food) {
+  var self = this
+  addDietIngredient({ food_id: food.id, name: food.name }).then(function(res) {
+    self.closePicker()
+    self.loadIngredients()
+  })
+}
+```
+
+> `addFood` 现有逻辑已正确(用 `food.id`),若未改动则保持。弹窗模板 `@tap="addFood(food)"` 已存在,`food.id`/`food.name` 与搜索返回字段一致,无需改模板。
+
+- [ ] **步骤 5:`index.vue` import 增加 `searchDietFoods`**
+
+第 161 行 import 行追加 `searchDietFoods`:
+
+```js
+import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
+```
+
+- [ ] **步骤 6:前端校验**
+
+Run(`cfc-frontend/` 下):
+```bash
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync('pages/diet/index.vue','utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+"
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue
+```
+Expected: `script OK` + grep 无输出
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/diet/index.vue
+git commit -m "feat(diet): 首页生成食谱取值修复+弹窗搜索实现+selectedIngredients同步"
+```
+
+---
+
+## Task 4: 前端「食谱推荐页」取值修复(recommendation.vue)
+
+**Files:**
+- Modify: `cfc-frontend/pages/diet/recommendation.vue`
+
+**Interfaces:**
+- Consumes: `getDietRecommendation` / `generateDietRecommendation` 返回 `{code,message,data:{id,menu,nutritionSummary,status}}`;后端已归一化的菜单结构
+- Produces: 修复后的 `recommendation.vue`(能加载今日推荐、空态生成可跳转、营养汇总/状态徽章正常)
+
+- [ ] **步骤 1:`loadRecommendation` 用 `res.data.*`**
+
+第 137-147 行替换为:
+
+```js
+loadRecommendation: function() {
+  var self = this
+  var today = self.formatDate(new Date())
+  getDietRecommendation({ date: today }).then(function(res) {
+    var d = res && res.data
+    if (d && d.id) {
+      self.recommendation = d
+      self.parseMenu(d.menu)
+      self.summary = d.nutritionSummary ? (typeof d.nutritionSummary === 'string' ? JSON.parse(d.nutritionSummary) : d.nutritionSummary) : {}
+    } else {
+      self.recommendation = null
+      self.meals = []
+      self.summary = {}
+    }
+  })
+}
+```
+
+- [ ] **步骤 2:`generateRecipe` 用 `res.data.id` + 传 `selected_foods`(若有)**
+
+第 175-191 行替换为:
+
+```js
+generateRecipe: function() {
+  var self = this
+  self.loading = true
+  generateDietRecommendation({
+    date: self.formatDate(new Date()),
+    meal_type: 'all'
+  }).then(function(res) {
+    self.loading = false
+    if (res && res.data && res.data.id) {
+      uni.showToast({ title: '食谱生成成功', icon: 'success' })
+      self.loadRecommendation()
+    } else {
+      uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
+    }
+  }).catch(function() {
+    self.loading = false
+    uni.showToast({ title: '生成失败', icon: 'none' })
+  })
+}
+```
+
+- [ ] **步骤 3:`parseMenu` 兼容后端归一化结构(含旧结构兜底)**
+
+第 148-174 行,`parseMenu` 内 `ing.amount` 已与后端归一化输出一致;为兼容后端可能存的旧数据,在 ingredient 解析处加兜底:
+
+```js
+parseMenu: function(menuJson) {
+  try {
+    var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
+    this.meals = []
+    var mealMap = {
+      'breakfast': { name: '早餐', icon: '🌅' },
+      'lunch': { name: '午餐', icon: '☀️' },
+      'dinner': { name: '晚餐', icon: '🌙' }
+    }
+    if (menu.meals) {
+      var self = this
+      menu.meals.forEach(function(meal) {
+        var config = mealMap[meal.type] || { name: meal.name || meal.type, icon: '🍽️' }
+        var dishes = (meal.dishes || []).map(function(dish) {
+          var ing = (dish.ingredients || []).map(function(item) {
+            return { name: item.name, amount: item.amount || item.grams || 0 }
+          })
+          return {
+            name: dish.name,
+            calories: dish.calories || (dish.nutrition && dish.nutrition.calories) || 0,
+            ingredients: ing,
+            method: dish.method || dish.cooking_method || ''
+          }
+        })
+        self.meals.push({
+          name: config.name,
+          icon: config.icon,
+          type: meal.type,
+          participants: meal.participants || 1,
+          dishes: dishes
+        })
+      })
+    }
+  } catch (e) {
+    console.error('解析菜单失败', e)
+  }
+}
+```
+
+> 说明:模板中 `dish.calories`/`ing.amount`/`dish.method` 字段名保持不变(后端归一化已对齐),此步骤仅加旧结构兜底 + 支持 `nutritionSummary` 为 JSON 字符串的情况。
+
+- [ ] **步骤 4:前端校验**
+
+Run(`cfc-frontend/` 下):
+```bash
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync('pages/diet/recommendation.vue','utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+"
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/recommendation.vue
+```
+Expected: `script OK` + grep 无输出
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages/diet/recommendation.vue
+git commit -m "fix(diet): 食谱推荐页 res.data 取值修复+菜单解析兼容"
+```
+
+---
+
+## Task 5: API 文档同步 + 全量复核
+
+**Files:**
+- Modify: `docs/superpowers/api/API_REFERENCE.md`
+
+- [ ] **步骤 1:API_REFERENCE 新增搜索接口记录**
+
+在 `docs/superpowers/api/API_REFERENCE.md` 第 353 行附近的 `### 4.11 饮食推荐(/api/diet/*)` 表格中追加:
+
+```markdown
+| `POST /api/diet/ingredients/search` | 搜索食材(keyword) |
+```
+
+- [ ] **步骤 2:全量编译 + 前端校验**
+
+Run:
+```bash
+cd cfc-backend && mvn clean compile
+cd cfc-frontend && node -e "..." pages/diet/index.vue pages/diet/recommendation.vue
+```
+Expected: BUILD SUCCESS + script OK × 2 + grep 无输出
+
+- [ ] **步骤 3:git 复核 staged 范围**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git diff --cached --stat
+```
+Expected: 仅包含目标文件
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git commit -m "docs(diet): API_REFERENCE 记录 /api/diet/ingredients/search"
+```
+
+---
+
+## 自检记录
+
+- [ ] **规格覆盖度**:spec §4.2(食材推荐流程:换一批/添加/生成)→ Task 1+3;§4.2 confirm→generate(食材确认+生成)→ Task 2;§6.1 接口 → Task 1+2;§7.3 首页布局按钮 → Task 3+4
+- [ ] **占位符扫描**:无 TODO/待定;每个代码步骤含实际代码
+- [ ] **类型一致性**:`res.data.id` / `res.data.menu` / `res.data.nutritionSummary` 前后端一致;后端 `normalizeMenu` 输出 `dish.calories/ingredients[].amount/method` 与前端 `parseMenu` 期望一致
+- [ ] **LangGraph 不改**:Python `/api/v1/menu/generate` 已实现,仅 Java 端消费其输出

+ 252 - 0
docs/superpowers/specs/2026-08-31-coupon-family-based-design.md

@@ -0,0 +1,252 @@
+# 优惠券全链路改为家庭维度 — 设计文档
+
+**文档版本:** v1.0
+**日期:** 2026-08-31
+**状态:** 待确认(Design Review)
+**决策:** 用户确认 ① 两张入口都改为家庭(CouponManagement + Families.vue) ② 先写设计文档 + 计划
+
+---
+
+## 一、背景与审计发现
+
+### 1.1 业务诉求
+
+用户要求"优惠券只发放给家庭"("不放在用户身上,应该在家庭管理中实现")。本次变更把优惠券从**个人维度**重构为**家庭维度**,覆盖全部发放和消费链路。
+
+### 1.2 当前系统审计结果
+
+| 类别 | 位置 | 现状 |
+|------|------|------|
+| **模板** | `coupon` 表 | 复用,不动 |
+| **发放载体** | `user_coupon` 表 | per-user:`user_id, coupon_id, status(AVAILABLE/USED), received_at, used_at, order_id` |
+| **发放流水** | `coupon_grant_log` 表 | per-user:`user_id, coupon_id, grant_type(JOIN/PERIODIC/POPULATION/EXCHANGE), period, quantity, source`,唯一键 `uk_grant(user_id, coupon_id, grant_type, period)` |
+| **发放路径** | 6 条 | 见下表 |
+| **消费接口** | `CouponController` | `/api/coupon/list`, `/api/coupon/my`, `/api/coupon/apply`, `/api/coupon/claim`, `/api/coupon/checkout-list` |
+| **订单绑定** | `PackageOrder.userCouponId`, `PaymentOrder.userCouponId` | 存 wallet record id |
+
+#### 六条发放路径(当前都发到用户)
+
+| # | 触发场景 | 调用方 | 目标 |
+|---|----------|--------|------|
+| 1 | 后台批量发放 | `AdminCouponController.issue` | `List<Long> userIds` |
+| 2 | 开通/续费会员赠券 | `MembershipService.grantJoinCoupons` | family 管理员 userId |
+| 3 | 新成员加入家庭人口券 | `FamilyMemberService.grantPopulationCoupons` | 成员 userId / 家庭创建者 |
+| 4 | 周期补发(每天 5:00) | `CouponGrantTask.grantPeriodicCoupons` | 有效会员 userId 列表 |
+| 5 | 积分兑换 | `PointsExchangeService.exchangeCoupon` | 操作用户 userId |
+| 6 | CF 值兑换 | `FamilyPlatformPointsService.exchangeCouponByCf` | 操作用户 refUserId(已有 familyId) |
+
+### 1.3 改造影响面
+
+- **后端(Java)**:`CouponService` 核心、6 个发放调用方、`CouponController`、`AdminCouponController`
+- **前端管理端(cfc-web)**:`Families.vue`(新增按钮)、`CouponManagement.vue`(批量发放改为家庭 ID)
+- **小程序前端(cfc-frontend)**:**零改动**(API 路径与响应字段不变,内部改为读 `family_coupon`)
+
+---
+
+## 二、目标架构
+
+### 2.1 新数据表
+
+```sql
+-- family_coupon:家庭维度的优惠券实例表
+CREATE TABLE IF NOT EXISTS family_coupon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME,
+    order_id BIGINT COMMENT '核销时写入的订单号',
+    INDEX idx_family_coupon (family_id, coupon_id),
+    INDEX idx_status (status),
+    INDEX idx_order_id (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
+
+-- family_coupon_grant_log:家庭维度发券流水(防重 + 审计)
+CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE(与coupon_grant_log枚举对齐,新增CF_EXCHANGE)',
+    period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用',
+    quantity INT DEFAULT 1 COMMENT '发放数量',
+    source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period),
+    INDEX idx_coupon (coupon_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
+```
+
+> `user_coupon` 与 `coupon_grant_log` **保留**,不删除、不迁移历史数据。新发放一律写 `family_coupon`/`family_coupon_grant_log`。
+
+### 2.2 关键设计决策
+
+| 决策 | 方案 | 理由 |
+|------|------|------|
+| **API 路径与响应字段是否变化?** | 不变 | 小程序已上线(coupons.vue / checkout.vue / pay.vue / wealth-cf),不能破坏 |
+| **家庭券的 id 如何用于订单?** | 写入 `PackageOrder.userCouponId` / `PaymentOrder.userCouponId` | 保持字段名不动,语义上存储的是 `family_coupon.id`(wallet record id) |
+| **admin 订单详情页如何展示家庭券?** | 暂不改动显示逻辑;字段名为 userCouponId,查 `family_coupon` 表 | 最小改动原则 |
+| **历史 user_coupon 如何处理?** | 保留,不再写入也不读取(旧家庭券静默过期) | 避免大规模数据迁移风险 |
+
+---
+
+## 三、服务层改动(CouponService)
+
+### 3.1 新增 Mapper / Entity
+
+- `FamilyCoupon` 实体(`@TableName("family_coupon")`)
+- `FamilyCouponGrantLog` 实体(`@TableName("family_coupon_grant_log")`)
+- `FamilyCouponMapper`、`FamilyCouponGrantLogMapper`(均继承 `BaseMapper`)
+
+### 3.2 方法签名变更
+
+**保留向后兼容的方法(签名不变,内部替换实现):**
+
+| 旧签名 | 新签名 | 说明 |
+|--------|--------|------|
+| `listAvailable(Long userId)` | `listAvailable(Long userId)` | 内部:`userMapper.selectById(userId).getFamilyId()` → 查 family_coupon |
+| `listMyCoupons(Long userId)` | `listMyCoupons(Long userId)` | 同上 |
+| `apply(Long userId, Long userCouponId, String orderType, Integer orderAmount)` | **不变** | 内部:userId→familyId,校验 family_coupon.family_id == familyId |
+| `claim(Long userId, Long couponId)` | **不变** | 内部:发给家庭,插入 family_coupon |
+| `markUsed(Long userCouponId, Long orderId)` | **不变** | 内部:操作 family_coupon 行 |
+| `revert(Long userCouponId)` | **不变** | 同上 |
+| `listUnownedExchangeable(Long userId, Long productId)` | **不变** | 内部:familyId |
+
+**新增/改名的私用方法:**
+
+| 新方法 | 说明 |
+|--------|------|
+| `issueToFamily(Long couponId, Long familyId)` | 单条发放到家庭(替代 issueToUser) |
+| `grantFamily(familyId, couponId, grantType, period, source)` | 通用家庭发放(含去重 + 落流水) |
+| `grantFamilyJoinCoupons(familyId, levelCode, source)` | JOIN 赠券(替换原 grantJoinCoupons(userId, ...)) |
+| `grantFamilyPopulationCoupons(familyId, memberId)` | 家庭人口券(替换原 grantPopulationCoupons(userId, ...)) |
+| `listActiveMemberFamilyIds(String levelCode)` | 查指定等级的活跃家庭 ID 列表(供 CouponGrantTask) |
+
+### 3.3 方法实现要点
+
+- `listAvailable(userId)`:通过 User 实体拿 familyId,查 `family_coupon` where family_id=familyId and status=AVAILABLE,join coupon 模板过滤有效期
+- `listMyCoupons(userId)`:同理,返回包含 `userCouponId`(= family_coupon.id)+ 模板字段的 Map 列表,**与现有 coupons.vue 兼容**
+- `apply(userId, userCouponId, ...)`:familyId 校验必须通过;否则返回 null(不可用)
+- `grantFamilyJoinCoupons(familyId, levelCode, source)`:取模板 grantType=JOIN, grantLevelCode=levelCode 的券 → 逐张调用 `grantFamily`
+- `grantFamilyPopulationCoupons(familyId, memberId)`:取模板 grantType=POPULATION → 逐张 `grantFamily(familyId, ...)`
+- `listActiveMemberFamilyIds(levelCode)`:等价于 `MembershipService.listActiveMemberUserIds`,但返回 `Set<Long> familyIds`
+
+---
+
+## 四、发放路径改写(6 条)
+
+| # | 场景 | 改写点 |
+|---|------|--------|
+| 1 | Admin 发放 | `AdminCouponController.issue` 改为支持 `familyIds`(同时保留旧 `userIds` 参数兼容,但返回警告日志) |
+| 2 | JOIN 会员赠券 | `MembershipService` 中 `grantJoinCoupons(adminUserId, ...)` → `grantFamilyJoinCoupons(order.getFamilyId(), levelCode, order.getOrderNo())` |
+| 3 | 新成员人口券 | `FamilyMemberService` 中 `grantPopulationCoupons(targetUserId, memberId)` → `grantFamilyPopulationCoupons(familyId, member.getId())` |
+| 4 | PERIODIC 周期补发 | `CouponGrantTask` 改为调用 `listActiveMemberFamilyIds` + `grantFamily` |
+| 5 | 积分兑换 | `PointsExchangeService.exchangeCoupon(userId, childId, couponId)` → 取 `familyMemberMapper.selectOne(FamilyMember.userId=userId).getFamilyId()` 得 `familyId`,调 `grantFamily(familyId, ...)` |
+| 6 | CF 值兑换 | `FamilyPlatformPointsService.exchangeCouponByCf(familyId, couponId, refUserId)` → 直接 `grantFamily(familyId, ...)` |
+
+---
+
+## 五、控制器层改动
+
+### 5.1 `CouponController`(小程序端)
+
+- 不修改路由或响应字段
+- 内部所有 `userId` → 解析 `userMapper.selectById(userId).getFamilyId()`
+- `apply` 方法中增加 familyCouponId 归属校验:`familyCoupon.getFamilyId() == user.familyId`
+
+### 5.2 `AdminCouponController`(管理端)
+
+- `/issue`:body 改为 `{ couponId, familyIds: [Long] }`,逐条调用 `issueToFamily`
+- 新增 `/issue-family`:`(familyId, couponId, quantity)` 快捷单家庭发放(供 Families.vue 调用)
+- `/grant-log`:支持 `familyId` 查询,返回时 join family.name
+
+### 5.3 路由冲突检查
+
+- `/api/admin/coupon/issue-family`:经全量扫描 admin 下所有 `@PostMapping` 路径,无冲突(现有 `/issue` 与 `/issue-family` 不重叠)
+- 二次验证命令:`Get-ChildItem cfc-backend/src/main/java/com/etotem/cfc/controller/admin -Filter *.java | Select-String '@PostMapping'`
+
+---
+
+## 六、前端改动(cfc-web)
+
+### 6.1 `Families.vue`
+
+- 「更多」下拉新增 **「发放优惠券」** 按钮
+- 点击打开 `el-dialog`,表单包含:
+  - 优惠券下拉(调 `/api/admin/coupon/list`)
+  - 发放数量(默认 1)
+  - 确认按钮 → POST `/api/admin/coupon/issue-family`
+- 弹窗底部加「查看该家庭发放记录」链接 → 打开 `CouponGrantLog.vue` 并预填 `familyId`
+
+### 6.2 `CouponManagement.vue`
+
+- 批量发放弹窗:由"用户ID列表(每行一个)"改为"家庭ID列表(每行一个)"
+- 文案与 placeholder 同步调整
+
+### 6.3 `CouponGrantLog.vue`
+
+- 搜索框新增「家庭ID」输入项(与现有"用户ID"并存,两者可选其一)
+- 查询条件支持 `familyId`
+- 列表列"用户"改为"家庭"(显示家庭 name 或 ID)
+
+### 6.4 `api/coupon.js`
+
+- 新增 `issueFamilyCoupon(data)` → POST `/api/admin/coupon/issue-family`
+- 新增 `getFamilyCouponGrantLog(data)` → POST `/api/admin/coupon/grant-log`(支持 familyId 参数)
+
+---
+
+## 七、数据库迁移
+
+- **迁移编号**: 迁移 269
+- 入口:`DatabaseInitializer.runMigrations()`,参照 AGENTS.md 规范用 `ensureColumn` 或 `try-catch` 包裹
+- 同步 `schema.sql`
+
+迁移内容:
+```
+// 迁移269: 创建 family_coupon 表 + family_coupon_grant_log 表
+// (优惠券全链路改为家庭维度:2026-08-31)
+```
+
+---
+
+## 八、验证步骤
+
+1. **编译**:`mvn clean compile` — 必须通过
+2. **API 路径检查**:
+   ```bash
+   grep -rn '@PostMapping("' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
+   ```
+   确认无冲突
+3. **Bean 命名检查**:新增 `FamilyCouponMapper` / `FamilyCouponGrantLogMapper` 的 Bean name 不冲突
+4. **API 端点注册**(如适用):确认 `FamilyCouponController` 不在(本次不改小程序控制器的路由)
+5. **Admin 操作流**:在 cfc-web 家庭管理页测试发放 → 管理端查看记录
+
+---
+
+## 九、风险与降级
+
+| 风险 | 缓解 |
+|------|------|
+| 小程序用户现有优惠券消失 | `user_coupon` 历史数据保留,但前端 `/api/coupon/my` 改读 family_coupon → 旧券不可见。**影响面**:已使用的券不受影响;未使用的历史券会"消失"。建议:本功能上线前清理或迁移旧券(本设计暂不包含迁移,留作一期后续) |
+| `CouponGrantTask` 跑批前 family 尚未存在 | 迁移后首次运行即按家庭发券,行为一致 |
+| 旧订单(PackageOrder.userCouponId)指向 user_coupon 历史数据 | 订单详情中的优惠券展示逻辑不变(字段名不更名),仅新订单写入 family_coupon.id |
+
+**关键注意**:用户现有 `user_coupon` 未使用券在新链路下不可用(因为 `/api/coupon/my` 改读 family_coupon)。建议在上线说明中告知历史券有效期内的使用方式(如有)。
+
+---
+
+## 十、不在本次范围内
+
+- 小程序端优惠券展示/核销流程的优化(API 路径与字段不变,但页面逻辑暂不动)
+- `user_coupon` 历史数据清理/迁移
+- 订单详情页(cfc-web)中优惠券信息的增强展示
+
+---
+
+## 十一、关联文档
+
+- 实施计划:`plans/2026-08-31-coupon-family-based.md`(编写中)
+- API 参考:`docs/superpowers/api/API_REFERENCE.md`(发放相关章节需更新)
+- 项目管理:`docs/superpowers/PROJECT-OVERVIEW.md`(本项完成后需同步更新状态)

+ 385 - 0
docs/superpowers/specs/2026-08-31-self-check-ai-integration-design.md

@@ -0,0 +1,385 @@
+# 五维家庭自检 AI 结合(P0 + P1)设计
+
+> 日期:2026-08-31
+> 状态:已获用户方案确认(P0+P1 第一批,P2 后续)
+> 关联:`FiveDimensionSelfCheckService` / `AiGateway` / `cfc-langgraph`
+
+---
+
+## 1. 背景与目标
+
+现有五维自检使用**静态五行寻源表**(`WuxingSourcingService` 硬编码 5 条建议)生成结果,所有用户看到相同文案,无个性化、无趋势感知。
+
+本批次实现 P0+P1 共 4 个子系统,按优先级排序:
+
+| 优先级 | 子系统 | 目标 |
+|:------:|--------|------|
+| **P0-1** | 静态建议 → AI 个性化建议 | 每次自检生成有温度、有针对性的解读 |
+| **P0-2** | 用户点击生成健康计划 | 自检低分可一键生成家庭健康计划草稿 |
+| **P1-1** | 历史趋势 + AI 解读 | 3 次自检趋势可视化 + AI 趋势分析 |
+| **P1-2** | 自检结果注入 AI Chat | 聊天页自动携带自检上下文 |
+
+P2(家庭成员差异化建议)延后单独实现。
+
+已确认决策:
+- P0-1:**纯 AI 替换**,不保留静态五行寻源建议(`WuxingSourcingService` 仅保留 `levelOf/levelName/dimensionName`)
+- P0-2:**用户点击才生成**,不自动创建 draft plan
+- P0-1 字段设计:**直接改造 `advices` 字段**,新结构 `[{dimension, interpretation, microActions, aiInsight}]`
+
+---
+
+## 2. 现状盘点
+
+### 后端(自检)
+
+- `FiveDimensionSelfCheckController`:`/questions` `/submit` `/latest` `/history`
+- `FiveDimensionSelfCheckService`:
+  - `QUESTION_BANK`(15题)、`SUB_DIMENSION_QUESTION_BANK`(19题)
+  - `submitSelfCheck`:计分 → `wuxingSourcingService.getAdvicesForLowScores()` → 落库 `adviceJson`
+  - `getQuestions()` 已改为支持 `retake` 参数(上个计划完成)
+- `WuxingSourcingService`:静态 `SOURCING_TABLE`(5行×5列)+ `levelOf/levelName/dimensionName` 纯工具方法
+- 表 `five_dimension_self_checks`:`adviceJson TEXT`
+
+### 后端(AI 基础设施)
+
+- `AiGateway.java`:统一网关(熔断+降级+PII脱敏)
+  - `chat(query, userId, conversationId, inputs)` → `POST /api/v1/chat`
+  - `generateInnateReading(portrait)` → `POST /api/v1/innate/reading`
+  - `generateHealthPlan(inputs)` → `POST /api/v1/analysis/run`
+- LangGraph Python(`cfc-langgraph/app/`,端口 9000):
+  - `app/graphs/chat_graph.py`(78行,Dify 兼容)
+  - `app/graphs/health_plan_graph.py`(297行,Java 客户端 JavaClient 读取家庭数据)
+  - `app/graphs/innate_portrait_graph.py`(65行,参考实现)
+  - `app/api/adapter.py`(Dify 兼容路由)
+  - `app/api/innate_portrait.py`(`/api/v1/innate/reading`)
+  - `app/main.py` 注册各 router
+- 参考链路:`InnatePortraitController` → `InnatePortraitService.generateAiReading()` → `AiGateway.generateInnateReading()` → LangGraph `/api/v1/innate/reading` → `innate_portrait_graph.py` → 成功返回 reading,失败 null(调用方降级模板)
+
+### 前端
+
+- `pages/family/self-check-result.vue`:结果页,advices 展示在第 71-123 行(五行相生寻源建议区块)
+- `pages/family/self-check-entry.vue`:中间页(刚实现)
+- `pages/ai/chat.vue`:AI 聊天页(需确认路径)
+- `utils/api.js`:自检相关方法(`getSelfCheckStatus/ignoreSelfCheck/getSelfCheckQuestions/submitSelfCheck/getSelfCheckLatest/getSelfCheckHistory`)
+
+---
+
+## 3. 数据层设计
+
+### 3.1 `adviceJson` 结构变更
+
+**旧结构**(静态五行寻源):
+```json
+[
+  {
+    "dimension": "mind",
+    "dimensionName": "心",
+    "element": "火",
+    "color": "#FF6B9D",
+    "score": 2,
+    "level": "tense",
+    "levelName": "紧绷",
+    "upstreamDimension": "action",
+    "upstreamName": "行",
+    "upstreamElement": "木",
+    "upstreamColor": "#10B981",
+    "upstreamReason": "关系顺畅了,内心才安定",
+    "restrainerDimension": "wealth",
+    "restrainerName": "富",
+    "restrainerElement": "水",
+    "restrainerColor": "#F59E0B",
+    "restrainerReason": "钱多了,情薄了",
+    "action": "关系周记;家庭夜谈"
+  }
+]
+```
+
+**新结构**(AI 生成):
+```json
+[
+  {
+    "dimension": "mind",
+    "dimensionName": "心",
+    "element": "火",
+    "color": "#FF6B9D",
+    "score": 2,
+    "level": "tense",
+    "levelName": "紧绷",
+    "interpretation": "你的心能量偏低,可能最近情绪压力较大...",
+    "microActions": ["今晚睡前做10分钟深呼吸", "和伴侣约定每周一次夜谈"],
+    "aiInsight": "建议从'行'维度入手...",
+    "fallbackUsed": false
+  }
+]
+```
+
+**注意**:`dimension`/`dimensionName`/`element`/`color`/`score`/`level`/`levelName` 字段保留,与现有 VO 兼容。`upstreamDimension`/`upstreamReason`/`restrainerDimension` 等静态字段移除,新增 `interpretation`/`microActions`/`aiInsight`/`fallbackUsed`。
+
+### 3.2 新增 LangGraph Graph 输入/输出
+
+**self_check_analysis_graph**(P0-1):
+- 输入 `dict`:`scores`(Map<dimension, score>)、`questionIds`(List[int])、`userId`(int)、`recentHistory`(List[dict] 最近3次自检快照)
+- 输出 `dict`:`advice_json`(新结构 JSON string)、`fallback_used`(bool)
+
+**self_check_trend_graph**(P1-1):
+- 输入 `dict`:`history`(List[dict] 最近3次含 totalScore/dimensions/createdAt)、`userId`(int)
+- 输出 `dict`:`aiInsight`(str)、`trendSummary`(str)
+
+---
+
+## 4. 后端接口设计
+
+### 4.1 修改 `POST /api/family/self-check/submit`(P0-1)
+
+AI 建议在 `submitSelfCheck` 时**同步生成**并落库 `adviceJson`(不单独暴露 analysis 接口,避免 YAGNI 冗余)。
+
+```
+POST /api/family/self-check/submit
+  ↓
+submitSelfCheck():计分 → selfCheckAnalysisService.generateAdvice(...) → 落库
+```
+
+### 4.2 新增 `POST /api/family/self-check/generate-plan`(P0-2)
+
+请求:`{ "checkId": 12 }`(可空,默认取最近一次)。
+
+响应 `Result<Map>`:
+```json
+{ "planId": 45, "status": "draft" }
+```
+
+### 4.3 新增 `POST /api/family/self-check/trend-analysis`(P1-1)
+
+请求:无 body(JWT 取 `userId`)。
+
+响应 `Result<TrendAnalysisVO>`:
+```json
+{
+  "history": [{ "createdAt": "...", "totalScore": 32, "dimensions": [...] }],
+  "aiInsight": "近三次身维度持续下降,可能...",
+  "trendSummary": "身-2 智+1 富0 行-1 心+2"
+}
+```
+
+### 4.4 修改 `POST /api/family/self-check/submit`(P0-1)
+
+删除 `WuxingSourcingService.getAdvicesForLowScores()` 调用,改为:
+```java
+SelfCheckAdviceResult adviceResult = selfCheckAnalysisService.generateAdvice(userId, scoreMap, questionIds);
+// adviceResult 含 adviceJson + fallbackUsed
+// 直接落库
+```
+
+### 4.5 `SelfCheckResultVO` 字段
+
+`SelfCheckResultVO.advices` 类型保持 `List<WuxingSourcingAdviceVO>` 不变,字段结构按 5.4 改造后的 VO 承载 AI 建议内容。
+
+---
+
+## 5. Java 服务层设计
+
+### 5.1 新增 `SelfCheckAnalysisService`
+
+```java
+@Service
+public class SelfCheckAnalysisService {
+    @Resource private AiGateway aiGateway;
+    @Resource private FiveDimensionSelfCheckMapper selfCheckMapper;
+    
+    /** 生成自检建议(AI 优先,LangGraph 失败返回 null) */
+    public SelfCheckAdviceResult generateAdvice(Long userId, Map<String, Integer> scoreMap, List<Integer> questionIds) {
+        // 1. 组装 inputs
+        // 2. 调 aiGateway.generateSelfCheckAdvice(inputs)
+        // 3. 返回 SelfCheckAdviceResult(含 adviceJson + fallbackUsed)
+    }
+    
+    /** 生成趋势分析 */
+    public TrendAnalysisVO getTrendAnalysis(Long userId) { ... }
+}
+```
+
+### 5.2 AiGateway 新增方法
+
+```java
+public Map<String, Object> generateSelfCheckAdvice(Map<String, Object> inputs) {
+    // POST /api/v1/self-check/analysis
+    // 失败返回 null(调用方设置 fallbackUsed=true)
+}
+
+public Map<String, Object> generateSelfCheckTrend(Map<String, Object> inputs) {
+    // POST /api/v1/self-check/trend
+}
+```
+
+### 5.3 WuxingSourcingService 变更
+
+- **保留**:`DIMENSION_META`、`levelOf()`、`levelName()`、`dimensionName()`(被 `buildDimensionScoreVO` 使用)
+- **删除**:`SOURCING_TABLE`、`getAdvice()`、`getAdvicesForLowScores()`
+
+### 5.4 `WuxingSourcingAdviceVO` 改造(P0-1)
+
+不新建 VO,直接改造现有 `dto/WuxingSourcingAdviceVO.java`(保持 `SelfCheckResultVO.advices` 类型不变,最小改动):
+
+- **保留字段**:`dimension` / `dimensionName` / `element` / `color` / `score` / `level` / `levelName`(与现有 VO 兼容)
+- **移除字段**:`upstreamDimension` / `upstreamName` / `upstreamElement` / `upstreamColor` / `upstreamReason` / `restrainerDimension` / `restrainerName` / `restrainerElement` / `restrainerColor` / `restrainerReason` / `action`
+- **新增字段**:
+  - `interpretation`(String,AI 对低分维度的解读)
+  - `microActions`(List&lt;String&gt;,2-3 个本周微行动)
+  - `aiInsight`(String,AI 补充洞察,可空)
+  - `fallbackUsed`(Boolean,AI 不可用降级标记)
+
+---
+
+## 6. LangGraph Python 设计
+
+### 6.1 新增 `self_check_analysis_graph.py`
+
+```python
+# cfc-langgraph/app/graphs/self_check_analysis_graph.py
+SYSTEM_PROMPT = """你是一位家庭健康顾问,基于五维自检结果(身·智·富·行·心,每维0-9分,满分45)给出个性化建议。
+
+要求:
+1. 用第二人称"你"称呼
+2. 对每个低分维度(≤6分)给出1-2句解读和2-3个具体可执行的微行动
+3. 如有历史数据,简要对比趋势
+4. 语气温暖口语化,不超过300字/维度
+5. 最后给出1句家庭整体洞察
+
+返回 JSON:
+{
+  "advice": [
+    {"dimension": "mind", "dimensionName": "心", "interpretation": "...", "microActions": ["...", "..."]},
+    ...
+  ],
+  "familyInsight": "..."
+}
+"""
+
+class SelfCheckAnalysisAgent:
+    async def run(self, scores: dict, questionIds: list, recentHistory: list, userId: int) -> dict:
+        # 格式化输入 → LLM → JSON 解析 → 返回
+```
+
+### 6.2 新增 `self_check_trend_graph.py`
+
+```python
+SYSTEM_PROMPT = """分析用户近3次五维自检趋势,输出:
+- aiInsight: 趋势解读(2-3句,指出最大变化维度和可能原因)
+- trendSummary: 各维度 delta(如"身-2 智+1 富0 行-1 心+2")
+返回 JSON:{"aiInsight": "...", "trendSummary": "..."}
+"""
+```
+
+### 6.3 注册路由
+
+`cfc-langgraph/app/api/self_check.py`:
+- `POST /api/v1/self-check/analysis` → `self_check_analysis_graph`
+- `POST /api/v1/self-check/trend` → `self_check_trend_graph`
+
+`cfc-langgraph/app/main.py`:追加 `app.include_router(self_check.router)`
+
+---
+
+## 7. 前端设计
+
+### 7.1 P0-1:`self-check-result.vue` 建议区块重写
+
+删除现有 `scr-card` 寻源建议区块(第 71-123 行),替换为:
+
+```html
+<view class="ai-advice-card" v-if="result.adviceJson">
+  <view class="ai-advice-header">
+    <text class="ai-advice-title">AI 健康解读</text>
+    <text class="ai-advice-sub">基于你的五维自检结果</text>
+  </view>
+  <view v-for="item in result.adviceJson" :key="item.dimension" class="ai-advice-item">
+    <view class="ai-advice-dim-badge" :style="{background: item.color}">
+      <text>{{ item.dimensionName }}</text>
+      <text>{{ item.score }}分</text>
+    </view>
+    <text class="ai-advice-interpretation">{{ item.interpretation }}</text>
+    <view class="ai-advice-actions">
+      <text class="ai-advice-action-label">本周行动:</text>
+      <text v-for="(action, i) in item.microActions" :key="'a'+i" class="ai-advice-action">{{ i+1 }}. {{ action }}</text>
+    </view>
+  </view>
+  <view class="ai-advice-family" v-if="result.adviceJson[0] && result.adviceJson[0].familyInsight">
+    <text class="ai-advice-family-label">💡 家庭整体洞察</text>
+    <text class="ai-advice-family-text">{{ result.adviceJson[0].familyInsight }}</text>
+  </view>
+</view>
+<view class="ai-advice-fallback" v-else-if="result.fallbackUsed">
+  <text>AI 分析暂时不可用,请稍后再试</text>
+</view>
+```
+
+### 7.2 P0-2:结果页加「生成健康计划」按钮
+
+在结果页底部按钮区追加(有低分维度时显示):
+```html
+<button v-if="hasLowScore && !planGenerating" class="plan-btn" @click="generatePlan">
+  生成健康计划
+</button>
+```
+
+### 7.3 P1-1:趋势展示
+
+新增 `POST /api/family/self-check/trend-analysis`,结果页中间页可展示(或结果页底部加「查看趋势」链接)。
+
+### 7.4 P1-2:Chat 注入 context
+
+在 `self-check-result.vue` 加「问问 AI」按钮:
+```html
+<button @click="askAI">问问 AI</button>
+```
+跳转:`uni.navigateTo({ url: '/pages/ai/chat?selfCheckId=' + result.id })`
+
+`AIChatController` 读取 `selfCheckId`,调 `getSelfCheckLatest`,将结果放入 context 传给 AiGateway。
+
+---
+
+## 8. 边界行为与错误处理
+
+1. **LangGraph 不可用**:`AiGateway` 熔断/open 时,`generateAdvice` 返回 `fallbackUsed=true`,`adviceJson=null`,前端显示"AI 分析暂时不可用"
+2. **LLM 返回非 JSON**:Python 侧 `validate` 节点捕获,返回 error,Java 侧 fallbackUsed=true
+3. **P0-2 计划生成失败**:toast "计划生成失败,请稍后重试",不阻断结果页
+4. **P1-1 历史不足 3 次**:用现有次数展示(1-2 次),LLM 做有限对比
+5. **P1-2 chat 上下文**:selfCheckId 无效时静默跳过,不影响 chat 正常流程
+
+---
+
+## 9. 测试与验证
+
+- 后端:`mvn clean compile` BUILD SUCCESS
+- 前端:`node --check` api.js 语法校验
+- 手动验证路径:
+  1. 提交自检 → 查看 adviceJson 含 AI 生成内容(非静态表)
+  2. 点「生成健康计划」→ 跳转 plan 详情(draft 状态)
+  3. 点「查看趋势」→ 展示趋势 + AI 解读
+  4. 点「问问 AI」→ chat 页,LLM 回答知晓自检背景
+
+---
+
+## 10. 文件变更清单
+
+**LangGraph Python(新增 2 graph + 1 api + 改 main)**:
+- `cfc-langgraph/app/graphs/self_check_analysis_graph.py`(P0-1)
+- `cfc-langgraph/app/graphs/self_check_trend_graph.py`(P1-1)
+- `cfc-langgraph/app/api/self_check.py`(路由注册)
+- `cfc-langgraph/app/main.py`(追加 router include)
+
+**Java 后端**:
+- `dto/WuxingSourcingAdviceVO.java`(改造:移除 upstream/restrainer/action,新增 interpretation/microActions/aiInsight/fallbackUsed)
+- `service/SelfCheckAnalysisService.java`(新建)
+- `service/WuxingSourcingService.java`(删除 SOURCING_TABLE/getAdvice/getAdvicesForLowScores)
+- `service/FiveDimensionSelfCheckService.java`(修改 submitSelfCheck 调新 Service,新增 trend 方法)
+- `service/AiGateway.java`(新增 2 方法)
+- `controller/family/FiveDimensionSelfCheckController.java`(新增 3 接口,修改 submit)
+- `service/HealthPlanService.java`(新增 generateFromSelfCheck)
+- `service/impl/HealthPlanServiceImpl.java`(实现 generateFromSelfCheck)
+
+**前端**:
+- `utils/api.js`(新增 3 方法)
+- `pages/family/self-check-result.vue`(P0-1/P0-2/P1-2 改造)
+- `pages/family/self-check-entry.vue`(P1-1 趋势展示)
+- `pages/ai/chat.vue`(P1-2 context 注入,需确认是否存在)

+ 322 - 0
docs/superpowers/specs/2026-09-01-cf-commission-redesign.md

@@ -0,0 +1,322 @@
+# CF 值分佣体系重构设计文档
+
+> 日期:2026-09-01
+> 状态:待评审
+> 分支:cfc 主分支
+
+## 1. 背景与目标
+
+### 1.1 现状问题
+
+当前系统存在两套并行的返利/分佣体系:
+
+| 体系 | 服务 | 表 | 作用域 | 用途 | 状态 |
+|------|------|-----|--------|------|------|
+| 新 CF 值分佣 | `CommissionDistService` | `commission_dist_log` / `user_platform_balance` | 个人 | 商品订单 P点分润 | 仅商品订单 |
+| 旧佣金体系 | `CommissionService.settle/settleTwoLevel` | `commission_records` / `family_earnings` | 个人+家庭 | 套餐/测评/会员/订阅订单 L1/L2 佣金 | `@Deprecated`,仍被调用 |
+
+问题:
+1. 旧佣金体系 `settleTwoLevel` 仍被 5 个订单服务调用(套餐/测评/会员/订阅),与需求"全部走 CF 值体系"冲突。
+2. 团队规模统计 `PromotionTierService.updateTeamSize()` 全库无调用者,等级评估链路断裂。
+3. `promotion_tier_config` 表列(`min_team_size_1st/2nd + commission_rate_l1/l2`)与实体 `PromotionTierConfig`(`min_team_size + profit_share_percent + enabled`)不一致,等级配置实际未接通。
+4. 用户无法查询自己的团队规模和返佣比例。
+5. CF 值不能成员间转让;优惠券不绑定家庭。
+
+### 1.2 目标
+
+1. 全部订单统一走 CF 值分佣,废弃旧佣金体系写入。
+2. 返佣比例由团队规模(不限层级)决定,规模越大比例越高。
+3. 用户可查自己的团队规模与返佣比例。
+4. 家庭成员间可转让 CF 值;CF 值兑换的优惠券绑定家庭共享。
+
+### 1.3 关键决策(已与需求方确认)
+
+| # | 决策点 | 结论 |
+|---|--------|------|
+| D1 | 返佣比例模型 | **纯团队规模阶梯**(`cf_rate_tier` 动态可配置) |
+| D2 | CF 值归属维度 | **返给个人钱包**(`user_platform_balance`),不进家庭池 |
+| D3 | 会员升级特例 | 仅 membership/subscription 订单不返当前人,只返推荐人 |
+| D4 | 优惠券绑定 | 用 CF 兑换的券**放进家庭共享池**(券绑 `family_id`,全家可用) |
+| D5 | 同家庭互推 | **跳过本人,上溯到第一个非同家庭的引荐人** |
+| D6 | 分润结算主体 | **个人维度**(A/B 引荐人不同则各自独立结算) |
+| D7 | 团队规模口径 | 推荐人全层级下线总人数(`promotion_tier.total_team_size`) |
+
+## 2. 数据模型设计
+
+### 2.1 新建表
+
+#### `cf_rate_tier` — 分佣阶梯配置
+
+```sql
+CREATE TABLE IF NOT EXISTS cf_rate_tier (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tier_name VARCHAR(50) NOT NULL COMMENT '档位名称: 铜牌/银牌/金牌/铂金/钻石',
+    min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)',
+    rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%),如10表示10%',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高',
+    enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_min_size (min_team_size)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置';
+```
+
+种子数据(默认 5 档,管理端可动态增删改):
+
+| tier_name | min_team_size | rate_percent |
+|-----------|--------------|--------------|
+| 铜牌 | 0 | 5 |
+| 银牌 | 3 | 10 |
+| 金牌 | 10 | 15 |
+| 铂金 | 30 | 20 |
+| 钻石 | 100 | 25 |
+
+#### 个人 CF 钱包 — **复用 `user_platform_balance`(不新建)**
+
+经核对,`user_platform_balance` 表已具备 `family_id` 列及完整字段(`available/frozen/withdrawn/exchanged/total_earned`),`PlatformPointsService` 即为个人 CF 钱包服务(`earn/spend/freeze/unfreeze/withdraw`)。**直接复用,无需新建 `user_cf_wallet` 表。**
+
+转让时个人钱包变动复用 `PlatformPointsService.spend/earn`,通过 `cf_transfer_record` 记录流转明细。
+
+#### `cf_transfer_record` — CF 流转记录
+
+```sql
+CREATE TABLE IF NOT EXISTS cf_transfer_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    from_user_id BIGINT COMMENT '转出用户ID(null=平台/家庭池)',
+    to_user_id BIGINT COMMENT '转入用户ID',
+    family_id BIGINT COMMENT '所属家庭ID',
+    amount INT NOT NULL COMMENT 'CF值数量',
+    type VARCHAR(16) NOT NULL COMMENT 'transfer成员转让/allocate管理员分配/refund退款回补',
+    ref_type VARCHAR(50) COMMENT '关联业务类型',
+    ref_id BIGINT COMMENT '关联业务ID',
+    remark VARCHAR(255),
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_from (from_user_id),
+    INDEX idx_to (to_user_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录';
+```
+
+### 2.2 迁移现有表
+
+| 表 | 操作 | 说明 |
+|----|------|------|
+| `promotion_tier` | 删除 `team_size_1st/2nd/3rd`;新增 `rate_percent INT DEFAULT 0` | 团队规模只留 `total_team_size` 一个口径 |
+| `promotion_tier_config` | 废弃(保留历史数据,业务不再读取) | 替代者为 `cf_rate_tier` |
+| `coupon` | 新增 `family_id BIGINT NULL` | 券绑定家庭(NULL=不绑定) |
+| `user_platform_balance` | **复用,不改表** | 已有 `family_id` 列及完整字段,直接作为个人 CF 钱包 |
+
+### 2.3 保留不变
+
+- `referral_tree`:已支持全层级(`path` 物化路径)。
+- `user_platform_balance` / `platform_balance_log`:个人 CF 值余额与流水(复用为个人钱包)。
+- `commission_dist_log`:商品订单分润流水(迁移后并入通用分润流水体系)。
+- `family_platform_balance` / `family_platform_exchange_record`:**仅作优惠券兑换用途**(用个人 CF 兑换后券挂家庭),不再承接返佣。
+
+## 3. 分佣核心逻辑
+
+### 3.1 统一服务 `CfCommissionService`(新建)
+
+```java
+@Service
+public class CfCommissionService {
+    /** 通用分佣入口(商品/套餐/测评订单:当前人+推荐人双返) */
+    void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                Integer orderAmountCent, Long productId);
+
+    /** 会员/订阅专用(只返推荐人,不返当前人) */
+    void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                Integer orderAmountCent);
+}
+```
+
+### 3.2 推荐链查询与同家庭上溯
+
+```java
+List<ReferralNode> getEffectiveReferrers(Long buyerUserId, Long buyerFamilyId) {
+    // 1. 查 referral_tree WHERE child_id=buyerUserId ORDER BY level ASC
+    // 2. 对每个 referrer R:
+    //    a. R.familyId == buyerFamilyId → 跳过(同家庭不返),查 R 的引荐人继续上溯
+    //    b. R.familyId != buyerFamilyId → 加入结果集
+    // 3. 上溯终止:无更上级 / 已找到非同家庭引荐人
+}
+```
+
+> 决策 D5:同家庭互推时跳过本人,上溯到第一个非同家庭的引荐人。避免"自家消费返自己家人"的无效内耗与刷单。
+
+### 3.3 返佣比例计算(纯团队规模阶梯)
+
+```java
+int getRatePercent(Long referrerUserId) {
+    // 1. 查 promotion_tier.total_team_size(全层级下线人数)
+    // 2. SELECT rate_percent FROM cf_rate_tier
+    //    WHERE enabled=1 AND min_team_size <= total_team_size
+    //    ORDER BY min_team_size DESC LIMIT 1
+    // 3. 无匹配 → 0
+}
+```
+
+### 3.4 分润公式
+
+```
+分润CF = floor(orderAmountCent / 100) × 分润基数比例 × rate_percent / 100
+
+其中:
+- 商品订单:分润基数比例 = 商品 P点 ppointConfig(如 sourcePpoint 已含此义)
+- 非商品订单(套餐/测评/会员/订阅):分润基数比例 = sys_config.commission_service_rate(默认 10%)
+```
+
+简化统一为:
+
+```
+商品:cf = floor(sourcePpoint) × rate_percent / 100   (sourcePpoint 来自 PpointConfigService)
+非商品:cf = floor(orderAmountCent / 100) × serviceRateBps / 10000 × rate_percent / 100
+```
+
+### 3.5 返现路径
+
+- **当前人返 CF**(商品/套餐/测评):`platformPointsService.earn(buyerUserId, cf, "order_consume", orderId, ...)` → 个人钱包
+- **推荐人分润**:`platformPointsService.earn(referrerUserId, cf, "referral_dist", orderId, ...)` → 个人钱包
+- **流水**:`cf_transfer_record` 记录 from=平台 to=受益人(type=allocate)
+
+### 3.6 幂等
+
+以 `(ref_type='referral_dist'|'order_consume', ref_id=orderId)` 为幂等键,`platform_balance_log` 存在即跳过。`platformPointsService.earn` 当前实现无幂等检查,实现时需补(仿 `FamilyPlatformPointsService.earn` 的 `(ref_type, ref_id)` 去重)。
+
+## 4. 订单结算迁移点
+
+| 订单类型 | 文件:行 | 原调用 | 新调用 | 当前人返 CF | 推荐人分润 |
+|---|---|---|---|---|---|
+| 测评 | `AssessmentOrderService.java:76` | `commissionService.settle(...)` | `cfCommissionService.settle(...)` | ✅ | ✅ |
+| 套餐 | `PackagePaymentService.java:220` | `commissionService.settle(...)` | `cfCommissionService.settle(...)` | ✅ | ✅ |
+| 套餐 | `PaymentService.java:240` | `commissionService.settle(...)` | `cfCommissionService.settle(...)` | ✅ | ✅ |
+| 商品 | `ProductOrderService.java:645` | `commissionDistService.distribute(...)` | `cfCommissionService.settle(...)` | ✅ | ✅ |
+| 会员 | `MembershipService.java:784` | `commissionService.settleTwoLevel(...)` | `cfCommissionService.settleReferrerOnly(...)` | ❌ | ✅ |
+| 订阅 | `MemberSubscriptionService.java:174` | `commissionService.settleTwoLevel(...)` | `cfCommissionService.settleReferrerOnly(...)` | ❌ | ✅ |
+| 订阅 | `MemberSubscriptionService.java:255` | `commissionService.settleTwoLevel(...)` | `cfCommissionService.settleReferrerOnly(...)` | ❌ | ✅ |
+| 活动 | `ActivityOrderService` | 无 | 无 | ❌ | ❌ |
+
+### 4.1 商品订单处理
+
+商品订单当前 `ProductOrderService:633` 用 `platformPointsService.earn(buyerId, cfValue, "product_order", ...)` 返个人钱包,`:827 confirmReceive` 用 `familyPlatformPointsService.earn(familyId, ...)` 返家庭池。
+
+**本次设计决定:**
+- `:633` 个人 CF 返现:**保留**(属于分佣双返中的"当前人返")。
+- `:827 confirmReceive` 家庭池返 CF:**保留不动**(属于"确认收货额外积分",非本次推荐分佣范畴)。若后续需统一口径,另行提任务。
+
+> 待产品确认项:确认收货返家庭池 CF 是否仍需保留(与"返佣只进个人钱包"是否冲突)。
+
+## 5. 等级评估触发链路修复
+
+### 5.1 团队规模写入
+
+```
+推荐绑定:CommissionService.bindReferral(userId, referralCode)
+  → 建立 referral_tree 关系(path 物化路径)
+  → promotionTierService.updateTeamSize(referrerId, 全链路上溯, +1)
+     (对 referrer 及其所有祖先 promotion_tier.total_team_size +1)
+```
+
+### 5.2 等级/比例刷新
+
+```
+PromotionTierService.refreshRate(referrerUserId)
+  → 查 total_team_size → JOIN cf_rate_tier → rate_percent
+  → 写回 promotion_tier.rate_percent + tier(档位名)
+```
+
+触发时机:
+1. `bindReferral` 后(实时)
+2. 每日定时任务 `PromotionTierCheckScheduledTask`(现有,改调 refreshRate 批量刷新)
+
+### 5.3 废弃旧逻辑
+
+- `CommissionService.settle/settleTwoLevel` → 标 `@Deprecated` + 保留(历史读取),删除全部调用点。
+- `PromotionTierEvalService.evaluateTier`(基于 promotion_tier_config)→ 改读 `cf_rate_tier`。
+- `PromotionTierConfig` 实体 → 不再使用。
+
+## 6. 查询接口
+
+### 6.1 用户查询
+
+| 接口 | 说明 | 返回 |
+|------|------|------|
+| `POST /api/commission/cf/rate` | 我的团队规模 + 当前返佣比例 | `{ totalTeamSize, ratePercent, tierName }` |
+| `POST /api/commission/cf/summary` | 我的 CF 钱包汇总 | `{ available, frozen, totalEarned, totalSpent }` |
+| `POST /api/commission/cf/list` | CF 分润/消费流水(分页) | `Page<PlatformBalanceLog>` |
+| `POST /api/commission/cf/wallet` | 个人 CF 余额 | `PlatformPointsService.getBalance` |
+
+### 6.2 CF 转让
+
+| 接口 | 说明 | 约束 |
+|------|------|------|
+| `POST /api/cf/transfer/send` | 成员间转让 CF | 双方同家庭;转出余额足;type=transfer |
+| `POST /api/cf/transfer/list` | 转让记录(分页) | 按 family_id 或 userId 查询 |
+
+### 6.3 家庭券库
+
+| 接口 | 说明 |
+|------|------|
+| `POST /api/coupon/family/list` | 查询家庭共享券(`user_coupon.family_id=familyId`) |
+| 复用 | `POST /api/family-platform/exchange-coupon`(兑换后券挂 family_id) |
+
+### 6.4 管理端
+
+| 接口 | 说明 |
+|------|------|
+| `POST /api/admin/cf-rate-tier/list` | 阶梯配置列表 |
+| `POST /api/admin/cf-rate-tier/save` | 新增/更新档位 |
+| `POST /api/admin/cf-rate-tier/delete` | 删除档位 |
+
+## 7. 前端改造
+
+### 7.1 推广中心(`pages/promotion/*`)
+
+- `team.vue`:展示全层级团队总人数 + 当前返佣比例 + 档位名(调 `/api/commission/cf/rate`)
+- `commission.vue`:改用 CF 分润流水(调 `/api/commission/cf/list`)
+- `index.vue`:头部汇总改用个人 CF 钱包(调 `/api/commission/cf/summary`)
+
+### 7.2 家庭积分页
+
+- 新增"成员间转让"入口:调 `/api/cf/transfer/send`
+- 显示个人 CF 余额与转让记录
+
+### 7.3 优惠券
+
+- 兑换后的券进入家庭共享券库;下单可选择家庭券
+
+## 8. 错误处理与边界
+
+| 场景 | 处理 |
+|------|------|
+| 同家庭互推 | 跳过本人,上溯到非同家庭引荐人(D5) |
+| 无有效推荐人 | 不产生分润,结束 |
+| 余额不足 | `spend` 抛异常,前端提示"CF值不足" |
+| 重复结算 | `earn` 补幂等(`ref_type+ref_id` 去重) |
+| 团队规模未更新 | 定时任务兜底刷新 |
+| 孤儿用户(无 family) | 分润照常走个人钱包,family_id 冗余为空 |
+
+## 9. 测试计划
+
+- 单元:`getRatePercent` 阶梯匹配(边界 0/2/3/9/10);同家庭上溯逻辑;幂等防重。
+- 集成:各订单支付成功 → 分润到账;会员订单只返推荐人;转让同家庭校验。
+- E2E:推广中心展示团队规模与比例;成员间转让;家庭券库兑换与使用。
+
+## 10. 实施顺序(阶段)
+
+1. 数据库迁移:`cf_rate_tier` 建表 + 种子;`promotion_tier` 精简加列;`coupon` 加 `family_id`;`user_platform_balance` 补 `family_id`。
+2. `CfCommissionService` 核心 + `PlatformPointsService.earn` 幂等补丁。
+3. 等级评估:`updateTeamSize` 调用链 + `refreshRate` + 定时任务改造。
+4. 各订单服务迁移点切换(7 处)。
+5. 旧 `settle/settleTwoLevel` 调用点摘除 + `@Deprecated`。
+6. 查询接口 + CF 转让接口 + 家庭券库接口。
+7. 管理端阶梯配置接口。
+8. 前端改造。
+9. 测试 + 部署。
+
+## 11. 明确不做(YAGNI)
+
+- 不重建家庭级分润系统(家庭公共账户 `family_earnings` 不在本次范围)。
+- 不改 `invite_milestone` / `referral_leaderboard` / `onboarding` 现有逻辑。
+- 不迁移历史 `commission_records` 数据到 CF 体系(历史只读)。
+- 不做积分商城实物兑换(现有 `PointsExchangeProduct` 不动)。

+ 211 - 0
运营文案/opencode_client.py

@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""
+opencode_client.py — 通过本机常驻 opencode 服务的 HTTP API 进行会话交互
+
+负责:
+    1. 认证(basic auth)
+    2. chat_id → session_id 映射的持久化(JSON 文件,每用户/群一个 opencode 会话)
+    3. 确保某 chat 的会话存在(build agent)
+    4. 发消息 + 轮询取 assistant 最终文本回复
+
+权限策略(最安全):
+    - 只为会话启用「只读」类工具(读文件、浏览、文本生成)
+    - 禁用运行命令 / 写文件等需要授权的操作
+    - 因此 opencode 只能做无副作用的分析、生成回复,无法改动本机文件
+"""
+import io
+import json
+import os
+import sys
+import time
+import urllib.parse
+import urllib.request
+import base64
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+OPERATION_DIR = SCRIPT_DIR
+CFC_ROOT = os.path.dirname(OPERATION_DIR)
+
+# opencode 常驻服务
+OPCODE_BASE = "http://127.0.0.1:4090"
+OPCODE_USER = "opencode"
+OPCODE_PASSWORD = os.environ.get("OPENCODE_SERVER_PASSWORD", "IwinTrue@123")
+
+# 会话映射文件(chat_id -> session_id)
+SESSION_MAP_PATH = os.path.join(OPERATION_DIR, "opencode_sessions.json")
+
+# opencode 会话工作目录(项目根,含素材库/脚本,open code 可直接只读分析)
+WORK_DIR = CFC_ROOT
+
+# build agent 默认工作于该 project 的模型取服务端默认
+AGENT = "build"
+
+# 只读工具白名单:其余工具(bash 执行、写文件等)一律禁用
+# 此处列出 opencode 内置常用只读工具;未列出的默认不可用(服务端按 tools 参数过滤)
+READONLY_TOOLS = []
+
+# 危险的需授权工具,显式禁用(防御性,即便服务端宽松也不给)
+FORBIDDEN_TOOLS = [
+    "bash", "shell", "write", "edit", "apply_patch",
+    "task", "dispatch", "webfetch_post", "chrome_launch",
+]
+
+
+def _auth_header() -> str:
+    token = base64.b64encode(
+        f"{OPCODE_USER}:{OPCODE_PASSWORD}".encode("utf-8")
+    ).decode("ascii")
+    return f"Basic {token}"
+
+
+def _api(method: str, path: str, body=None, timeout: float = 60):
+    """请求 opencode HTTP API,返回 (status, decoded_body)。"""
+    url = OPCODE_BASE + path
+    data = None
+    if body is not None:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+    req = urllib.request.Request(url, data=data, method=method)
+    req.add_header("Authorization", _auth_header())
+    if body is not None:
+        req.add_header("Content-Type", "application/json; charset=utf-8")
+    with urllib.request.urlopen(req, timeout=timeout) as resp:
+        raw = resp.read().decode("utf-8", errors="replace")
+        return resp.status, raw
+
+
+def _load_session_map() -> dict:
+    if not os.path.exists(SESSION_MAP_PATH):
+        return {}
+    try:
+        with open(SESSION_MAP_PATH, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception:
+        return {}
+
+
+def _save_session_map(mapping: dict):
+    with open(SESSION_MAP_PATH, "w", encoding="utf-8") as f:
+        json.dump(mapping, f, ensure_ascii=False, indent=2)
+
+
+def _create_session(chat_id: str) -> str:
+    """为某 chat 新建一个 build agent 会话,返回 session_id。"""
+    dir_q = urllib.parse.quote(WORK_DIR)
+    body = {
+        "title": f"xhs-bot-{chat_id[:24]}",
+        "agent": AGENT,
+    }
+    st, raw = _api("POST", f"/session?directory={dir_q}", body=body, timeout=30)
+    if st not in (200, 201):
+        raise RuntimeError(f"创建会话失败 status={st}: {raw[:300]}")
+    data = json.loads(raw)
+    sid = data.get("id")
+    if not sid:
+        raise RuntimeError(f"创建会话未返回 session_id: {raw[:300]}")
+    return sid
+
+
+def ensure_session(chat_id: str) -> str:
+    """确保某 chat 有对应 opencode 会话,返回 session_id。"""
+    mapping = _load_session_map()
+    sid = mapping.get(chat_id)
+    if sid:
+        return sid
+    sid = _create_session(chat_id)
+    mapping[chat_id] = sid
+    _save_session_map(mapping)
+    return sid
+
+
+def send_prompt(session_id: str, text: str, timeout: float = 30):
+    """向指定会话异步发送一条用户消息。返回 HTTP 状态。"""
+    parts = [{"type": "text", "text": text}]
+    body = {
+        "parts": parts,
+        # 只读约束:明确禁用需授权的执行/写入类工具
+        "tools": {t: False for t in FORBIDDEN_TOOLS},
+    }
+    st, raw = _api("POST", f"/session/{session_id}/prompt_async",
+                   body=body, timeout=timeout)
+    return st, raw
+
+
+def _extract_assistant_text(msgs) -> str:
+    """从 v1 message 列表里提取最后一条 assistant 的最终可见文本。"""
+    last = msgs[-1] if msgs else None
+    if not last:
+        return ""
+    if last.get("info", {}).get("role") != "assistant":
+        return ""
+    texts = [p.get("text", "") for p in last.get("parts", [])
+             if p.get("type") == "text" and p.get("text")]
+    return texts[-1] if texts else ""
+
+
+def wait_for_reply(session_id: str, timeout: float = 180,
+                   poll_interval: float = 5.0) -> str:
+    """等待会话最新 assistant 回复完成,返回最终文本。
+
+    处理两种情形:
+      1) assistant 回复极快,进入本函数时已生成 → 直接看最后一条 assistant 文本
+      2) 正常流式:等待「发消息后新增」的 assistant 带文本出现
+    """
+    # 进入时先记录当前消息条数,作为"已有"基线
+    try:
+        _, raw0 = _api("GET", f"/session/{session_id}/message", timeout=30)
+        before = len(json.loads(raw0)) if raw0 else 0
+    except Exception:
+        before = 0
+
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        time.sleep(poll_interval)
+        try:
+            st, raw = _api("GET", f"/session/{session_id}/message", timeout=30)
+            if st != 200 or not raw:
+                continue
+            msgs = json.loads(raw)
+        except Exception:
+            continue
+        if not msgs:
+            continue
+
+        # 情况1:最后一条是 assistant 且有文本(覆盖超快回复:before 已含该条)
+        last = msgs[-1]
+        if last.get("info", {}).get("role") == "assistant":
+            lt = [p.get("text", "") for p in last.get("parts", [])
+                  if p.get("type") == "text" and (p.get("text") or "").strip()]
+            if lt:
+                return lt[-1]
+
+        # 情况2:出现新增的 assistant 消息(条数 > 基线)
+        if len(msgs) > before:
+            for m in reversed(msgs):
+                role = m.get("info", {}).get("role")
+                if role == "assistant":
+                    txts = [p.get("text", "") for p in m.get("parts", [])
+                            if p.get("type") == "text" and (p.get("text") or "").strip()]
+                    if txts:
+                        return txts[-1]
+    raise TimeoutError(f"等待 opencode 回复超时({timeout}s)")
+
+
+def ask(chat_id: str, text: str, timeout: float = 180) -> str:
+    """高层封装:确保会话 → 发消息 → 等回复 → 返回最终文本。"""
+    sid = ensure_session(chat_id)
+    st, raw = send_prompt(sid, text)
+    if st != 204:
+        raise RuntimeError(f"发送到 opencode 失败 status={st}: {raw[:300]}")
+    return wait_for_reply(sid, timeout=timeout)
+
+
+if __name__ == "__main__":
+
+    if not sys.stdout.isatty():
+        try:
+            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+    test_chat = "test-opencode-client"
+    reply = ask(test_chat, "请用一句话回复:收到消息了吗?")
+    print(f"REPLY: {reply}")

+ 329 - 0
运营文案/xhs_bot_listener.py

@@ -0,0 +1,329 @@
+# -*- coding: utf-8 -*-
+"""
+小红书数据机器人(飞书长连接双向版)
+=====================================
+监听飞书群的 @机器人 消息,按指令采集小红书数据并回复。
+
+指令:
+    看数据 / 日报 / 数据   → 通过 CDP 采集最新小红书数据,推送报告卡片
+    help / 帮助           → 显示帮助
+
+技术栈:
+    - lark_oapi.ws.Client(飞书官方长连接 WebSocket,免公网回调)
+    - cdp_publish.XiaohongshuPublisher(复用已登录 Chrome,端口 9222)
+    - xhs_daily_report.collect_overview / collect_notes / screenshot_current
+    - xhs_feishu.send_daily_report
+
+用法:
+    python xhs_bot_listener.py
+    python xhs_bot_listener.py --test   # 只跑一次连接测试,不常驻
+
+先决条件:
+    1. 本机 Chrome 已以 --remote-debugging-port=9222 启动且已登录小红书
+    2. 运营文案/xhs_daily_config.json 已填入飞书 app_id/secret/群ID
+"""
+import io
+import json
+import os
+import re
+import sys
+import threading
+import time
+
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
+sys.stderr = sys.stdout
+
+import requests
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import opencode_client  # opencode 4090 HTTP 会话交互
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+OPERATION_DIR = SCRIPT_DIR  # 本脚本位于 运营文案/ 目录,配置文件也在这
+CFC_ROOT = os.path.dirname(OPERATION_DIR)  # C:\code\cfc
+
+CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
+SCREENSHOT_DIR = os.path.join(OPERATION_DIR, "小红书发布", "_过程文件", "截图")
+
+# 复用 xhs_daily_report 与 xhs_feishu 的路径注入
+XHS_DAILY_DIR = os.path.join(OPERATION_DIR, "小红书发布", "_过程脚本")
+for _p in (OPERATION_DIR, XHS_DAILY_DIR, r"C:\code\XiaohongshuSkills\scripts"):
+    if _p not in sys.path:
+        sys.path.insert(0, _p)
+
+FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
+FEISHU_REPLY_URL = "https://open.feishu.cn/open-apis/im/v1/messages/{msg_id}/reply"
+FEISHU_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
+
+
+def _log(msg):
+    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
+
+
+def _load_config() -> dict:
+    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
+        cfg = json.load(f)
+    for k in ("feishu_app_id", "feishu_app_secret", "feishu_chat_id"):
+        if not cfg.get(k):
+            raise ValueError(f"配置缺少必填字段: {k}")
+    return cfg
+
+
+class FeishuChat:
+    """封装飞书 token、回复、发消息。"""
+
+    def __init__(self, cfg):
+        self.cfg = cfg
+        self._token = None
+
+    def token(self):
+        if self._token:
+            return self._token
+        r = requests.post(
+            FEISHU_TOKEN_URL,
+            json={"app_id": self.cfg["feishu_app_id"], "app_secret": self.cfg["feishu_app_secret"]},
+            timeout=15,
+        )
+        r.raise_for_status()
+        d = r.json()
+        if d.get("code") != 0:
+            raise RuntimeError(f"获取飞书 token 失败: {d.get('msg')}")
+        self._token = d["tenant_access_token"]
+        return self._token
+
+    def reply_text(self, message_id: str, text: str):
+        """回复某一 message_id(线程内回复)。
+
+        注意:reply 接口不需要 receive_id_type(消息 ID 已在 URL 中指定)。
+        """
+        url = FEISHU_REPLY_URL.format(msg_id=message_id)
+        body = {
+            "msg_type": "text",
+            "content": json.dumps({"text": text}, ensure_ascii=False),
+        }
+        r = requests.post(
+            url,
+            headers={"Authorization": f"Bearer {self.token()}"},
+            json=body,
+            timeout=20,
+        )
+        r.raise_for_status()
+        d = r.json()
+        if d.get("code") != 0:
+            _log(f"回复失败: {d.get('msg')} (message_id={message_id[:20]})")
+        return d
+
+    def send_text(self, text: str):
+        rtype = self.cfg.get("receive_id_type", "chat_id")
+        url = f"{FEISHU_MSG_URL}?receive_id_type={rtype}"
+        body = {
+            "receive_id": self.cfg["feishu_chat_id"],
+            "msg_type": "text",
+            "content": json.dumps({"text": text}, ensure_ascii=False),
+        }
+        r = requests.post(url, headers={"Authorization": f"Bearer {self.token()}"}, json=body, timeout=20)
+        r.raise_for_status()
+        d = r.json()
+        if d.get("code") != 0:
+            raise RuntimeError(f"发送文本失败: {d.get('msg')}")
+        return d
+
+
+def _mention_text(msg) -> str:
+    """还原消息中提到机器人之后的纯文本指令。
+
+    EventMessage.content 是 JSON 字符串,解析出 text 字段后
+    剥离 <at> 标签,返回可读文本。
+    """
+    if not (msg and msg.content):
+        return ""
+    content = msg.content
+    text = ""
+    try:
+        c = json.loads(content)
+        text = c.get("text", "") or ""
+    except Exception:
+        text = content
+    # 剥离 at 标签与首尾空白
+    text = re.sub(r"<at\b[^>]*>.*?</at>", "", text)
+    text = re.sub(r"@\S+\s*", "", text, flags=re.UNICODE)
+    return text.strip()
+
+
+def _is_bot_mentioned(msg) -> bool:
+    """判断消息是否 @了机器人。
+
+    飞书机器人消息的 mentions 字段:
+    - mentioned_type == 'bot' 表示 @了机器人(不是 'app')
+    - key == '@_user_1' 是机器人的特殊标识
+    """
+    if not (msg and msg.mentions):
+        return False
+
+    for mt in msg.mentions:
+        mtype = getattr(mt, "mentioned_type", "")
+        mkey = getattr(mt, "key", "")
+        # 飞书 bot 的 @mention 类型是 'bot',key 是 '@_user_1'
+        if mtype == "bot":
+            return True
+        if mkey == "@_user_1":
+            return True
+    return False  # 只要 mention 了,就看作对该 bot 的指令入口(群内一般是 @机器人)
+
+
+def _handle_data_command(message_id: str, feishu: FeishuChat):
+    """执行『看数据』指令:采集最新数据并推送报告卡片。"""
+    _log("[指令] 看数据 → 开始采集")
+    publisher = None
+    try:
+        # XiaohongshuPublisher 在 cdp_publish 模块(sys.path 已注入)
+        from cdp_publish import XiaohongshuPublisher
+        from xhs_daily_report import (
+            collect_overview,
+            collect_notes,
+            capture_three_screenshots,
+            CREATOR_HOME,
+            NOTES_MANAGE_URL,
+        )
+
+        cfg = _load_config()
+        date = time.strftime("%Y-%m-%d")
+
+        publisher = XiaohongshuPublisher()
+        publisher.connect(reuse_existing_tab=True)
+        if not publisher.check_login():
+            raise RuntimeError("未登录小红书,请先运行 --login")
+
+        _log("[采集] 账号总览...")
+        publisher._navigate(CREATOR_HOME)
+        time.sleep(4)
+        overview = collect_overview(publisher)
+
+        _log("[采集] 笔记列表...")
+        publisher._navigate(NOTES_MANAGE_URL)
+        time.sleep(4)
+        notes = collect_notes(publisher, 10)
+
+        _log("[截图] 三张整页截图(首页/笔记管理/账号概览)...")
+        shot_paths = capture_three_screenshots(publisher, SCREENSHOT_DIR, prefix="xhs_bot")
+        _log(f"  截图数量: {len(shot_paths)}")
+        publisher.disconnect()
+        publisher = None
+
+        _log("[推送] 发送日报卡片...")
+        try:
+            sys.path.insert(0, OPERATION_DIR)
+            from xhs_feishu import send_daily_report
+            send_daily_report(CONFIG_PATH, date, overview, notes, screenshot_paths=shot_paths)
+            _log("[完成] 日报已推送")
+        except Exception as e:
+            _log(f"[推送失败] {e}")
+            # 退化为文本回复
+            summary = (f"粉丝 {overview.get('fans_count', 0):,} | "
+                       f"阅读 {overview.get('total_read', 0):,} | "
+                       f"互动 {overview.get('total_interact', 0):,} | "
+                       f"笔记 {len(notes)} 篇")
+            feishu.reply_text(message_id, f"数据已采集({date}):{summary}\n卡片推送失败,详见日志。")
+            return
+
+        feishu.reply_text(message_id, f"✅ 日报已生成并推送到群({date})")
+    except Exception as e:
+        _log(f"[指令失败] {e}")
+        feishu.reply_text(message_id, f"❌ 采集失败:{e}")
+    finally:
+        try:
+            publisher.disconnect()
+        except Exception:
+            pass
+
+
+HELP_TEXT = (
+    "📌 小红书数据机器人可用指令:\n"
+    "· 看数据 / 日报 / 数据 → 采集最新小红书数据并推送报告\n"
+    "· help / 帮助 → 显示本帮助\n\n"
+    "请 @机器人 后发送指令。"
+)
+
+
+def on_message_receive(data):
+    """飞书消息接收事件回调(在 SDK 线程内执行,耗时操作放后台线程)。"""
+    try:
+        ev = data.event
+        msg = ev.message
+        if not msg:
+            return
+        chat_id = msg.chat_id
+        chat_type = getattr(msg, "chat_type", "")
+        msg_type = msg.message_type
+        _log(f"[收到消息] chat={chat_id} type={msg_type} chat_type={chat_type} mentions={len(msg.mentions or [])}")
+
+        # 仅响应 @机器人 的消息(群聊),p2p 私聊单独处理
+        if chat_type not in ("group", "p2p"):
+            return
+        if chat_type == "group" and not _is_bot_mentioned(msg):
+            _log("  未@机器人,忽略")
+            return
+
+        # 后台线程执行指令,避免阻塞事件循环
+        def _worker():
+            try:
+                feishu = FeishuChat(_load_config())
+                cmd = _mention_text(msg).lower()
+
+                # 需要执行采集脚本的指令 -> 本地路由
+                # (opencode 会话为只读,无法代为运行 CDP 采集,故保留本地执行)
+                if any(k in cmd for k in ("看数据", "日报", "数据", "stats", "status")):
+                    _handle_data_command(msg.message_id, feishu)
+                    return
+                if any(k in cmd for k in ("help", "帮助", "?")):
+                    feishu.reply_text(msg.message_id, HELP_TEXT)
+                    return
+
+                # 其余消息 -> 转发到该群的 opencode 会话处理(只读分析 + 生成回复)
+                user_text = _mention_text(msg)
+                _log(f"[opencode] chat={chat_id} 转发到 opencode 会话处理")
+                reply_text = opencode_client.ask(chat_id, user_text)
+                _log(f"[opencode] 得到回复: {reply_text[:120]!r}")
+                if reply_text and reply_text.strip():
+                    feishu.reply_text(msg.message_id, reply_text)
+                else:
+                    feishu.reply_text(msg.message_id, "(opencode 未返回有效回复,请稍后再试)")
+            except Exception as e:
+                _log(f"[worker] {e}")
+                try:
+                    feishu.reply_text(
+                        msg.message_id, f"❌ 处理出错:{e}\n请稍后再试,或发送「help」查看可用命令。"
+                    )
+                except Exception:
+                    pass
+
+        threading.Thread(target=_worker, daemon=True).start()
+    except Exception as e:
+        _log(f"[on_message_receive] {e}")
+
+
+def main():
+    parser = __import__("argparse").ArgumentParser(description="小红书飞书数据机器人(长连接)")
+    parser.add_argument("--test", action="store_true", help="连接后保持运行,便于验证(默认也常驻)")
+    args = parser.parse_args()
+
+    cfg = _load_config()
+    _log(f"启动小红书数据机器人监听...")
+    _log(f"app_id={cfg['feishu_app_id']}, 群={cfg['feishu_chat_id']}")
+
+    from lark_oapi.ws import Client
+    from lark_oapi.ws.client import EventDispatcherHandler
+
+    handler = (
+        EventDispatcherHandler.builder("", "")
+        .register_p2_im_message_receive_v1(on_message_receive)
+        .build()
+    )
+    client = Client(cfg["feishu_app_id"], cfg["feishu_app_secret"], event_handler=handler)
+    _log("连接飞书长连接 (WebSocket)...")
+    client.start()
+    _log("连接已断开")
+
+
+if __name__ == "__main__":
+    main()

+ 4 - 0
运营文案/xhs_bot_listener.vbs

@@ -0,0 +1,4 @@
+' 小红书数据机器人监听启动器(隐藏窗口)
+' 由 Windows 计划任务在登录时调用,避免黑窗口常驻。
+Set WshShell = CreateObject("WScript.Shell")
+WshShell.Run """C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe"" ""C:\code\cfc\运营文案\xhs_bot_listener.py""", 0, False

+ 2 - 0
运营文案/xhs_bot_listener_start.bat

@@ -0,0 +1,2 @@
+@echo off
+C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe "%~dp0xhs_bot_listener.py"

+ 6 - 0
运营文案/xhs_daily_config.json.example

@@ -0,0 +1,6 @@
+{
+  "feishu_app_id": "cli_xxxxxxxxxxxx",
+  "feishu_app_secret": "your_app_secret_here",
+  "feishu_chat_id": "oc_xxxxxxxxxxxx",
+  "receive_id_type": "chat_id"
+}

+ 224 - 0
运营文案/xhs_feishu.py

@@ -0,0 +1,224 @@
+"""
+飞书企业机器人推送工具
+======================
+通过飞书开放平台(open.feishu.cn API)发送富文本卡片消息 + 图片附件。
+
+用法:
+    from xhs_feishu import send_daily_report
+    send_daily_report(config_path, date, overview, notes, screenshot_path)
+
+依赖:
+    requests(第三方库)
+    配置 JSON 文件(含 feishu_app_id / feishu_app_secret / feishu_chat_id)
+"""
+import io
+import json
+import os
+import sys
+import time
+
+import requests
+
+FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
+FEISHU_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
+FEISHU_IMG_URL = "https://open.feishu.cn/open-apis/im/v1/images"
+
+# 进程内 token 缓存:{key: (token, expire_ts)}
+_TOKEN_CACHE = {}
+
+
+def _get_token(app_id: str, app_secret: str) -> str:
+    """获取 tenant_access_token(带进程内缓存,过期的自动刷新)。
+
+    飞书 token 默认有效期 7200s,这里提前 60s 视为过期。
+    """
+    cache_key = f"{app_id}:{app_secret}"
+    now = time.time()
+    if cache_key in _TOKEN_CACHE:
+        token, expire_ts = _TOKEN_CACHE[cache_key]
+        if expire_ts > now:
+            return token
+    resp = requests.post(
+        FEISHU_TOKEN_URL,
+        json={"app_id": app_id, "app_secret": app_secret},
+        timeout=15,
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取飞书 token 失败: {data.get('msg')}")
+    token = data["tenant_access_token"]
+    expire = data.get("expire", 7200)
+    _TOKEN_CACHE[cache_key] = (token, now + expire - 60)
+    return token
+
+
+def _upload_image(token: str, image_path: str) -> str:
+    """上传图片到飞书,返回 image_key。
+
+    注意:`image_type` 是 multipart form-data 的字段(不是 URL query 参数)。
+    此前误写成 `?image_type=message` 会导致 400 Bad Request(code=234001)。
+    """
+    url = FEISHU_IMG_URL
+    with open(image_path, "rb") as f:
+        resp = requests.post(
+            url,
+            headers={"Authorization": f"Bearer {token}"},
+            files={"image": (os.path.basename(image_path), f, "image/jpeg")},
+            data={"image_type": "message"},
+            timeout=30,
+        )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"上传图片失败: {data.get('msg')}")
+    return data["data"]["image_key"]
+
+
+def _build_card_json(date: str, overview: dict, notes: list) -> str:
+    """构建飞书富文本卡片 JSON 字符串。
+
+    overview 字段: fans_count, interact_count, follow_count,
+                   period_7d: {曝光数/观看数/点赞数/评论数/收藏数/分享数/净涨粉...},
+                   period_30d: 同上
+    """
+    ov = overview or {}
+
+    def _period_lines(period, label):
+        """格式化一个周期段"""
+        if not period:
+            return [f"⚠️ {label}数据暂不可用"]
+        return [
+            f"{label} 曝光 {period.get('曝光数', '-')}",
+            f"     观看 {period.get('观看数', '-')} | 点赞 {period.get('点赞数', '-')} | "
+            f"评论 {period.get('评论数', '-')} | 收藏 {period.get('收藏数', '-')} | "
+            f"分享 {period.get('分享数', '-')}",
+            f"     净涨粉 {period.get('净涨粉', '-')}",
+        ]
+
+    overview_lines = [
+        f"粉丝数:{ov.get('fans_count', 0)}  关注:{ov.get('follow_count', 0)}  "
+        f"获赞与收藏:{ov.get('interact_count', 0)}",
+    ]
+    # 近7日
+    overview_lines += _period_lines(ov.get("period_7d", {}), "近7日")
+    # 近30日(如果有)
+    if ov.get("period_30d"):
+        overview_lines += _period_lines(ov.get("period_30d", {}), "近30日")
+    note_lines = []
+    for i, n in enumerate(notes, 1):
+        note_lines.append(
+            f"{i}. 《{n.get('title', '')}》\n"
+            f"   📅{n.get('publish_date', '')}  "
+            f"👀{n.get('read_count', 0):,}  "
+            f"❤️{n.get('like_count', 0):,}  "
+            f"⭐{n.get('collect_count', 0):,}  "
+            f"💬{n.get('comment_count', 0):,}  "
+            f"🔄{n.get('share_count', 0):,}\n"
+            f"   📮未回复评论:{n.get('unreplied_comment_count', 0)}"
+        )
+
+    content = "\n\n".join([
+        "**📈 账号概览**\n" + "\n".join(overview_lines),
+        f"**📝 笔记列表({len(notes)}篇)**\n" + "\n\n".join(note_lines),
+        "🖼️ 附:创作者中心页面截图",
+    ])
+
+    card = {
+        "config": {"wide_screen_mode": True},
+        "header": {
+            "template": "blue",
+            "title": {"tag": "plain_text", "content": f"小红书创作者中心日报 | {date}"},
+        },
+        "elements": [
+            {"tag": "div", "text": {"tag": "lark_md", "content": content}},
+        ],
+    }
+    return json.dumps(card, ensure_ascii=False)
+
+
+def _send_message(token: str, cfg: dict, msg_type: str, content: str) -> dict:
+    """发送一条飞书消息,返回响应数据。
+
+    重要:飞书 im/v1/messages 接口的 receive_id_type 是 URL 查询参数,
+    不是 JSON body 字段(否则报 99992402 receive_id_type is required)。
+    """
+    rtype = cfg.get("receive_id_type", "chat_id")
+    url = f"{FEISHU_MSG_URL}?receive_id_type={rtype}"
+    body = {
+        "receive_id": cfg["feishu_chat_id"],
+        "msg_type": msg_type,
+        "content": content,
+    }
+    resp = requests.post(
+        url,
+        headers={"Authorization": f"Bearer {token}"},
+        json=body,
+        timeout=30,
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"发送飞书{msg_type}消息失败: {data.get('msg')}")
+    return data
+
+
+def send_daily_report(
+    config_path: str,
+    date: str,
+    overview: dict,
+    notes: list,
+    screenshot_path: str = None,
+    screenshot_paths: list = None,
+):
+    """发送小红书创作者中心日报到飞书群。
+
+    Args:
+        config_path: xhs_daily_config.json 路径
+        date: 报告日期 YYYY-MM-DD
+        overview: 账号总览 dict
+        notes: 笔记列表 list[dict]
+        screenshot_path: 单张截图文件路径(可选,向后兼容)
+        screenshot_paths: 多张截图文件路径列表(可选,优先使用)
+    """
+    with open(config_path, "r", encoding="utf-8") as f:
+        cfg = json.load(f)
+    required = ["feishu_app_id", "feishu_app_secret", "feishu_chat_id"]
+    missing = [k for k in required if not cfg.get(k)]
+    if missing:
+        raise ValueError(f"配置缺少必填字段: {', '.join(missing)}")
+
+    token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
+    card_json = _build_card_json(date, overview, notes)
+    data = _send_message(token, cfg, "interactive", card_json)
+
+    # 收集要附发的截图
+    shot_list = []
+    if screenshot_paths:
+        shot_list = [p for p in screenshot_paths if p and os.path.exists(p)]
+    elif screenshot_path and os.path.exists(screenshot_path):
+        shot_list = [screenshot_path]
+
+    for shot in shot_list:
+        try:
+            image_key = _upload_image(token, shot)
+            _send_message(
+                token, cfg, "image",
+                json.dumps({"image_key": image_key}),
+            )
+        except Exception as e:
+            print(f"[WARN] 发送截图失败,但卡片已推送: {e}", flush=True)
+
+    print(f"[OK] 飞书日报已推送,message_id={data['data']['message_id']}", flush=True)
+    return data["data"]["message_id"]
+
+
+if __name__ == "__main__":
+
+    if not sys.stdout.isatty():
+        try:
+            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+    print("飞书推送工具模块加载成功")
+    print(f"token URL: {FEISHU_TOKEN_URL}")

+ 2 - 0
运营文案/xhs_unreplied_comments.bat

@@ -0,0 +1,2 @@
+@echo off
+C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe "%~dp0xhs_unreplied_comments.py"

+ 411 - 0
运营文案/xhs_unreplied_comments.py

@@ -0,0 +1,411 @@
+"""小红书评论检测脚本 - 记录每篇笔记评论数,发现新增评论时读取具体评论并推送给回复建议
+
+运行逻辑:
+1. CDP 网络捕获 note-manager 的 posted API,获取每篇笔记的 id/xsec_token/comments_count/标题
+2. 与状态文件 xhs_comment_state.json 对比: 评论数比上次增加的笔记 → 打开详情页读取评论区
+3. 用 createTime > last_check_time 识别新增评论(含楼中楼回复)
+4. 每条新评论用 opencode 生成针对性回复建议
+5. 推送飞书(逐条评论+建议); 无新增评论则不推送
+6. 更新状态文件。首次运行只建立基线, 不推送
+"""
+import argparse
+import datetime
+import io
+import json
+import os
+import re
+import sys
+import time
+
+import requests
+
+if not sys.stdout.isatty():
+    try:
+        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
+    except Exception:
+        pass
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+OPERATION_DIR = SCRIPT_DIR
+CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
+STATE_PATH = os.path.join(OPERATION_DIR, "xhs_comment_state.json")
+
+XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
+sys.path.insert(0, XHS_SKILLS_PATH)
+sys.path.insert(0, OPERATION_DIR)
+
+from cdp_publish import XiaohongshuPublisher
+from xhs_feishu import _get_token, FEISHU_MSG_URL
+import opencode_client
+
+POSTED_API_PATH = "/api/galaxy/v2/creator/note/user/posted"
+NOTE_MANAGER_URL = "https://creator.xiaohongshu.com/new/note-manager"
+
+FALLBACK_SUGGESTIONS = [
+    "谢谢你这么用心地评论,看到你的留言真的很暖心~",
+    "哈哈哈你这个角度很特别,欢迎常来聊~",
+    "你的想法很有意思,我记下了,后面单独展开说说",
+    "谢谢支持!你提的这个点我改天专门写一篇",
+    "被你说中了,看来咱们想到一块儿去了 😄",
+]
+
+
+def _load_config():
+    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _load_state():
+    if not os.path.exists(STATE_PATH):
+        return {"notes": {}}
+    try:
+        with open(STATE_PATH, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception:
+        return {"notes": {}}
+
+
+def _save_state(state):
+    tmp = STATE_PATH + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump(state, f, ensure_ascii=False, indent=1)
+    os.replace(tmp, STATE_PATH)
+
+
+def _now_ms() -> int:
+    return int(time.time() * 1000)
+
+
+def _fmt_ms(ms: int) -> str:
+    try:
+        return datetime.datetime.fromtimestamp(ms / 1000).strftime("%m-%d %H:%M")
+    except Exception:
+        return ""
+
+
+def _parse_posted_body(body_text: str) -> list:
+    """解析 posted API 响应, 返回笔记列表。"""
+    try:
+        payload = json.loads(body_text)
+    except json.JSONDecodeError:
+        return []
+    notes = ((payload.get("data") or {}).get("notes")) or []
+    result = []
+    for n in notes:
+        if not isinstance(n, dict):
+            continue
+        note_id = str(n.get("id") or "").strip()
+        if not note_id:
+            continue
+        result.append({
+            "id": note_id,
+            "title": str(n.get("display_title") or "(无标题)").strip(),
+            "comments_count": int(n.get("comments_count") or 0),
+            "view_count": int(n.get("view_count") or 0),
+            "xsec_token": str(n.get("xsec_token") or "").strip(),
+            "publish_time": str(n.get("time") or "").strip(),
+        })
+    return result
+
+
+def capture_posted_notes(publisher, max_wait: float = 30.0) -> list:
+    """通过 CDP 网络捕获 note-manager 的 posted API, 滚动加载全部笔记。
+
+    注意: 滚动必须用裸 ws.send 发 Runtime.evaluate, 不能走 publisher._evaluate
+    (其内部 _send 会 recv 吞掉网络事件, 导致漏抓后续页面的 API 响应)。
+    """
+    publisher._send("Page.enable")
+    publisher._send("Network.enable", {"maxPostDataSize": 65536})
+    publisher._navigate(NOTE_MANAGER_URL)
+    time.sleep(3.0)
+
+    request_url_by_id = {}
+    target_request_ids = set()
+    start = time.time()
+    last_scroll_at = 0.0
+    last_new_at = time.time()
+    _scroll_cmd_id = 9000
+
+    while time.time() - start < max_wait:
+        # 捕获到请求后空闲 5s → 提前结束
+        if target_request_ids and time.time() - last_new_at > 5.0:
+            break
+
+        timeout = min(0.8, max(0.1, max_wait - (time.time() - start)))
+        try:
+            raw = publisher.ws.recv(timeout=timeout)
+        except TimeoutError:
+            # 空闲期滚动触发下一页加载(裸 ws.send, 不 recv)
+            if time.time() - last_scroll_at > 2.5:
+                _scroll_cmd_id += 1
+                publisher.ws.send(json.dumps({
+                    "id": _scroll_cmd_id,
+                    "method": "Runtime.evaluate",
+                    "params": {"expression": "window.scrollTo(0, document.body.scrollHeight)"},
+                }))
+                last_scroll_at = time.time()
+            continue
+
+        try:
+            message = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        method = message.get("method")
+        params = message.get("params", {})
+
+        if method == "Network.requestWillBeSent":
+            rid = params.get("requestId")
+            req = params.get("request", {})
+            if isinstance(rid, str):
+                request_url_by_id[rid] = req.get("url", "")
+        elif method == "Network.responseReceived":
+            rid = params.get("requestId")
+            if not isinstance(rid, str):
+                continue
+            url = request_url_by_id.get(rid, "")
+            if POSTED_API_PATH not in url:
+                continue
+            if params.get("response", {}).get("status") != 200:
+                continue
+            if rid not in target_request_ids:
+                target_request_ids.add(rid)
+                last_new_at = time.time()
+
+    # 空闲太久且已无新请求 → 停止
+    if not target_request_ids:
+        raise RuntimeError("未捕获到笔记列表 API 响应")
+
+    notes_by_id = {}
+    for rid in target_request_ids:
+        body_result = publisher._send("Network.getResponseBody", {"requestId": rid})
+        body_text = body_result.get("body", "")
+        if body_result.get("base64Encoded"):
+            import base64
+            body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")
+        for n in _parse_posted_body(body_text):
+            notes_by_id[n["id"]] = n
+
+    notes = list(notes_by_id.values())
+    print(f"[capture] pages={len(target_request_ids)} notes={len(notes)}")
+    return notes
+
+
+def _norm_comment(c: dict, is_sub: bool) -> dict:
+    """规范化一条评论(父评论或楼中楼)。"""
+    content = str(c.get("content") or "").strip()
+    pictures = c.get("pictures") or []
+    if not content and pictures:
+        content = "[图片]"
+    user = c.get("userInfo") or {}
+    create_ms = int(c.get("createTime") or 0)
+    return {
+        "id": str(c.get("id") or "").strip(),
+        "content": content,
+        "nickname": str(user.get("nickname") or "").strip() or "匿名用户",
+        "create_ms": create_ms,
+        "is_sub": is_sub,
+    }
+
+
+def fetch_note_comments(publisher, note_id: str, xsec_token: str) -> list:
+    """打开笔记详情页读取评论(父评论+楼中楼)。"""
+    if not xsec_token:
+        return []
+    res = publisher.get_feed_detail(
+        feed_id=note_id,
+        xsec_token=xsec_token,
+        load_all_comments=True,
+        limit=60,
+        click_more_replies=True,
+        reply_limit=10,
+    )
+    detail = res.get("detail") or {}
+    comments_raw = detail.get("comments") or {}
+    parents = comments_raw.get("list") or []
+
+    out = []
+    for p in parents:
+        if not isinstance(p, dict):
+            continue
+        out.append(_norm_comment(p, is_sub=False))
+        for s in (p.get("subComments") or []):
+            if isinstance(s, dict):
+                out.append(_norm_comment(s, is_sub=True))
+    return out
+
+
+def generate_suggestions(title: str, new_comments: list) -> list:
+    """用 opencode 批量生成每条新评论的回复建议; 失败则回退模板。"""
+    lines = []
+    for i, c in enumerate(new_comments, 1):
+        lines.append(f"{i}. 用户「{c.get('nickname')}」:{c.get('content') or '(图片)'}")
+
+    prompt = (
+        "你是小红书博主「72年冻龄妈妈的家的算法」(家庭教育博主) 的运营助手。\n"
+        f"笔记《{title}》收到以下新评论({len(lines)}条),请为每一条写一条中文回复建议。\n"
+        "要求: 每条 15-40 字, 口语化、真诚、像真人博主, 避免套话和模板感, 可适当互动引导。\n\n"
+        "评论列表:\n" + "\n".join(lines) +
+        "\n\n请严格按以下格式输出, 每条一行:\n1. <回复内容>\n2. <回复内容>\n..."
+    )
+    try:
+        reply = opencode_client.ask("xhs-comment-suggest", prompt, timeout=120)
+    except Exception as e:
+        print(f"[suggest] opencode 失败, 使用模板回退: {e}")
+        return [FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)] for i in range(len(new_comments))]
+
+    parsed = []
+    for ln in reply.splitlines():
+        ln = ln.strip()
+        m = re.match(r"^(\d+)\s*[.、)::]\s*(.+)$", ln)
+        if m and m.group(2).strip():
+            parsed.append(m.group(2).strip())
+
+    if not parsed:
+        # 严格格式未命中, 按非空行盲取
+        parsed = [ln.strip() for ln in reply.splitlines() if ln.strip()]
+
+    if len(parsed) < len(new_comments):
+        for i in range(len(parsed), len(new_comments)):
+            parsed.append(FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)])
+    return parsed[:len(new_comments)]
+
+
+def push_feishu(cfg, text: str):
+    token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
+    url = f"{FEISHU_MSG_URL}?receive_id_type=chat_id"
+    r = requests.post(
+        url,
+        headers={"Authorization": f"Bearer {token}"},
+        json={
+            "receive_id": cfg["feishu_chat_id"],
+            "msg_type": "text",
+            "content": json.dumps({"text": text}, ensure_ascii=False),
+        },
+        timeout=20,
+    )
+    d = r.json()
+    if d.get("code") != 0:
+        raise RuntimeError(f"推送失败: {d.get('msg')}")
+    return True
+
+
+def check_comments(dry_run: bool = False):
+    """检查新增评论并推送飞书"""
+    cfg = _load_config()
+    state = _load_state()
+    notes_state = state.setdefault("notes", {})
+
+    publisher = XiaohongshuPublisher()
+    publisher.connect(reuse_existing_tab=True)
+
+    try:
+        if not publisher.check_login():
+            raise RuntimeError("未登录小红书")
+
+        notes = capture_posted_notes(publisher)
+        now = _now_ms()
+        first_run = not notes_state
+        changed = []
+
+        for n in notes:
+            nid = n["id"]
+            prev = notes_state.get(nid)
+
+            if prev is None:
+                # 新笔记: 建立基线
+                notes_state[nid] = {
+                    "title": n["title"],
+                    "comment_count": n["comments_count"],
+                    "last_check_time": now,
+                }
+                continue
+
+            prev_count = int(prev.get("comment_count") or 0)
+            if n["comments_count"] <= prev_count:
+                continue
+
+            # 评论数增加 → 打开评论区
+            try:
+                comments = fetch_note_comments(publisher, nid, n.get("xsec_token") or "")
+            except Exception as e:
+                print(f"[fetch] 笔记 {nid} 评论区读取失败, 跳过: {e}")
+                continue
+
+            last_check_ms = int(prev.get("last_check_time") or 0)
+            new = [c for c in comments if c["create_ms"] > last_check_ms and c["id"]]
+            if not new:
+                # 评论被删除/替换, 只更新计数
+                notes_state[nid]["comment_count"] = n["comments_count"]
+                continue
+
+            suggestions = generate_suggestions(n["title"], new)
+            for i, c in enumerate(new):
+                c["suggestion"] = suggestions[i] if i < len(suggestions) else FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)]
+
+            changed.append({
+                "note": n,
+                "new_comments": new,
+            })
+            # 更新基线: 计数 + 已读时间(取已读评论的最大时间, 防止漏报)
+            max_create = max(c["create_ms"] for c in new)
+            notes_state[nid]["comment_count"] = n["comments_count"]
+            notes_state[nid]["last_check_time"] = max(now, max_create)
+            notes_state[nid]["title"] = n["title"]
+
+        _save_state(state)
+
+        if first_run:
+            print("[INIT] 首次运行: 已建立全部笔记基线, 不推送")
+            return
+
+        if not changed:
+            print("[SKIP] 无新增评论, 不推送")
+            return
+
+        date = time.strftime("%Y-%m-%d %H:%M")
+        sections = []
+        for ch in changed:
+            note = ch["note"]
+            new_c = ch["new_comments"]
+            lines = [f"《{note['title']}》 评论 +{len(new_c)} (共 {note['comments_count']} 条)"]
+            if note.get("publish_time"):
+                lines[0] += f" | 发布 {note['publish_time']}"
+            for c in new_c:
+                kind = "回复" if c.get("is_sub") else "评论"
+                lines.append(
+                    f"· [{kind}] @{c.get('nickname')} ({_fmt_ms(c.get('create_ms'))})\n"
+                    f"  {c.get('content') or '(图片)'}\n"
+                    f"  💡 建议回复: {c.get('suggestion')}"
+                )
+            sections.append("\n".join(lines))
+
+        msg = (
+            f"📬 小红书新增评论提醒({date})\n\n"
+            + "\n\n".join(sections)
+            + "\n\n💬 提示:以上为新增评论,请及时回复以增加互动和粉丝粘性"
+        )
+        print(msg)
+
+        if dry_run:
+            print("[DRY-RUN] 未实际推送")
+            return
+
+        push_feishu(cfg, msg)
+        print("[OK] 评论通知已推送")
+
+    except Exception as e:
+        print(f"检查评论失败: {e}")
+        import traceback
+        traceback.print_exc()
+    finally:
+        try:
+            publisher.disconnect()
+        except Exception:
+            pass
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="小红书新增评论检测")
+    parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
+    args = parser.parse_args()
+    check_comments(dry_run=args.dry_run)

+ 4 - 1
运营文案/小红书内容/D29-假懂事的孩子比叛逆更让人心疼.md

@@ -1,4 +1,7 @@
-D29-假懂事的孩子比叛逆更让人心疼
+# 假懂事的孩子,比叛逆更让人心疼
+
+> 发布编号:D29 | 维度:智(赋能未来)+ 心(大爱相随) | 状态:草稿  
+> 兑现钩子:D26《抢跑的孩子》预告"你以为你爱孩子,其实你爱的是'他应该成为的样子'"
 
 你家孩子是不是这样:从来不顶嘴、作业自己写、你说的话他都"嗯"一声照做,但你问他"你想做什么",他说"随便",然后就不说话了。
 

+ 360 - 0
运营文案/小红书发布/_过程脚本/xhs_daily_report.py

@@ -0,0 +1,360 @@
+"""
+小红书创作者中心日报采集脚本
+=============================
+每天早上 9:00(Windows 计划任务)运行:通过 CDP 连接已登录的 Chrome,
+采集小红书创作者中心的账号总览 + 笔记列表数据及页面截图,
+推送到飞书群。
+
+技术栈:CDP(复用已在运行的 Chrome + 小红书登录态),requests,飞书 API。
+复用自 C:\\code\\XiaohongshuSkills\\scripts\\cdp_publish.py 的 XiaohongshuPublisher。
+
+用法:
+    python xhs_daily_report.py        # 正常执行(连接 9222 端口 CDP)
+    python xhs_daily_report.py --login   # 手动登录引导(保存 CDP 登录态)
+
+先决条件:
+    1. 本机 Chrome 已以 --remote-debugging-port=9222 启动且已登录小红书
+    2. 运营文案/xhs_daily_config.json 已填入飞书 app_id/secret/群ID
+"""
+import argparse
+import base64
+import io
+import json
+import os
+import re
+import sys
+import time
+
+import requests
+
+# 遵循项目规范:UTF-8 输出,避免 Windows GBK 乱码
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
+sys.stderr = sys.stdout
+
+# ---- 路径配置 ----
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))                        # 小红书发布/_过程脚本/
+XHS_DIR = os.path.dirname(SCRIPT_DIR)                                           # 小红书发布/
+OPERATION_DIR = os.path.dirname(XHS_DIR)                                        # 运营文案/
+
+CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
+SCREENSHOT_DIR = os.path.join(XHS_DIR, "_过程文件", "截图")
+
+# 2026-08-28 起新增脚本采用轻量记录,不依赖旧版 master_tracking.csv
+RECORD_DIR = os.path.join(XHS_DIR, "_数据追踪")
+
+# XiaohongshuSkills 库路径
+XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
+sys.path.insert(0, XHS_SKILLS_PATH)
+
+CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
+NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/new/note-manager"
+
+
+def _log(msg):
+    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
+
+
+def _load_config() -> dict:
+    """读取飞书配置。"""
+    if not os.path.exists(CONFIG_PATH):
+        raise FileNotFoundError(
+            f"配置文件不存在: {CONFIG_PATH}\n"
+            f"请从 xhs_daily_config.json.example 复制并填入真实值"
+        )
+    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
+        cfg = json.load(f)
+    missing = [k for k in ("feishu_app_id", "feishu_app_secret", "feishu_chat_id") if not cfg.get(k)]
+    if missing:
+        raise ValueError(f"配置缺少必填字段: {', '.join(missing)}")
+    return cfg
+
+
+def _to_int(text):
+    """'1.2万' -> 12000, '1,234' -> 1234, '--'/'' -> 0。"""
+    if not text:
+        return 0
+    t = str(text).strip().replace(",", "").replace(" ", "")
+    if not t or t == "--":
+        return 0
+    m = re.match(r"^([\d.]+)(万|亿)?$", t)
+    if not m:
+        return 0
+    val = float(m.group(1))
+    unit = m.group(2)
+    if unit == "万":
+        val *= 10000
+    elif unit == "亿":
+        val *= 100000000
+    return int(val)
+
+
+def _clean_title(title):
+    """清理标题:去掉换行与多余空白。"""
+    if not title:
+        return ""
+    return " ".join(title.split())
+
+
+def collect_overview(publisher) -> dict:
+    """
+    从创作者中心首页 /new/home 获取账号总览数据。
+
+    页面 innerText 结构(实测):
+      - 账号级字段: "数字\\n标签"(如 "20\\n粉丝数", "551\\n获赞与收藏")
+      - 周期数据: "标签\\n数字\\n环比xx%"(如 "观看数\\n209\\n环比-86%")
+      - 默认显示近7日,点击「近30日」tab 抓第二组
+
+    返回 dict:
+      fans_count / follow_count / interact_count(账号级)
+      period_7d / period_30d: 各含 exposure/view/like/comment/collect/share/net_fans
+    """
+    overview = {}
+
+    def _parse_period(text):
+        """解析周期数据(标签\\n数字\\n环比%)。返回 dict[label] = (值, 原始字符串)"""
+        p = {}
+        # 匹配 "标签\n数字\n环比xx%" 或 "标签\n数字\n环比-"
+        pattern = re.compile(
+            r"^(曝光数|观看数|封面点击率|视频完播率|点赞数|评论数|收藏数|分享数|净涨粉|新增关注|取消关注|主页访客)"
+            r"\n([\d.,万%]+)\n环比([+-]?\d*%?)", re.M)
+        for m in pattern.finditer(text):
+            p[m.group(1)] = m.group(2)
+        return p
+
+    # --- 账号级字段:数字在标签前 ---
+    text = (publisher._evaluate("document.body.innerText") or "")
+    for key, label in (("fans_count", "粉丝数"), ("follow_count", "关注数"),
+                       ("interact_count", "获赞与收藏")):
+        m = re.search(r"([\d.,万]+)\n" + re.escape(label), text)
+        if m:
+            overview[key] = _to_int(m.group(1))
+        else:
+            overview[key] = 0
+
+    # --- 近7日(默认 tab) ---
+    period_7d = _parse_period(text)
+
+    # --- 近30日:点击 tab 抓第二组 ---
+    period_30d = {}
+    try:
+        js = (
+            "(() => {"
+            "  const els = [...document.querySelectorAll('*')] ;"
+            "  const t = els.find(e => e.children.length === 0 && e.textContent.trim() === '近30日');"
+            "  if (t) { t.click(); return 'CLICKED'; }"
+            "  return 'NOT_FOUND';"
+            "})()"
+        )
+        r = publisher._evaluate(js)
+        if r == "CLICKED":
+            time.sleep(6)  # 等待数据刷新
+            text2 = publisher._evaluate("document.body.innerText") or ""
+            period_30d = _parse_period(text2)
+            _log(f"  近30日: 观看={period_30d.get('观看数')} 净涨粉={period_30d.get('净涨粉')}")
+            if not period_30d:
+                _log("  [WARN] 近30日解析为空,重试一次")
+                time.sleep(4)
+                text2 = publisher._evaluate("document.body.innerText") or ""
+                period_30d = _parse_period(text2)
+                _log(f"  近30日(重试): 观看={period_30d.get('观看数')}")
+        else:
+            _log(f"  [WARN] 切换近30日 tab 失败: {r}")
+    except Exception as e:
+        _log(f"  [WARN] 抓近30日失败: {e}")
+
+    overview["period_7d"] = period_7d
+    overview["period_30d"] = period_30d
+    return overview
+
+
+def collect_notes(publisher, limit=10):
+    """
+    从笔记管理页(/new/note-manager)采集笔记列表。
+
+    该页每篇笔记在 innerText 中呈现为固定行块:
+        标题
+        YYYY-MM-DD HH:MM
+        阅读数
+        点赞数
+        收藏数
+        评论数
+        分享数
+    """
+    text = publisher._evaluate("document.body.innerText") or ""
+    lines = [ln.strip() for ln in text.split("\n")]
+    dates = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")
+    notes = []
+    i = 0
+    while i < len(lines) and len(notes) < limit:
+        ln = lines[i]
+        # 日期行:上一行是标题,后续连续 5 行是数字
+        if dates.match(ln) and i + 5 < len(lines):
+            title = _clean_title(lines[i - 1]) if i >= 1 else ""
+            nums = []
+            # 收集日期行后连续的数字行
+            j = i + 1
+            while j < len(lines) and len(nums) < 5 and lines[j].isdigit():
+                nums.append(int(lines[j]))
+                j += 1
+            if len(nums) >= 5 and title:
+                # 页面顺序: 阅读/评论/点赞/收藏/分享
+                notes.append({
+                    "title": title,
+                    "publish_date": ln[:10],
+                    "read_count": nums[0],
+                    "comment_count": nums[1],
+                    "like_count": nums[2],
+                    "collect_count": nums[3],
+                    "share_count": nums[4],
+                    "unreplied_comment_count": 0,
+                })
+            i = j if j > i + 1 else i + 1
+        else:
+            i += 1
+    return notes[:limit]
+
+
+def screenshot_current(publisher, output_path, full_page: bool = True):
+    """用 CDP 截取当前页面(JPEG),默认整页截图。
+
+    full_page=True 时用 captureBeyondViewport 截取整个可滚动页面。
+    """
+    os.makedirs(os.path.dirname(output_path), exist_ok=True)
+    params = {"format": "jpeg", "quality": 60}
+    if full_page:
+        params["captureBeyondViewport"] = True
+    result = publisher._send("Page.captureScreenshot", params)
+
+    data = result.get("data") or (result.get("result") or {}).get("data")
+    if data:
+        img_data = base64.b64decode(data)
+        with open(output_path, "wb") as f:
+            f.write(img_data)
+        _log(f"截图已保存: {output_path} ({len(img_data)} bytes)")
+    else:
+        raise RuntimeError(f"截图失败: {result}")
+
+
+# 账号概览页 URL(创作者中心数据看板)
+ACCOUNT_OVERVIEW_URL = "https://creator.xiaohongshu.com/statistics/account/v2"
+
+
+def capture_three_screenshots(publisher, shot_dir, prefix="xhs_bot"):
+    """依次截取三张整页截图:首页 / 笔记管理 / 账号概览。
+
+    Returns:
+        list[str]: 三张截图的文件路径(按顺序:首页, 笔记管理, 账号概览)
+    """
+    os.makedirs(shot_dir, exist_ok=True)
+    ts = int(time.time())
+    shots = []
+
+    targets = [
+        (CREATOR_HOME, f"{prefix}_{ts}_home.jpg"),
+        (NOTES_MANAGE_URL, f"{prefix}_{ts}_notes.jpg"),
+        (ACCOUNT_OVERVIEW_URL, f"{prefix}_{ts}_overview.jpg"),
+    ]
+    for url, fname in targets:
+        try:
+            publisher._navigate(url)
+            time.sleep(5)
+            path = os.path.join(shot_dir, fname)
+            screenshot_current(publisher, path, full_page=True)
+            shots.append(path)
+        except Exception as e:
+            _log(f"[WARN] 截图失败 {url}: {e}")
+
+    return shots
+
+
+def append_daily_record(date, overview, notes):
+    """追加当日快照到本地 JSON 记录(轻量,不依赖旧 CSV 结构)。"""
+    os.makedirs(RECORD_DIR, exist_ok=True)
+    rec_path = os.path.join(RECORD_DIR, "daily_reports.jsonl")
+    rec = {
+        "date": date,
+        "overview": overview,
+        "notes": notes,
+        "recorded_at": time.strftime("%Y-%m-%d %H:%M:%S"),
+    }
+    with open(rec_path, "a", encoding="utf-8") as f:
+        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="小红书创作者中心日报采集与飞书推送")
+    parser.add_argument("--login", action="store_true", help="手动登录态(需人工扫码)")
+    parser.add_argument("--limit", type=int, default=10, help="推送笔记条数上限")
+    args = parser.parse_args()
+
+    cfg = _load_config()
+
+    from cdp_publish import XiaohongshuPublisher
+    publisher = XiaohongshuPublisher()
+
+    if args.login:
+        try:
+            publisher.connect(reuse_existing_tab=True)
+        except Exception as e:
+            _log(f"连接 CDP 失败: {e}")
+            _log("请先用下列命令启动带调试端口的 Chrome 并登录小红书:")
+            _log('chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data"')
+            sys.exit(1)
+        publisher.check_login()
+        publisher.disconnect()
+        _log("登录态已确认/缓存。之后直接运行 xhs_daily_report.py 即可。")
+        return
+
+    # ---- 常规日报流程 ----
+    date = time.strftime("%Y-%m-%d")
+    overview = {"fans_count": 0, "fans_growth_30d": 0, "total_read": 0,
+                "total_interact": 0, "note_count": 0}
+    try:
+        _log("连接 Chrome CDP (9222)...")
+        publisher.connect(reuse_existing_tab=True)
+        if not publisher.check_login():
+            raise RuntimeError("未登录小红书,请先运行 --login 或手动打开浏览器完成扫码")
+
+        # 1. 账号总览
+        _log("采集账号总览...")
+        publisher._navigate(CREATOR_HOME)
+        time.sleep(4)
+        overview = collect_overview(publisher)
+        _log(f"  粉丝数: {overview.get('fans_count')}, 总阅读: {overview.get('total_read')}, "
+             f"总互动: {overview.get('total_interact')}, 笔记数: {overview.get('note_count')}")
+
+        _log(f"采集笔记列表(最多 {args.limit} 条)...")
+        publisher._navigate(NOTES_MANAGE_URL)
+        time.sleep(4)
+        notes = collect_notes(publisher, args.limit)
+        _log(f"  采集到 {len(notes)} 篇笔记")
+
+
+        _log("截图(首页/笔记管理/账号概览 三张整页)...")
+        shot_paths = capture_three_screenshots(publisher, SCREENSHOT_DIR, prefix=f"xhs_daily_{date}")
+        _log(f"  截图 {len(shot_paths)} 张")
+
+
+        append_daily_record(date, overview, notes)
+
+
+        _log("推送到飞书...")
+        sys.path.insert(0, OPERATION_DIR)
+        from xhs_feishu import send_daily_report
+        send_daily_report(CONFIG_PATH, date, overview, notes, screenshot_paths=shot_paths)
+        _log("✅ 日报流程完成")
+
+    except Exception as e:
+        _log(f"❌ 采集失败: {e}")
+        import traceback
+        traceback.print_exc(file=sys.stderr)
+        sys.exit(1)
+    finally:
+        try:
+            publisher.disconnect()
+        except Exception:
+            pass
+
+
+if __name__ == "__main__":
+    main()