Просмотр исходного кода

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

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
E2E Test Bot 2 месяцев назад
Родитель
Сommit
ee3db28803
98 измененных файлов с 4502 добавлено и 1301 удалено
  1. 3 0
      .gitignore
  2. 93 3
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java
  4. 93 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java
  5. 2 11
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  6. 34 77
      cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java
  7. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  8. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java
  9. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  10. 8 18
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PackagePurchaseController.java
  11. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java
  12. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java
  13. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java
  14. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MemberUpgradeRecord.java
  15. 2 10
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java
  16. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java
  17. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java
  18. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java
  19. 11 4
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java
  20. 200 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  21. 89 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java
  22. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
  23. 24 3
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  24. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  25. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java
  26. 15 11
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  27. 5 3
      cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java
  28. 5 9
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  29. 0 341
      cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java
  30. 5 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  31. 43 16
      cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java
  32. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java
  33. 15 36
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  34. 12 42
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierEvalService.java
  35. 35 21
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java
  36. 0 13
      cfc-backend/src/main/java/com/etotem/cfc/service/api/PackagePaymentServiceInterface.java
  37. 16 7
      cfc-backend/src/main/java/com/etotem/cfc/task/PromotionTierCheckScheduledTask.java
  38. 38 4
      cfc-backend/src/main/resources/schema.sql
  39. 0 1
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/FiveDimensionSelfCheckServiceTest.java
  40. 13 0
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/PdfParseServiceTest.java
  41. 119 0
      cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java
  42. 63 2
      cfc-frontend/AGENTS.md
  43. 42 15
      cfc-frontend/__tests__/api/api.test.js
  44. 18 6
      cfc-frontend/components/ArticleBookshelf.vue
  45. 5 1
      cfc-frontend/components/DimensionActivities.vue
  46. 5 1
      cfc-frontend/components/DimensionProducts.vue
  47. 191 172
      cfc-frontend/components/MemberChipPicker.vue
  48. 5 3
      cfc-frontend/components/RadarChart.vue
  49. 16 2
      cfc-frontend/pages/diet/recommendation.vue
  50. 6 1
      cfc-frontend/pages/energy/index.vue
  51. 1 1
      cfc-frontend/pages/growth-main/index.vue
  52. 7 1
      cfc-frontend/pages/guide/hierarchy/index.vue
  53. 6 1
      cfc-frontend/pages/guide/invite/share.vue
  54. 9 6
      cfc-frontend/pages/health-main/index.vue
  55. 6 1
      cfc-frontend/pages/health/challenge-manage.vue
  56. 145 40
      cfc-frontend/pages/health/gut-flora-detail.vue
  57. 208 82
      cfc-frontend/pages/health/gut-flora-foods-detail.vue
  58. 13 10
      cfc-frontend/pages/health/gut-flora-risks-detail.vue
  59. 14 12
      cfc-frontend/pages/health/gut-flora-species-detail.vue
  60. 128 50
      cfc-frontend/pages/health/report-list.vue
  61. 5 1
      cfc-frontend/pages/health/survey-history.vue
  62. 8 2
      cfc-frontend/pages/home-pages/parent-index.vue
  63. 37 24
      cfc-frontend/pages/index-home/index.vue
  64. 6 0
      cfc-frontend/pages/invite/join.vue
  65. 6 1
      cfc-frontend/pages/notification/index.vue
  66. 6 1
      cfc-frontend/pages/profile-extra/onboarding.vue
  67. 55 110
      cfc-frontend/pages/promotion/commission.vue
  68. 16 17
      cfc-frontend/pages/promotion/index.vue
  69. 12 12
      cfc-frontend/pages/promotion/team.vue
  70. 6 1
      cfc-frontend/pages/shop/address/address.vue
  71. 4 1
      cfc-frontend/pages/shop/cart/cart.vue
  72. 7 2
      cfc-frontend/pages/shop/checkout/checkout.vue
  73. 8 3
      cfc-frontend/pages/shop/index/index.vue
  74. 6 1
      cfc-frontend/pages/tasks/daily-tasks.vue
  75. 6 1
      cfc-frontend/pages/teacher/service-tasks.vue
  76. 6 1
      cfc-frontend/pages/teacher/teacher-home.vue
  77. 6 1
      cfc-frontend/pages/wealth-cf/index.vue
  78. 234 0
      cfc-frontend/scripts/audit-duplicate-api-calls.js
  79. 91 33
      cfc-frontend/utils/api.js
  80. 1 1
      cfc-web/.last_build_commit
  81. 2 2
      cfc-web/package-lock.json
  82. 1 1
      cfc-web/package.json
  83. 130 0
      cfc-web/public/CHANGELOG-v1.0.md
  84. 131 1
      cfc-web/public/CHANGELOG.md
  85. 13 36
      cfc-web/src/views/admin/MembershipCenter.vue
  86. 37 5
      cfc-web/src/views/admin/ProductEdit.vue
  87. 47 1
      docs/superpowers/api/API_REFERENCE.md
  88. 1 1
      docs/superpowers/plans/2026-09-01-cf-commission-redesign.md
  89. 211 0
      运营文案/opencode_client.py
  90. 329 0
      运营文案/xhs_bot_listener.py
  91. 4 0
      运营文案/xhs_bot_listener.vbs
  92. 2 0
      运营文案/xhs_bot_listener_start.bat
  93. 6 0
      运营文案/xhs_daily_config.json.example
  94. 224 0
      运营文案/xhs_feishu.py
  95. 2 0
      运营文案/xhs_unreplied_comments.bat
  96. 411 0
      运营文案/xhs_unreplied_comments.py
  97. 4 1
      运营文案/小红书内容/D29-假懂事的孩子比叛逆更让人心疼.md
  98. 360 0
      运营文案/小红书发布/_过程脚本/xhs_daily_report.py

+ 3 - 0
.gitignore

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

+ 93 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10106,7 +10106,97 @@ public class DatabaseInitializer implements CommandLineRunner {
             log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
         }
 
-        // 迁移279: 创建 product_gift_configs 表(商品赠品配置,商品×等级)
+        // 迁移279: 创建 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());
+        }
+
+        // 迁移280: 创建 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());
+        }
+
+        // 迁移281: 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());
+        }
+
+        // 迁移282: 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());
+        }
+
+        // 迁移283: referral_tree 唯一键改为 (parent_id, child_id) 联合唯一(支持每祖先一行物化)
+        try {
+            jdbcTemplate.execute("ALTER TABLE referral_tree DROP INDEX uk_child_id");
+            log.info("referral_tree 已删除 uk_child_id 唯一键");
+        } catch (Exception e) {
+            log.warn("referral_tree.uk_child_id 删除失败(可能不存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE referral_tree ADD UNIQUE KEY uk_parent_child (parent_id, child_id)");
+            log.info("referral_tree 已添加 uk_parent_child 联合唯一键");
+        } catch (Exception e) {
+            log.warn("referral_tree.uk_parent_child 已存在: {}", e.getMessage());
+        }
+
+        // 迁移284: cf_transfer_record 加 (ref_type, ref_id) 唯一索引(幂等兜底,防止并发 earn 重复发放)
+        try {
+            jdbcTemplate.execute("ALTER TABLE cf_transfer_record ADD UNIQUE KEY uk_ref_type_id (ref_type, ref_id)");
+            log.info("cf_transfer_record 已添加 uk_ref_type_id 唯一索引");
+        } catch (Exception e) {
+            log.warn("cf_transfer_record.uk_ref_type_id 已存在: {}", e.getMessage());
+        }
+
+        // 迁移285: 创建 product_gift_configs 表(商品赠品配置,商品×等级)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_configs (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -10124,7 +10214,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             // 表已存在,忽略
         }
 
-        // 迁移280: 创建 product_gift_items 表(商品赠品项)
+        // 迁移286: 创建 product_gift_items 表(商品赠品项)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_items (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -10143,7 +10233,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             // 表已存在,忽略
         }
 
-        // 迁移281: 创建 product_order_gifts 表(订单赠品记录)
+        // 迁移287: 创建 product_order_gifts 表(订单赠品记录)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_order_gifts (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java

@@ -0,0 +1,54 @@
+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 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;
+
+    /** 我的团队规模 + 当前返佣比例 + 档位名 */
+    @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));
+    }
+}

+ 93 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java

@@ -0,0 +1,93 @@
+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;
+import java.util.UUID;
+
+@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) {
+        Object toObj = params.get("toUserId");
+        Object amtObj = params.get("amount");
+        if (toObj == null || amtObj == null) {
+            return Result.error("参数不完整");
+        }
+        Long toUserId = ((Number) toObj).longValue();
+        int amount = ((Number) amtObj).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 = 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));
+    }
+}

+ 2 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -336,17 +336,8 @@ public class EnergyController {
             return Result.noFamily("请先创建或加入家庭");
         }
 
-        EnergySandboxDTO sandbox = energyService.calculateFamilyEnergy(user.getFamilyId());
-        if (sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
-            return Result.error("未找到成员能量数据");
-        }
-
-        MemberEnergyDTO result = sandbox.getMembers().stream()
-                .filter(m -> memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType()))
-                .findFirst()
-                .orElse(null);
-
-        if (result == null) return Result.error("未找到指定成员的能量数据");
+        MemberEnergyDTO result = energyService.findMemberEnergy(user.getFamilyId(), memberId, memberType);
+        // 成员暂无能量数据时返回空,避免前端把业务 miss 当成 500
         return Result.success(result);
     }
 }

+ 34 - 77
cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java

@@ -1,129 +1,86 @@
 package com.etotem.cfc.controller;
-import javax.annotation.Resource;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.TaskPlanInstance;
-import com.etotem.cfc.service.MembershipService;
-import com.etotem.cfc.service.PackagePaymentService;
-import com.etotem.cfc.service.TaskPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.Map;
-import com.etotem.cfc.util.ParamUtils;
 
-@Tag(name = "任务模板支付", description = "任务模板购买支付接口")
+/**
+ * 任务模板支付控制器(已废弃)
+ *
+ * 旧链路:任务模板通过 package_orders 独立下单支付。
+ * 现链路:所有套餐统一合并到「组合商品(bundle)」走 product_orders + ProductOrderService 购买支付。
+ * 本控制器前端已无调用,保留类壳返回 410,待确认无调用后清理。
+ */
+@Deprecated
+@Tag(name = "任务模板支付(已废弃)", description = "旧链路,已废弃,请走组合商品购买")
 @RestController
 @RequestMapping("/api/payment")
 public class PackagePaymentController {
 
-    @Resource
-    private PackagePaymentService packagePaymentService;
-
-    @Resource
-    private TaskPlanService planService;
-
-    @Resource
-    private MembershipService membershipService;
-
     /**
-     * 创建支付订单
+     * 创建支付订单(已废弃)
      */
-    @Operation(summary = "创建支付订单")
+    @Deprecated
+    @Operation(summary = "创建支付订单(已废弃)")
     @PostMapping("/create")
     public Result<PackageOrder> createOrder(
             @RequestAttribute("userId") Long userId,
             @RequestBody Map<String, Object> params) {
-        Long familyId = membershipService.getUserFamilyId(userId);
-        if (familyId == null) {
-            return Result.noFamily("请先创建或加入家庭");
-        }
-        Long packageId = ParamUtils.getLong(params.get("packageId"));
-        String payMethod = params.get("payMethod") != null ? params.get("payMethod").toString() : "wechat";
-        Long couponId = ParamUtils.getLong(params.get("couponId"));
-        
-        PackageOrder order = packagePaymentService.createOrder(userId, familyId, packageId, payMethod, couponId);
-        return Result.success(order);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 
     /**
-     * 获取支付参数 (微信JSAPI)
+     * 获取支付参数 (微信JSAPI)(已废弃)
      */
-    @Operation(summary = "获取微信支付参数")
-    @PostMapping("/wechat params")
+    @Deprecated
+    @Operation(summary = "获取微信支付参数(已废弃)")
+    @PostMapping("/wechat/params")
     public Result<Map<String, Object>> getWechatPayParams(@RequestBody Map<String, Object> params) {
-        Long orderId = ParamUtils.getLong(params.get("orderId"));
-        String openid = params.get("openid").toString();
-        
-        Map<String, Object> payParams = packagePaymentService.createWechatPayOrder(orderId, openid);
-        return Result.success(payParams);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 
     /**
-     * 微信支付回调
+     * 微信支付回调(已废弃)
      */
-    @Operation(summary = "微信支付回调")
+    @Deprecated
+    @Operation(summary = "微信支付回调(已废弃)")
     @PostMapping("/notify")
     public String handlePayNotify(@RequestBody String xmlData) {
-        boolean success = packagePaymentService.handlePayNotify(xmlData);
-        
-        if (success) {
-            return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>";
-        } else {
-            return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>";
-        }
+        return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[接口已废弃]]></return_msg></xml>";
     }
 
     /**
-     * 查询订单状态
+     * 查询订单状态(已废弃)
      */
-    @Operation(summary = "查询订单状态")
+    @Deprecated
+    @Operation(summary = "查询订单状态(已废弃)")
     @PostMapping("/orders/{orderId}")
     public Result<PackageOrder> getOrderStatus(@PathVariable Long orderId) {
-        PackageOrder order = packagePaymentService.getOrderById(orderId);
-        return Result.success(order);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 
     /**
-     * 支付成功后应用任务模板
+     * 支付成功后应用任务模板(已废弃)
      */
-    @Operation(summary = "支付成功后应用任务模板")
+    @Deprecated
+    @Operation(summary = "支付成功后应用任务模板(已废弃)")
     @PostMapping("/apply")
     public Result<TaskPlanInstance> applyAfterPayment(@RequestBody Map<String, Object> params) {
-        Long orderId = ParamUtils.getLong(params.get("orderId"));
-        Long memberId = ParamUtils.getLong(params.get("memberId"));
-        java.util.List<Long> selectedItemIds = null;
-        if (params.get("selectedItemIds") instanceof java.util.List) {
-            selectedItemIds = new java.util.ArrayList<>();
-            for (Object itemId : (java.util.List<?>) params.get("selectedItemIds")) {
-                selectedItemIds.add(Long.valueOf(itemId.toString()));
-            }
-        }
-        
-        PackageOrder order = packagePaymentService.getOrderById(orderId);
-        if (order == null || !"paid".equals(order.getStatus())) {
-            return Result.error("订单未支付");
-        }
-
-        // 应用任务模板
-        com.etotem.cfc.dto.ApplyPackageDTO dto = new com.etotem.cfc.dto.ApplyPackageDTO();
-        dto.setPackageId(order.getPackageId());
-        dto.setMemberId(memberId);
-        dto.setSelectedItemIds(selectedItemIds);
-        
-        TaskPlanInstance instance = planService.applyPackage(dto, order.getFamilyId());
-        return Result.success(instance);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 
     /**
-     * 取消订单
+     * 取消订单(已废弃)
      */
-    @Operation(summary = "取消订单")
+    @Deprecated
+    @Operation(summary = "取消订单(已废弃)")
     @PostMapping("/orders/{orderId}/cancel")
     public Result<Boolean> cancelOrder(@PathVariable Long orderId) {
-        boolean success = packagePaymentService.cancelOrder(orderId);
-        return Result.success(success);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 }

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -354,6 +354,24 @@ public class AdminArticleController {
         }
     }
 
+    @PostMapping("/upload/video")
+    public Result<String> uploadVideo(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件为空");
+        }
+        if (file.getSize() > 50L * 1024 * 1024) {
+            return Result.error("视频大小不能超过 50MB");
+        }
+        try {
+            String url = fileStorageService.storeVideo(file, "articles");
+            return Result.success(url);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
     @PostMapping("/comments/list")
     public Result<Page<Map<String, Object>>> commentList(@RequestBody Map<String, Object> body) {
         Long articleId = ParamUtils.getLong(body.get("articleId"));

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java

@@ -0,0 +1,52 @@
+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) {
+        Object idObj = params.get("id");
+        if (idObj == null) return Result.error("id 不能为空");
+        Long id = ((Number) idObj).longValue();
+        cfRateTierMapper.deleteById(id);
+        return Result.success("已删除");
+    }
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -1218,6 +1218,31 @@ public class AdminController {
         Page<MemberUpgradeRecord> pageParam = new Page<>(page, size);
         SortUtil.applySort(wrapper);
         Page<MemberUpgradeRecord> result = memberUpgradeRecordMapper.selectPage(pageParam, wrapper);
+
+        // 批量填充 userName(避免前端逐条调 /api/admin/user/search 造成 N+1)
+        List<MemberUpgradeRecord> records = result.getRecords();
+        if (records != null && !records.isEmpty()) {
+            List<Long> userIds = new ArrayList<>();
+            for (MemberUpgradeRecord r : records) {
+                if (r.getUserId() != null && !userIds.contains(r.getUserId())) {
+                    userIds.add(r.getUserId());
+                }
+            }
+            if (!userIds.isEmpty()) {
+                List<User> users = userMapper.selectBatchIds(userIds);
+                Map<Long, String> nameMap = new HashMap<>();
+                for (User u : users) {
+                    if (u != null && u.getId() != null) {
+                        String display = u.getNickname() != null ? u.getNickname()
+                                : (u.getRealName() != null ? u.getRealName() : ("用户" + u.getId()));
+                        nameMap.put(u.getId(), display);
+                    }
+                }
+                for (MemberUpgradeRecord r : records) {
+                    r.setUserName(nameMap.getOrDefault(r.getUserId(), "用户" + r.getUserId()));
+                }
+            }
+        }
         return Result.success(result);
     }
 

+ 8 - 18
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PackagePurchaseController.java

@@ -51,32 +51,22 @@ public class PackagePurchaseController {
     }
 
     /**
-     * 创建任务模板订单
+     * 创建任务模板订单(已废弃)
      */
-    @Operation(summary = "创建任务模板订单")
+    @Deprecated
+    @Operation(summary = "创建任务模板订单(已废弃)")
     @PostMapping("/order/create")
     public Result<PackageOrder> createOrder(@RequestBody PackagePurchaseDTO dto) {
-        PackageOrder order = packagePurchaseService.createOrder(dto);
-        
-        if (order == null) {
-            return Result.error("创建订单失败");
-        }
-        
-        return Result.success(order);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 
     /**
-     * 获取订单详情
+     * 获取订单详情(已废弃)
      */
-    @Operation(summary = "获取订单详情")
+    @Deprecated
+    @Operation(summary = "获取订单详情(已废弃)")
     @PostMapping("/order/{orderNo}")
     public Result<PackageOrder> getOrder(@PathVariable String orderNo) {
-        PackageOrder order = packagePurchaseService.getOrder(orderNo);
-        
-        if (order == null) {
-            return Result.error("订单不存在");
-        }
-        
-        return Result.success(order);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java

@@ -206,7 +206,8 @@ public class StatsController {
         Map<String, Object> data = new HashMap<>();
         try {
             CompletableFuture<Long> freeFuture = CompletableFuture.supplyAsync(() ->
-                userMapper.selectCount(new LambdaQueryWrapper<User>().or().eq(User::getMemberLevel, "FREE").isNull(User::getMemberLevel)), taskExecutor);
+                userMapper.selectCount(new LambdaQueryWrapper<User>()
+                    .and(w -> w.eq(User::getMemberLevel, "FREE").or().isNull(User::getMemberLevel))), taskExecutor);
             CompletableFuture<Long> familyFuture = CompletableFuture.supplyAsync(() ->
                 userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getMemberLevel, "FAMILY")), taskExecutor);
             CompletableFuture<Long> providerFuture = CompletableFuture.supplyAsync(() ->

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java

@@ -0,0 +1,22 @@
+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;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java

@@ -0,0 +1,24 @@
+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;
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MemberUpgradeRecord.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -38,4 +39,8 @@ public class MemberUpgradeRecord implements Serializable {
     private Date approveTime;  // 审批时间
 
     private Date createdAt;
+
+    /** 非表字段:冗余的用户昵称(列表展示用,由 Controller 批量填充) */
+    @TableField(exist = false)
+    private String userName;
 }

+ 2 - 10
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java

@@ -1,7 +1,6 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -20,21 +19,14 @@ public class PromotionTier implements Serializable {
 
     private String tier;
 
-    @TableField("team_size_1st")
-    private Integer teamSize1st;
-
-    @TableField("team_size_2nd")
-    private Integer teamSize2nd;
-
-    @TableField("team_size_3rd")
-    private Integer teamSize3rd;
-
     private Integer totalTeamSize;
 
     private Integer totalReferralEarnings;
 
     private Integer totalShareEarnings;
 
+    private Integer ratePercent;
+
     private Date promotedAt;
 
     private Date lastChangeAt;

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java

@@ -7,6 +7,10 @@ import lombok.Data;
 import java.io.Serializable;
 import java.util.Date;
 
+/**
+ * 已废弃,改用 cf_rate_tier
+ */
+@Deprecated
 @Data
 @TableName("promotion_tier_config")
 public class PromotionTierConfig implements Serializable {

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfRateTier;
+
+public interface CfRateTierMapper extends BaseMapper<CfRateTier> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfTransferRecord;
+
+public interface CfTransferRecordMapper extends BaseMapper<CfTransferRecord> {
+}

+ 11 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java

@@ -7,9 +7,10 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentAppointment;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
-import com.etotem.cfc.service.CommissionService;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -31,7 +32,10 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     private AssessmentAppointmentMapper assessmentAppointmentMapper;
 
     @Resource
-    private CommissionService commissionService;
+    private CfCommissionService cfCommissionService;
+
+    @Resource
+    private UserMapper userMapper;
 
     public AssessmentOrder createOrder(Long familyId, Long userId, Long memberId,
                                        Long guideId, Long packageId,
@@ -73,8 +77,11 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         order.setUpdatedAt(new Date());
         boolean updated = this.updateById(order);
         if (updated) {
-            commissionService.settle(order.getId(), "assessment", order.getUserId(),
-                    order.getActualPrice() != null ? order.getActualPrice().intValue() : null, null);
+            User buyer = userMapper.selectById(order.getUserId());
+            Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
+            cfCommissionService.settle(order.getId(), "assessment", order.getUserId(),
+                    buyerFamilyId,
+                    order.getActualPrice() != null ? order.getActualPrice().intValue() : 0, null);
         }
         return updated;
     }

+ 200 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -0,0 +1,200 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+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.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 统一 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) {
+                log.warn("计算当前人返CF失败: productId={}, err={}", productId, e.getMessage());
+                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;
+
+        Set<Long> processed = new HashSet<>();
+        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) {
+                log.warn("计算推荐人分润基数失败: productId={}, err={}", productId, e.getMessage());
+                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 e) {
+            log.warn("读取系统配置失败: key={}, err={}", key, e.getMessage());
+        }
+        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());
+        }
+    }
+}

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java

@@ -0,0 +1,89 @@
+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;
+
+    /**
+     * 绑定推荐关系时物化整条祖先链(不含本人,直接推荐人为 level 1)到 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(1 = 直接推荐人,越远 level 越大),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(i + 1);
+            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
+     * 无匹配返回 null。
+     */
+    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);
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -72,6 +72,9 @@ public class CommissionService {
     @Resource
     private PromotionTierConfigMapper configMapper;
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     // ==================== 工具方法 ====================
 
     /**
@@ -129,6 +132,9 @@ public class CommissionService {
         user.setReferrerId(referrer.getId());
         userMapper.updateById(user);
 
+        // 物化推荐树(全层级)
+        try { cfReferralService.materializeReferralTree(userId); } catch (Exception e) { log.warn("推荐树物化失败 userId={}", userId, e); }
+
         // 更新推荐人直接推荐人数
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         userMapper.updateById(referrer);
@@ -175,6 +181,7 @@ public class CommissionService {
     /**
      * 原有settle方法 - 兼容旧调用
      * 内部委托给settleTwoLevel实现L1+L2佣金
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      */
     @Deprecated
     public void settle(Long orderId, String orderType, Long buyerId,
@@ -184,6 +191,7 @@ public class CommissionService {
 
     /**
      * 两级佣金结算(L1+L2)
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      * @deprecated 佣金体系已废弃,由 CF 值(平台积分)替代。保留兼容旧调用方。
      * - L1: 买家的直接推荐人获得佣金
      * - L2: 如果L1推荐人也有推荐人,且订单类型为membership,L2获得佣金

+ 24 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -42,10 +42,11 @@ public class DietRecommendationService {
                .eq(DietRecommendation::getRecommendationDate, date)
                .in(DietRecommendation::getStatus, Arrays.asList("pending", "accepted", "completed"));
         
-        DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
+        List<DietRecommendation> records = dietRecommendationMapper.selectList(wrapper);
         
         Map<String, Object> result = new HashMap<>();
-        if (record != null) {
+        if (!records.isEmpty()) {
+            DietRecommendation record = records.get(0);
             result.put("id", record.getId());
             result.put("date", dateStr);
             result.put("mealType", record.getMealType());
@@ -96,7 +97,27 @@ public class DietRecommendationService {
         String normalizedMenuJson = safeWrite(normalized);
         Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
 
-        // 6. 保存推荐
+        // 6. 幂等保存:同日同 mealType 已存在则更新,否则新建
+        LambdaQueryWrapper<DietRecommendation> existWrapper = new LambdaQueryWrapper<>();
+        existWrapper.eq(DietRecommendation::getFamilyId, familyId)
+                    .eq(DietRecommendation::getRecommendationDate, java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()))
+                    .eq(DietRecommendation::getMealType, mealType != null ? mealType : "all");
+        DietRecommendation existRecord = dietRecommendationMapper.selectOne(existWrapper);
+        if (existRecord != null) {
+            existRecord.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
+            existRecord.setNutritionSummary(safeWrite(nutritionSummary));
+            existRecord.setParticipantMemberIds(participantIds.toString());
+            existRecord.setStatus("pending");
+            existRecord.setUpdatedAt(new Date());
+            dietRecommendationMapper.updateById(existRecord);
+            Map<String, Object> result = new HashMap<>();
+            result.put("id", existRecord.getId());
+            result.put("menu", existRecord.getMenuJson());
+            result.put("nutritionSummary", existRecord.getNutritionSummary());
+            result.put("message", "食谱推荐已更新");
+            return result;
+        }
+
         DietRecommendation record = new DietRecommendation();
         record.setFamilyId(familyId);
         record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -164,6 +164,42 @@ public class EnergyService {
         return dto;
     }
 
+    /**
+     * 在家庭沙盘中查找指定成员的能量数据。
+     * 匹配顺序:memberId+memberType → 仅 memberId → family_members.id 解析到的 userId(家长)。
+     */
+    public MemberEnergyDTO findMemberEnergy(Long familyId, Long memberId, String memberType) {
+        if (familyId == null || memberId == null) {
+            return null;
+        }
+        EnergySandboxDTO sandbox = calculateFamilyEnergy(familyId);
+        if (sandbox == null || sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
+            return null;
+        }
+        List<MemberEnergyDTO> members = sandbox.getMembers();
+        if (memberType != null && !memberType.isEmpty()) {
+            for (MemberEnergyDTO m : members) {
+                if (memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType())) {
+                    return m;
+                }
+            }
+        }
+        for (MemberEnergyDTO m : members) {
+            if (memberId.equals(m.getMemberId())) {
+                return m;
+            }
+        }
+        FamilyMember fm = familyMemberMapper.selectById(memberId);
+        if (fm != null && fm.getUserId() != null) {
+            for (MemberEnergyDTO m : members) {
+                if (fm.getUserId().equals(m.getMemberId()) && "parent".equals(m.getMemberType())) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
     /**
      * 计算当前用户个人能量沙盘(只含用户本人,不聚合家庭)
      */

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java

@@ -46,6 +46,24 @@ public class FileStorageService {
         return url;
     }
 
+    public String storeVideo(MultipartFile file, String subDir) {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("文件为空");
+        }
+        String contentType = file.getContentType();
+        String filename = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase();
+        boolean mimeOk = contentType != null && contentType.startsWith("video/");
+        boolean extOk = filename.endsWith(".mp4") || filename.endsWith(".webm")
+                || filename.endsWith(".ogg") || filename.endsWith(".mov")
+                || filename.endsWith(".m4v") || filename.endsWith(".avi");
+        if (!mimeOk && !extOk) {
+            throw new IllegalArgumentException("不支持的视频格式,请上传 mp4/webm/mov 等常见视频");
+        }
+        String url = storageService.storeFile(file, subDir);
+        log.info("视频已保存: original={}, url={}", file.getOriginalFilename(), url);
+        return url;
+    }
+
     public boolean delete(String url) {
         if (url == null || url.isEmpty()) {
             return false;

+ 15 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
 import com.etotem.cfc.entity.FamilyMember;
@@ -36,6 +37,9 @@ public class FoodRecommendService {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private FoodService foodService;
+
     @Transactional
     public void recalculateForUser(Long userId) {
         HealthReport report = healthReportService.getLatestReport(userId);
@@ -46,15 +50,13 @@ public class FoodRecommendService {
             return;
         }
 
-        List<ReportFoodSuitability> suitList = reportFoodSuitabilityMapper.selectList(
-                new LambdaQueryWrapper<ReportFoodSuitability>()
-                        .eq(ReportFoodSuitability::getReportId, report.getId())
-        );
+        // 从 foods 表读取最新报告关联的食材推荐(正常确认流程写入 foods 表)
+        List<Food> reportFoods = foodService.listByReportId(report.getId());
 
-        if (suitList.isEmpty()) {
+        if (reportFoods.isEmpty()) {
             foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
                     .eq(FoodRecommendIndex::getUserId, userId));
-            log.info("报告{}无食材适宜度数据,已清除用户{}的推荐指数", report.getId(), userId);
+            log.info("报告{}无食材推荐数据,已清除用户{}的推荐指数", report.getId(), userId);
             return;
         }
 
@@ -69,15 +71,17 @@ public class FoodRecommendService {
         Date now = new Date();
         Long reportId = report.getId();
 
-        for (ReportFoodSuitability suit : suitList) {
-            Long foodId = suit.getFoodId();
-            Integer newIndex = suit.getScore();
+        for (Food food : reportFoods) {
+            Long foodId = food.getId();
+            Integer newIndex = food.getScore();
+            String foodName = food.getName();
             FoodRecommendIndex existing = existingMap.get(foodId);
 
             if (existing != null) {
                 if (!Objects.equals(existing.getIndexScore(), newIndex)) {
                     Integer oldIndex = existing.getIndexScore();
                     existing.setIndexScore(newIndex);
+                    existing.setFoodName(foodName);
                     existing.setHealthReportId(reportId);
                     existing.setCalculatedAt(now);
                     foodRecommendIndexMapper.updateById(existing);
@@ -85,7 +89,7 @@ public class FoodRecommendService {
                     FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
                     logEntry.setUserId(userId);
                     logEntry.setFoodId(foodId);
-                    logEntry.setFoodName(suit.getFoodName());
+                    logEntry.setFoodName(foodName);
                     logEntry.setPreviousIndex(oldIndex);
                     logEntry.setNewIndex(newIndex);
                     logEntry.setHealthReportId(reportId);
@@ -99,7 +103,7 @@ public class FoodRecommendService {
                 FoodRecommendIndex index = new FoodRecommendIndex();
                 index.setUserId(userId);
                 index.setFoodId(foodId);
-                index.setFoodName(suit.getFoodName());
+                index.setFoodName(foodName);
                 index.setIndexScore(newIndex);
                 index.setHealthReportId(reportId);
                 index.setCalculatedAt(now);

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -32,7 +32,7 @@ public class MemberSubscriptionService {
     private FamilyMapper familyMapper;
 
     @Resource
-    private CommissionService commissionService;
+    private CfCommissionService cfCommissionService;
 
     @Resource
     private PromotionTierService promotionTierService;
@@ -171,7 +171,8 @@ public class MemberSubscriptionService {
 
         Family family = familyMapper.selectById(familyId);
         if (family != null && family.getCreatorId() != null) {
-            commissionService.settleTwoLevel(order.getId(), "subscription", family.getCreatorId(), amount, null);
+            cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
+                    family.getId(), amount);
 
             User buyer = userMapper.selectById(family.getCreatorId());
             if (buyer != null && buyer.getReferrerId() != null) {
@@ -252,7 +253,8 @@ public class MemberSubscriptionService {
 
         Family family = familyMapper.selectById(familyId);
         if (family != null && family.getCreatorId() != null) {
-            commissionService.settleTwoLevel(order.getId(), "subscription", family.getCreatorId(), amount, null);
+            cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
+                    family.getId(), amount);
 
             User buyer = userMapper.selectById(family.getCreatorId());
             if (buyer != null && buyer.getReferrerId() != null) {

+ 5 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -43,7 +43,7 @@ public class MembershipService implements MembershipServiceInterface {
     private PaymentOrderMapper paymentOrderMapper;
 
     @Resource
-    private CommissionService commissionService;
+    private CfCommissionService cfCommissionService;
 
     @Resource
     private SysConfigService sysConfigService;
@@ -780,14 +780,10 @@ public class MembershipService implements MembershipServiceInterface {
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
             }
 
-            // 结算佣金(L1+L2)
-            commissionService.settleTwoLevel(
-                    order.getId(),
-                    "membership",
-                    adminUserId,
-                    order.getAmount(),
-                    null
-            );
+            // 结算佣金(推荐人分润)
+            User buyer = userMapper.selectById(adminUserId);
+            Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
+            cfCommissionService.settleReferrerOnly(order.getId(), "membership", adminUserId, buyerFamilyId, order.getAmount());
 
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);

+ 0 - 341
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java

@@ -1,341 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.service.api.PackagePaymentServiceInterface;
-import javax.annotation.Resource;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONObject;
-import com.etotem.cfc.common.OrderNoGenerator;
-import com.etotem.cfc.entity.PackageOrder;
-import com.etotem.cfc.entity.TaskTemplatePackage;
-import com.etotem.cfc.entity.UserIntent;
-import com.etotem.cfc.mapper.PackageOrderMapper;
-import com.etotem.cfc.mapper.UserIntentMapper;
-import com.etotem.cfc.service.CommissionService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.ResponseEntity;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
-
-import java.util.Date;
-import java.util.Map;
-import java.util.UUID;
-
-@Service
-public class PackagePaymentService implements PackagePaymentServiceInterface {
-
-    private static final Logger log = LoggerFactory.getLogger(PackagePaymentService.class);
-
-    @Resource
-    private PackageOrderMapper orderMapper;
-
-    @Resource
-    private TaskTemplatePackageService packageService;
-
-    @Resource
-    private CommissionService commissionService;
-
-    @Resource
-    private CouponService couponService;
-
-    @Resource
-    private UserIntentMapper userIntentMapper;
-
-    @Value("${wechat.appid}")
-    private String appid;
-
-    @Value("${wechat.mch-id}")
-    private String mchId;
-
-    @Value("${wechat.mch-key}")
-    private String mchKey;
-
-    @Value("${wechat.notify-url}")
-    private String notifyUrl;
-
-    private final RestTemplate restTemplate = new RestTemplate();
-
-    /**
-     * 创建支付订单
-     */
-    public PackageOrder createOrder(Long userId, Long familyId, Long packageId, String payMethod, Long userCouponId) {
-        // 获取任务模板信息
-        TaskTemplatePackage pkg = packageService.getPackageById(packageId);
-        if (pkg == null) {
-            throw new RuntimeException("任务模板不存在");
-        }
-
-        // 如果任务模板免费,直接创建并返回
-        if (pkg.getPrice() == null || pkg.getPrice() <= 0) {
-            PackageOrder order = new PackageOrder();
-            order.setOrderNo(generateOrderNo());
-            order.setUserId(userId);
-            order.setFamilyId(familyId);
-            order.setPackageId(packageId);
-            order.setPackageName(pkg.getName());
-            order.setPrice(0);
-            order.setPlatformFee(0);
-            order.setGuideId(pkg.getCreatorId());
-            order.setStatus("paid");
-            order.setPayMethod("free");
-            order.setPaidAt(new Date());
-            order.setCreatedAt(new Date());
-            order.setUpdatedAt(new Date());
-            orderMapper.insert(order);
-            // 购买完成 → 推进旅程 stage=3(幂等)
-            advanceStage(userId, 3);
-            return order;
-        }
-
-        int originalPrice = pkg.getPrice();
-        int finalPrice = originalPrice;
-        Long appliedCouponId = null;
-        if (userCouponId != null) {
-            Integer discount = couponService.apply(userId, userCouponId, "PRODUCT", originalPrice);
-            if (discount != null && discount > 0) {
-                finalPrice = Math.max(0, originalPrice - discount);
-                appliedCouponId = userCouponId;
-            }
-        }
-
-        // 创建付费订单
-        PackageOrder order = new PackageOrder();
-        order.setOrderNo(generateOrderNo());
-        order.setUserId(userId);
-        order.setFamilyId(familyId);
-        order.setPackageId(packageId);
-        order.setPackageName(pkg.getName());
-        order.setPrice(finalPrice);
-        order.setPlatformFee(pkg.getPlatformFee());
-        order.setUserCouponId(appliedCouponId);
-        order.setGuideId(pkg.getCreatorId());
-        order.setStatus("pending");
-        order.setPayMethod(payMethod);
-        order.setCreatedAt(new Date());
-        order.setUpdatedAt(new Date());
-        
-        orderMapper.insert(order);
-        
-        return order;
-    }
-
-    /**
-     * 发起微信支付 (JSAPI)
-     */
-    public Map<String, Object> createWechatPayOrder(Long orderId, String openid) {
-        PackageOrder order = orderMapper.selectById(orderId);
-        if (order == null) {
-            throw new RuntimeException("订单不存在");
-        }
-
-        // 调用微信统一下单接口
-        String url = "https://api.mch.weixin.qq.com/pay/unifiedorder";
-        
-        JSONObject params = new JSONObject();
-        params.put("appid", appid);
-        params.put("mch_id", mchId);
-        params.put("nonce_str", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
-        params.put("body", "浠艾福-" + order.getPackageName());
-        params.put("out_trade_no", order.getOrderNo());
-        params.put("total_fee", order.getPrice() * 100);
-        params.put("spbill_create_ip", "127.0.0.1");
-        params.put("notify_url", notifyUrl);
-        params.put("trade_type", "JSAPI");
-        params.put("openid", openid);
-
-        // 生成签名
-        String sign = generateSign(params, mchKey);
-        params.put("sign", sign);
-
-        try {
-            ResponseEntity<String> response = restTemplate.postForEntity(url, params.toJSONString(), String.class);
-            JSONObject result = JSON.parseObject(response.getBody());
-
-            if ("SUCCESS".equals(result.getString("return_code"))) {
-                // 返回预支付订单信息给前端
-                JSONObject payParams = new JSONObject();
-                payParams.put("orderId", orderId);
-                payParams.put("prepay_id", result.getString("prepay_id"));
-                payParams.put("nonce_str", params.getString("nonce_str"));
-                
-                // 生成前端签名
-                String paySign = generatePaySign(payParams, mchKey);
-                payParams.put("sign", paySign);
-
-                return payParams;
-            } else {
-                log.error("微信下单失败: {}", result.toJSONString());
-                throw new RuntimeException("微信下单失败");
-            }
-        } catch (Exception e) {
-            log.error("微信支付异常", e);
-            throw new RuntimeException("支付异常: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 处理微信支付回调
-     */
-    public boolean handlePayNotify(String xmlData) {
-        try {
-            // 解析XML
-            JSONObject params = parseXmlToJson(xmlData);
-            
-            if (!"SUCCESS".equals(params.getString("return_code"))) {
-                log.error("支付回调失败: {}", params.getString("return_msg"));
-                return false;
-            }
-
-            // 验签
-            String sign = params.getString("sign");
-            params.remove("sign");
-            String calculatedSign = generateSign(params, mchKey);
-            
-            if (!sign.equals(calculatedSign)) {
-                log.error("签名验证失败");
-                return false;
-            }
-
-            // 更新订单状态
-            String orderNo = params.getString("out_trade_no");
-            PackageOrder order = orderMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<PackageOrder>()
-                    .eq("order_no", orderNo)
-            );
-            
-            if (order != null) {
-                order.setStatus("paid");
-                order.setTransactionId(params.getString("transaction_id"));
-                order.setPaidAt(new Date());
-                order.setNotifyData(xmlData);
-                order.setUpdatedAt(new Date());
-                orderMapper.updateById(order);
-
-                if (order.getUserCouponId() != null) {
-                    couponService.markUsed(order.getUserCouponId(), order.getId());
-                }
-
-                commissionService.settle(order.getId(), "package", order.getUserId(),
-                        order.getPrice(), null);
-
-                // 购买完成 → 推进旅程 stage=3(幂等)
-                advanceStage(order.getUserId(), 3);
-
-                log.info("订单支付成功: {}", orderNo);
-                return true;
-            }
-            
-            return false;
-        } catch (Exception e) {
-            log.error("处理支付回调异常", e);
-            return false;
-        }
-    }
-
-    /**
-     * 查询订单状态
-     */
-    public PackageOrder getOrderById(Long orderId) {
-        return orderMapper.selectById(orderId);
-    }
-
-    /**
-     * 取消订单
-     */
-    public boolean cancelOrder(Long orderId) {
-        PackageOrder order = new PackageOrder();
-        order.setId(orderId);
-        order.setStatus("cancelled");
-        order.setUpdatedAt(new Date());
-        return orderMapper.updateById(order) > 0;
-    }
-
-    /**
-     * 生成订单号
-     */
-    private String generateOrderNo() {
-        return OrderNoGenerator.generate("PKG");
-    }
-
-    /** 推进用户旅程至指定阶段(幂等:只向前推进) */
-    private void advanceStage(Long userId, int stage) {
-        try {
-            UserIntent intent = userIntentMapper.selectOne(
-                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserIntent>()
-                            .eq(UserIntent::getUserId, userId)
-                            .last("LIMIT 1"));
-            if (intent != null && (intent.getJourneyStage() == null || intent.getJourneyStage() < stage)) {
-                intent.setJourneyStage(stage);
-                intent.setStageUpdatedAt(new Date());
-                intent.setUpdatedAt(new Date());
-                userIntentMapper.updateById(intent);
-            }
-        } catch (Exception e) {
-            log.warn("推进旅程阶段失败 userId={}, stage={}: {}", userId, stage, e.getMessage());
-        }
-    }
-
-    /**
-     * 生成签名
-     */
-    private String generateSign(JSONObject params, String mchKey) {
-        StringBuilder sb = new StringBuilder();
-        params.keySet().stream().sorted().forEach(key -> {
-            if (params.getString(key) != null && !params.getString(key).isEmpty()) {
-                sb.append(key).append("=").append(params.getString(key)).append("&");
-            }
-        });
-        sb.append("key=").append(mchKey);
-        
-        // MD5加密并转大写
-        try {
-            java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
-            byte[] digest = md.digest(sb.toString().getBytes("UTF-8"));
-            StringBuilder hex = new StringBuilder();
-            for (byte b : digest) {
-                hex.append(String.format("%02x", b));
-            }
-            return hex.toString().toUpperCase();
-        } catch (Exception e) {
-            throw new RuntimeException("签名失败", e);
-        }
-    }
-
-    /**
-     * 生成前端支付签名
-     */
-    private String generatePaySign(JSONObject params, String mchKey) {
-        StringBuilder sb = new StringBuilder();
-        sb.append("appId=").append(appid).append("&");
-        sb.append("nonceStr=").append(params.getString("nonce_str")).append("&");
-        sb.append("package=prepay_id=").append(params.getString("prepay_id")).append("&");
-        sb.append("signType=MD5&");
-        sb.append("timeStamp=").append(System.currentTimeMillis() / 1000).append("&");
-        sb.append("key=").append(mchKey);
-        
-        try {
-            java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
-            byte[] digest = md.digest(sb.toString().getBytes("UTF-8"));
-            StringBuilder hex = new StringBuilder();
-            for (byte b : digest) {
-                hex.append(String.format("%02x", b));
-            }
-            return hex.toString().toUpperCase();
-        } catch (Exception e) {
-            throw new RuntimeException("签名失败", e);
-        }
-    }
-
-    /**
-     * 解析XML为JSON
-     */
-    private JSONObject parseXmlToJson(String xml) {
-        JSONObject json = new JSONObject();
-        // 简单XML解析
-        xml = xml.replace("<", "{").replace(">", ":").replace("</", "}").replace("><", ",");
-        // 这里简化处理,实际应该使用XML解析器
-        return json;
-    }
-}

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

@@ -57,6 +57,9 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Lazy
     @Resource
     private ProductOrderService productOrderService;
@@ -237,8 +240,8 @@ public class PaymentService implements PaymentServiceInterface {
         order.setUpdatedAt(new Date());
         boolean updated = packageOrderMapper.updateById(order) > 0;
         if (updated) {
-            commissionService.settle(order.getId(), "package", order.getUserId(),
-                    order.getPrice(), null);
+            cfCommissionService.settle(order.getId(), "package", order.getUserId(),
+                    order.getFamilyId(), order.getPrice(), null);
         }
         return updated;
     }

+ 43 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java

@@ -613,21 +613,50 @@ public ParsedReportResult parseText(String fullText) {
         return null;
     }
 
-    private String findReportNumber(List<String> lines) {
-        String value = findTextAfter(lines, "报告编号:");
-        if (value != null && !value.isEmpty()) return value.trim();
+    /**
+     * 读取标签对应的值。兼容募极报告封面常见排版:
+     * 1) 标签与值分行、无冒号(「姓名」下一行「马智强」)
+     * 2) 标签带半角/全角冒号且值在下一行(「姓名:」)
+     * 3) 标签与值同行(「姓名:马智强」)
+     */
+    private String findLabeledValue(List<String> lines, String... labels) {
         for (int i = 0; i < lines.size(); i++) {
             String line = lines.get(i).trim();
-            if (line.equals("编号:") && i + 1 < lines.size()) {
-                String next = lines.get(i + 1).trim();
-                if (!next.isEmpty() && next.length() <= 20) {
-                    return next;
+            for (String label : labels) {
+                String ascii = label + ":";
+                String fullwidth = label + "\uFF1A";
+                if (line.equals(label) || line.equals(ascii) || line.equals(fullwidth)) {
+                    if (i + 1 < lines.size()) {
+                        String next = lines.get(i + 1).trim();
+                        if (!next.isEmpty()) {
+                            return next;
+                        }
+                    }
+                } else if (line.startsWith(ascii) || line.startsWith(fullwidth)) {
+                    int colonIdx = line.indexOf(':');
+                    if (colonIdx < 0) {
+                        colonIdx = line.indexOf('\uFF1A');
+                    }
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        String val = line.substring(colonIdx + 1).trim();
+                        if (!val.isEmpty()) {
+                            return val;
+                        }
+                    }
                 }
             }
         }
         return null;
     }
 
+    private String findReportNumber(List<String> lines) {
+        String value = findLabeledValue(lines, "报告编号", "检测编号", "编号");
+        if (value != null && !value.isEmpty() && value.length() <= 30) {
+            return value.trim();
+        }
+        return null;
+    }
+
     private String findReportDate(List<String> lines) {
         String[] keywords = {"采样日期:", "报告日期:", "检测日期:"};
         for (String keyword : keywords) {
@@ -651,15 +680,13 @@ public ParsedReportResult parseText(String fullText) {
     }
 
     private String findPersonName(List<String> lines) {
-        String[] keywords = {"姓名:", "宝宝姓名:", "受检人姓名:"};
-        for (String keyword : keywords) {
-            String value = findTextAfter(lines, keyword);
-            if (value != null && !value.isEmpty()) {
-                String name = value.trim();
-                if (name.length() <= 20 && !name.matches(".*[0-9]{5,}.*")) {
-                    return name;
-                }
-            }
+        String value = findLabeledValue(lines, "受检人姓名", "宝宝姓名", "姓名");
+        if (value == null || value.isEmpty()) {
+            return null;
+        }
+        String name = value.trim().replaceAll("(编号|年龄|性别|备注|肠道).*", "").trim();
+        if (name.length() >= 2 && name.length() <= 20 && !name.matches(".*[0-9]{5,}.*")) {
+            return name;
         }
         return null;
     }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java

@@ -74,6 +74,17 @@ public class PlatformPointsService {
         if (amount <= 0) {
             throw new IllegalArgumentException("CF值数量必须为正数");
         }
+        // 幂等:按 (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;
+            }
+        }
         UserPlatformBalance b = getOrCreate(userId);
         b.setTotalEarned(safeInt(b.getTotalEarned()) + amount);
         b.setAvailable(safeInt(b.getAvailable()) + amount);

+ 15 - 36
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -21,6 +21,7 @@ import com.etotem.cfc.entity.ProductServicePerson;
 import com.etotem.cfc.entity.ProductServicePersonSlot;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
+import com.etotem.cfc.service.PlatformPointsService;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
@@ -32,10 +33,10 @@ import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductSku;
 import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.service.ProductSkuService;
-import com.etotem.cfc.service.CommissionService;
 import com.etotem.cfc.service.AssessmentProductService;
 import com.etotem.cfc.service.AssessmentQuotaService;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
@@ -88,7 +89,7 @@ public class ProductOrderService {
     private ProductBundleItemMapper bundleItemMapper;
 
     @Resource
-    private CommissionService commissionService;
+    private CfCommissionService cfCommissionService;
 
     @Resource
     private PaymentService paymentService;
@@ -120,20 +121,11 @@ public class ProductOrderService {
     @Resource
     private SupplySystemMemberMapper supplySystemMemberMapper;
 
-    @Resource
-    private PpointConfigService ppointConfigService;
-
-    @Resource
-    private CommissionDistService commissionDistService;
-
-    @Resource
-    private PlatformPointsService platformPointsService;
-
     @Resource
     private FamilyPlatformPointsService familyPlatformPointsService;
 
     @Resource
-    private SysConfigService sysConfigService;
+    private PlatformPointsService platformPointsService;
 
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
@@ -649,6 +641,12 @@ public class ProductOrderService {
         return Result.success(result);
     }
 
+    /**
+     * 支付成功处理:扣减库存(physical 单商品 / bundle 穿透子商品)+ 更新订单状态
+     * 事务保证 bundle 多子商品扣减的原子性——任一子商品库存不足时整体回滚,
+     * 避免部分出库(已扣子商品库存 + 已写 outbound 流水残留)
+     */
+    @Transactional(rollbackFor = Exception.class)
     public Result<String> handlePaymentSuccess(String orderNo, String transactionId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -703,32 +701,13 @@ public class ProductOrderService {
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
 
-        // CF值:购买者本人按分润公式获得
-        try {
-            Product p = productMapper.selectById(order.getProductId());
-            if (p != null) {
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(p.getId(), p.getCategoryId());
-                if (effectivePpoint > 0) {
-                    String shareStr = sysConfigService.getValue("product_platform_points_share");
-                    int shareBps = shareStr != null && !shareStr.isEmpty()
-                            ? Integer.parseInt(shareStr) : 1000;
-                    int cfValue = order.getTotalAmount() * effectivePpoint / 100 * shareBps / 10000;
-                    if (cfValue > 0) {
-                        platformPointsService.earn(order.getBuyerId(), cfValue,
-                                "product_order", order.getId(),
-                                "购买商品: " + order.getProductName());
-                    }
-                }
-            }
-        } catch (Exception e) {
-            log.error("购买者CF值发放失败: orderId={}, error={}", order.getId(), e.getMessage());
-        }
-
-        // CF值:推荐人按推荐链分润
+        // CF值:统一分佣(当前人 + 推荐人)
         try {
-            commissionDistService.distribute(order.getBuyerId(), order.getId(), order.getProductId());
+            cfCommissionService.settle(order.getId(), "product", order.getBuyerId(),
+                    userMapper.selectById(order.getBuyerId()).getFamilyId(),
+                    order.getTotalAmount(), order.getProductId());
         } catch (Exception e) {
-            log.error("推荐人分润失败: orderId={}, error={}", order.getId(), e.getMessage());
+            log.error("CF值分佣失败: orderId={}, error={}", order.getId(), e.getMessage());
         }
 
         // 原佣金结算标记为废弃,保留兼容

+ 12 - 42
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierEvalService.java

@@ -1,10 +1,8 @@
 package com.etotem.cfc.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.PromotionTier;
-import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
@@ -27,15 +25,15 @@ public class PromotionTierEvalService {
     @Resource
     private PromotionTierMapper tierMapper;
 
-    @Resource
-    private PromotionTierConfigMapper configMapper;
-
     @Resource
     private PromotionTierService promotionTierService;
 
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     @Resource
     private WechatService wechatService;
 
@@ -43,45 +41,17 @@ public class PromotionTierEvalService {
     private String promotionTierTemplateId;
 
     /**
-     * 获取所有启用且按sortOrder desc排序的等级配置
-     */
-    public List<PromotionTierConfig> getEnabledConfigs() {
-        return configMapper.selectList(
-            new LambdaQueryWrapper<PromotionTierConfig>()
-                .eq(PromotionTierConfig::getEnabled, 1)
-                .orderByDesc(PromotionTierConfig::getSortOrder)
-        );
-    }
-
-    /**
-     * 根据用户的 PromotionTier 数据,计算应该属于哪个等级
-     * 遍历配置,从高到低找到第一个满足所有条件的等级
-     * @return 目标等级代码,如 "R2",如果没有任何配置匹配返回 "R0"
+     * 根据全层级团队规模计算应属等级(读 cf_rate_tier)
+     * @return 目标等级代码,如 "R2",无匹配返回 "R0"
      */
     public String evaluateTier(Long userId) {
-        PromotionTier tier = promotionTierService.getCurrentTier(userId);
-        List<PromotionTierConfig> configs = getEnabledConfigs();
-
-        int teamSize = tier != null && tier.getTotalTeamSize() != null ? tier.getTotalTeamSize() : 0;
-        int referralEarnings = tier != null && tier.getTotalReferralEarnings() != null ? tier.getTotalReferralEarnings() : 0;
-        int shareEarnings = tier != null && tier.getTotalShareEarnings() != null ? tier.getTotalShareEarnings() : 0;
-
-        // 从高等级到低等级遍历,找到第一个满足所有条件的
-        // configs 已按 sortOrder desc 排序(高等级在前)
-        for (PromotionTierConfig config : configs) {
-            boolean teamSizeOk = teamSize >= config.getMinTeamSize();
-            boolean referralOk = config.getMinReferralEarnings() == null
-                || config.getMinReferralEarnings() == 0
-                || referralEarnings >= config.getMinReferralEarnings();
-            boolean shareOk = config.getMinShareEarnings() == null
-                || config.getMinShareEarnings() == 0
-                || shareEarnings >= config.getMinShareEarnings();
-
-            if (teamSizeOk && referralOk && shareOk) {
-                return config.getTierCode();
-            }
+        try {
+            int teamSize = cfReferralService.getTotalTeamSize(userId);
+            CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+            return tier == null ? "R0" : tier.getTierName();
+        } catch (Exception e) {
+            return "R0";
         }
-        return "R0";
     }
 
     /**

+ 35 - 21
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java

@@ -1,12 +1,15 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTierChangeLog;
 import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.mapper.PromotionTierChangeLogMapper;
 import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -16,6 +19,8 @@ import java.util.List;
 @Service
 public class PromotionTierService {
 
+    private static final Logger log = LoggerFactory.getLogger(PromotionTierService.class);
+
     @Resource
     private PromotionTierMapper tierMapper;
 
@@ -25,6 +30,9 @@ public class PromotionTierService {
     @Resource
     private PromotionTierConfigMapper tierConfigMapper;
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     /**
      * 获取用户当前推广等级
      */
@@ -59,17 +67,14 @@ public class PromotionTierService {
     }
 
     /**
-     * 更新团队规模(当有新推荐人加入时调用)
+     * 团队规模 +delta(新推荐绑定后对所有祖先增量)。level 参数已废弃(改为全层级)。
      */
-    public void updateTeamSize(Long userId, int level, int delta) {
+    public void updateTeamSize(Long userId, int delta) {
         PromotionTier tier = getCurrentTier(userId);
         if (tier == null) {
             tier = new PromotionTier();
             tier.setUserId(userId);
             tier.setTier("R0");
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalShareEarnings(0);
@@ -77,22 +82,34 @@ public class PromotionTierService {
             tier.setUpdatedAt(new Date());
             tierMapper.insert(tier);
         }
-
-        if (level == 1) {
-            tier.setTeamSize1st(tier.getTeamSize1st() + delta);
-        } else if (level == 2) {
-            tier.setTeamSize2nd(tier.getTeamSize2nd() + delta);
-        } else if (level == 3) {
-            tier.setTeamSize3rd(tier.getTeamSize3rd() + delta);
-        }
-        tier.setTotalTeamSize(
-                (tier.getTeamSize1st() != null ? tier.getTeamSize1st() : 0) +
-                (tier.getTeamSize2nd() != null ? tier.getTeamSize2nd() : 0) +
-                (tier.getTeamSize3rd() != null ? tier.getTeamSize3rd() : 0)
-        );
+        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.setTier("R0");
+                pt.setRatePercent(0);
+            }
+            pt.setUpdatedAt(new Date());
+            tierMapper.updateById(pt);
+        } catch (Exception e) {
+            log.warn("refreshRate失败 userId={}", userId, e);
+        }
     }
 
     /**
@@ -153,9 +170,6 @@ public class PromotionTierService {
             tier = new PromotionTier();
             tier.setUserId(userId);
             tier.setTier(tierCode);
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalShareEarnings(0);

+ 0 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/api/PackagePaymentServiceInterface.java

@@ -1,13 +0,0 @@
-package com.etotem.cfc.service.api;
-
-import com.etotem.cfc.entity.PackageOrder;
-
-import java.util.Map;
-
-public interface PackagePaymentServiceInterface {
-    PackageOrder createOrder(Long userId, Long familyId, Long packageId, String payMethod, Long userCouponId);
-    Map<String, Object> createWechatPayOrder(Long orderId, String openid);
-    boolean handlePayNotify(String xmlData);
-    PackageOrder getOrderById(Long orderId);
-    boolean cancelOrder(Long orderId);
-}

+ 16 - 7
cfc-backend/src/main/java/com/etotem/cfc/task/PromotionTierCheckScheduledTask.java

@@ -1,31 +1,40 @@
 package com.etotem.cfc.task;
 
-import com.etotem.cfc.service.PromotionTierEvalService;
+import com.etotem.cfc.entity.PromotionTier;
+import com.etotem.cfc.mapper.PromotionTierMapper;
+import com.etotem.cfc.service.PromotionTierService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
+import java.util.List;
 
 @Slf4j
 @Component
 public class PromotionTierCheckScheduledTask {
 
     @Resource
-    private PromotionTierEvalService evalService;
+    private PromotionTierService promotionTierService;
+
+    @Resource
+    private PromotionTierMapper promotionTierMapper;
 
     /**
      * 每天 1:00 AM 执行
-     * 遍历所有有推广等级记录的用户,检查是否需要晋级/降级
+     * 遍历所有有推广等级记录的用户,按全层级团队规模刷新等级与返佣比例
      */
     @Scheduled(cron = "0 0 1 * * ?")
     public void checkAndUpdateAllTiers() {
-        log.info("开始执行推广等级定时检查任务");
+        log.info("开始执行推广等级定时刷新任务");
         try {
-            int changed = evalService.evaluateAllUsers();
-            log.info("推广等级定时检查任务执行完成,变更{}个用户等级", changed);
+            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);
+            log.error("推广等级定时刷新失败", e);
         }
     }
 }

+ 38 - 4
cfc-backend/src/main/resources/schema.sql

@@ -2171,6 +2171,7 @@ CREATE TABLE IF NOT EXISTS coupon (
     used_count INT DEFAULT 0,
     discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
     product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
+    family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)',
     points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
     grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
     grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
@@ -2771,12 +2772,10 @@ CREATE TABLE IF NOT EXISTS promotion_tier (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id BIGINT NOT NULL COMMENT '用户ID',
     tier VARCHAR(20) NOT NULL DEFAULT 'R0' COMMENT 'R0利他者/R1传福人/R2聚能师/R3启慧者/R4传承者',
-    team_size_1st INT DEFAULT 0 COMMENT '一层团队人数',
-    team_size_2nd INT DEFAULT 0 COMMENT '二层团队人数',
-    team_size_3rd INT DEFAULT 0 COMMENT '三层团队人数',
     total_team_size INT DEFAULT 0 COMMENT '三层累计总人数',
     total_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)',
     total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)',
+    rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)',
     promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间',
     last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -2804,7 +2803,7 @@ CREATE TABLE IF NOT EXISTS referral_tree (
     level INT NOT NULL COMMENT '层级深度(1/2/3)',
     path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-    UNIQUE KEY uk_child_id (child_id),
+    UNIQUE KEY uk_parent_child (parent_id, child_id),
     INDEX idx_parent_id (parent_id),
     INDEX idx_level (level)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐关系树';
@@ -5349,3 +5348,38 @@ CREATE TABLE IF NOT EXISTS self_check_ignores (
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间',
     INDEX idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录';
+
+-- ============================================================
+-- CF值分佣体系重构(迁移279-282)
+-- ============================================================
+
+-- CF值返佣阶梯配置表(迁移279,替代 promotion_tier_config)
+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值返佣阶梯配置';
+
+-- CF值流转记录表(迁移280)
+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),
+    UNIQUE KEY uk_ref_type_id (ref_type, ref_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录';

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

@@ -53,7 +53,6 @@ public class FiveDimensionSelfCheckServiceTest {
         setField(service, "familyMemberMapper", familyMemberMapper);
 
         // 默认 mock:全部维度健康,无寻源建议
-        when(wuxingSourcingService.getAdvicesForLowScores(anyMap())).thenReturn(new ArrayList<>());
         when(wuxingSourcingService.levelOf(anyInt())).thenReturn("healthy");
         when(wuxingSourcingService.levelName(anyString())).thenReturn("健康");
     }

+ 13 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/service/PdfParseServiceTest.java

@@ -56,6 +56,19 @@ public class PdfParseServiceTest {
         assertEquals("AKK型", result.getGutType());
     }
 
+    @Test
+    public void parseText_mujiCoverWithoutColon_extractsPersonName() {
+        // 募极封面实际排版:标签与值分行且无冒号;「马」常被抽成康熙部首 ⻢ (U+2EE2)
+        String text = "报告生成:2026-08-01\n姓名\n\u2ee2智强\n编号\n503458261\n年龄\n43岁\n备注\n性别\n男\n"
+                + "肠道预测年龄:\n48.70岁\n肠型:\n拟杆菌型\n健康总分\n58\n菌群健康\n67";
+        ParsedReportResult result = service.parseText(text);
+        assertEquals("马智强", result.getPersonName());
+        assertEquals("503458261", result.getReportNumber());
+        assertEquals(Integer.valueOf(43), result.getAge());
+        assertEquals("male", result.getGender());
+        assertEquals(Integer.valueOf(58), result.getOverallScore());
+    }
+
     @Test
     public void parseText_withDiseaseRiskSection_parsesCorrectly() {
         // 模拟疾病风险表格格式

+ 119 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * CF 值分佣阶梯匹配边界测试。
+ *
+ * 通过 Mockito 注入 mock 的 CfRateTierMapper 到生产方法所在的 CfReferralService,
+ * 调用真实的 CfReferralService.matchRateTier(int) 验证匹配语义,而非复刻一份逻辑。
+ *
+ * 生产方法 matchRateTier 内部用 MyBatis-Plus 查询:
+ *   WHERE enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC LIMIT 1
+ * 该查询在数据库侧执行;单元测试中由 mock 用 thenAnswer 模拟 DB 侧语义(过滤 + 降序),
+ * 再由生产方法自行取第一条。这样生产方法 get(0)/empty 的真实 Java 逻辑仍被覆盖。
+ *
+ * 种子档位:
+ *   铜牌 min=0  5% / 银牌 min=3  10% / 金牌 min=10 15%
+ *   铂金 min=30 20% / 钻石 min=100 25%
+ * 预期边界:
+ *   0/1/2  → 铜牌 5;3~9 → 银牌 10;10~29 → 金牌 15
+ *   30~99  → 铂金 20;100+ → 钻石 25
+ */
+class CfCommissionServiceTest {
+
+    @Mock
+    private CfRateTierMapper cfRateTierMapper;
+
+    @InjectMocks
+    private CfReferralService cfReferralService;
+
+    private List<CfRateTier> seedTiers;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        seedTiers = new ArrayList<>();
+        seedTiers.add(tier("铜牌", 0, 5));
+        seedTiers.add(tier("银牌", 3, 10));
+        seedTiers.add(tier("金牌", 10, 15));
+        seedTiers.add(tier("铂金", 30, 20));
+        seedTiers.add(tier("钻石", 100, 25));
+
+        // 模拟 DB 侧语义:WHERE enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC
+        when(cfRateTierMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<CfRateTier> wrapper = inv.getArgument(0);
+            int teamSize = extractTeamSize(wrapper);
+            return seedTiers.stream()
+                    .filter(t -> t.getEnabled() != null && t.getEnabled() == 1
+                            && t.getMinTeamSize() != null && t.getMinTeamSize() <= teamSize)
+                    .sorted(Comparator.comparing(CfRateTier::getMinTeamSize).reversed())
+                    .collect(Collectors.toList());
+        });
+    }
+
+    /**
+     * 从 wrapper 的参数取值里取出 le 条件的 teamSize。
+     * wrapper.getParamNameValuePairs() 记录了 eq(enabled,1) 与 le(min_team_size, teamSize) 的值,
+     * enabled 恒为 1,取其余整型即 teamSize。
+     */
+    private int extractTeamSize(LambdaQueryWrapper<CfRateTier> wrapper) {
+        Map<String, Object> params = wrapper.getParamNameValuePairs();
+        return params.values().stream()
+                .filter(v -> v instanceof Integer && !v.equals(1))
+                .map(v -> (Integer) v)
+                .findFirst()
+                .orElse(0);
+    }
+
+    private CfRateTier tier(String name, int min, int rate) {
+        CfRateTier t = new CfRateTier();
+        t.setTierName(name);
+        t.setMinTeamSize(min);
+        t.setRatePercent(rate);
+        t.setEnabled(1);
+        return t;
+    }
+
+    @Test
+    void matchRateTier_边界档位() {
+        assertTier(0, "铜牌", 5);
+        assertTier(3, "银牌", 10);
+        assertTier(10, "金牌", 15);
+        assertTier(30, "铂金", 20);
+        assertTier(100, "钻石", 25);
+    }
+
+    @Test
+    void matchRateTier_临界相邻值() {
+        assertTier(2, "铜牌", 5);
+        assertTier(9, "银牌", 10);
+        assertTier(29, "金牌", 15);
+        assertTier(99, "铂金", 20);
+        assertTier(1000, "钻石", 25);
+    }
+
+    private void assertTier(int teamSize, String name, int rate) {
+        CfRateTier result = cfReferralService.matchRateTier(teamSize);
+        assertEquals(name, result.getTierName(), "teamSize=" + teamSize);
+        assertEquals(rate, result.getRatePercent(), "teamSize=" + teamSize);
+    }
+}

+ 63 - 2
cfc-frontend/AGENTS.md

@@ -86,7 +86,68 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **地址选择:** 使用 `components/address-picker.vue` 四级联动组件
 - **角色切换:** 页面根据用户角色显示不同内容
 - **页面标题:** 所有页面的标题必须在 `pages.json` 的 `navigationBarTitleText` 中配置,禁止在 Vue 模板中手动写 `<text class="nav-title">`。仅动态标题页面(根据数据实时变化)可保留自定义 nav-bar
-- **API 调用去重:** 同一个接口在同一个页面生命周期内只调用一次。禁止在 onLoad/onShow/mounted 中多次调用同一 URL。如需依赖数据,在首次请求的 then 回调中处理后续逻辑
+- **API 调用去重(强制规范 · 违反者会被 CI 拦截):** 同一个后端 URL 在同一个页面首次加载时只允许发起一次网络请求。这是硬性规则,不是建议。违规会通过 `scripts/audit-duplicate-api-calls.js` 被检出(exit code 1)。
+
+  **为什么是根本性的:** 微信小程序生命周期在页面首次进入时 `onLoad` 和 `onShow` 都会触发,`utils/api.js` 的 3 秒去重是兜底,不是免死金牌——去重只能掩盖重复调用,不能消除它带来的延迟、内存和可维护性问题。
+
+  **规则 1 · 生命周期双发(最常见):** `onLoad` 和 `onShow` 不得同时无条件调用同一个加载方法。必须使用 `_onLoadFired` 标志位让首次 `onShow` 跳过,切 Tab 返回时刷新:
+  ```js
+  // 正确
+  onLoad(options) {
+    this.loadData(options)
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
+    } else {
+      this.loadData()              // 切 Tab 返回:刷新数据
+    }
+  },
+  ```
+  如果 `onShow` 的加载是"从子页面返回后的条件性刷新"(如地址编辑页返回),同样适用该模式——将条件判断放在 `else` 分支内。
+
+  **规则 2 · 同接口多封装(utils/api.js):** `utils/api.js` 中,同一个后端 URL 只能有**一个**包含 `request()` 调用的导出函数作为规范入口;语义相近的别名必须**委托**到规范函数,禁止复制 `request()` 调用:
+  ```js
+  // 规范入口
+  export const getFamilyMemberList = (params) => request('/api/family/member/list', 'POST', params || {})
+  // 别名(委托,不复制 request 调用)
+  export const getChildren = () => getFamilyMemberList({ memberType: 'child' })
+  ```
+  同 URL 不同 HTTP 方法 / 不同 `params` 的变体允许共存(如 `createPackage` POST data vs `getMyPackages` POST {}),但须在代码注释中标明差异。
+
+  **规则 3 · 一个方法只打一次接口:** 同一个方法内不得发起对同一 URL 的两次调用。常见反模式:`loadLoggedInData()` 中同时调用 `loadFamilyMembers()` 和 `loadChildren()`,二者封装了同一个 URL → 合并为一个请求,从响应中派生不同用途的数据:
+  ```js
+  // 错误:同一 URL 打两次
+  loadLoggedInData() { this.loadFamilyMembers(); this.loadChildren() }
+  // 正确:打一次,派生两类数据
+  loadFamilyData() {
+    getFamilyMemberList({ visibleOnly: true }).then(res => {
+      var arr = res.data
+      this.familyMembersVisible = arr
+      this.children = arr.filter(m => m.effectiveRole === 'child')
+    })
+  }
+  ```
+
+  **规则 4 · 禁止 N+1 循环请求:** 不得在 `.map()` / `.forEach()` / `for` 循环体内发起 API 请求。N 个元素 → N 次请求。应改为:(a) 后端提供批量接口一次返回;(b) 如果确实需要多元素多请求,须用 `Promise.all()` 并发执行(减少串行等待),同时确认后端能承受并发量;(c) 在循环前做去重/过滤,减少实际请求数。
+
+  **规则 4a · 循环前过滤成员类型(常见反模式):** 调用 `getTaskHistory` 等按成员 ID 轮询的接口时,**必须先过滤 `effectiveRole === 'child'`**,不得对家庭成员全量列表(含家长)发起请求。示例:
+  ```js
+  // 正确:过滤后轮询
+  var children = members.filter(m => m.effectiveRole === 'child')
+  Promise.all(children.map(c => getTaskHistory(c.id, 1, 3)))
+  // 错误:对全部成员(含家长)发起请求
+  Promise.all(members.map(m => getTaskHistory(m.id, 1, 3)))
+  ```
+
+  **规则 4b · getChildren 已废弃:** `getChildren()` 内部委托 `getFamilyMemberList({})`,二者调用同一接口。新代码直接使用 `getFamilyMemberList` 并在页面内按 `effectiveRole === 'child'` 过滤,不要同时导入 `getChildren` 和 `getFamilyMemberList`。
+
+  **规则 4c · onLoad/onShow 首次加载只走一次:** 小程序 TabBar 页面首次加载时 `onLoad` 和 `onShow` 均会触发。所有 API 调用必须确保首次只执行一次:在 `onLoad` 中设置 `_onLoadFired = true`,`onShow` 中检查该标志——首次为 `true` 则跳过(由 `onLoad` 负责),后续切 Tab 返回时标志已重置为 `false` 才刷新。`checkMemberStatus` 等高频方法同样适用。详见规则 1 代码示例。
+
+  **CI 门禁:** `scripts/audit-duplicate-api-calls.js` 是 CI 必跑脚本。运行方式:`node scripts/audit-duplicate-api-calls.js`,退出码 `0` = 通过,`1` = 存在未解决的 ERROR 级违规。每个新页面提交前必须通过审计。
+
+  **子组件接口调用:** 子组件不得重复调用父页面已调用的接口。父组件应通过 props 传递数据,而非让子组件各自请求。
 
 ## 时间显示规范
 
@@ -121,7 +182,7 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 
 
 - **NEVER** 在页面模板中写 `<text class="nav-title">` 或自定义 nav-bar 标题。所有静态标题统一在 `pages.json` 的 `navigationBarTitleText` 中配置。需要运行时动态标题时才用自定义 nav-bar
-- **NEVER** 在同一个页面中多次调用相同的 API 接口。如果第一次请求的结果需要用于后续请求的参数,在第一次请求的 success/then 回调中发起第二次请求,而不是并行调用
+- **NEVER** 在同一个页面中多次调用相同的 API 接口。`utils/api.js` 的 `request()` 已内置 3 秒窗口去重(同 URL + method + body 自动复用 Promise),但这是安全网不是免死金牌——页面须从源头避免:`onLoad` 全量初始化 + `_initDone` 标志跳过 `onShow` 首次重复、降级路径同步设置所有 state 避免后续方法重复调用、子组件通过 props 接收数据而非各自请求
 
 ## UNIQUE FEATURES
 

+ 42 - 15
cfc-frontend/__tests__/api/api.test.js

@@ -25,7 +25,6 @@ function mockFail(err) {
   })
 }
 
-/** Helper: make uni.request return a sequence of responses (one per call) */
 function mockSequence(responses) {
   var i = 0
   global.uni.request.mockImplementation((opts) => {
@@ -167,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'
@@ -306,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 () => {
@@ -413,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 () => {
@@ -549,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 () => {
@@ -595,12 +628,6 @@ describe('guide & packages API', () => {
 // --------------- payment module ---------------
 
 describe('payment API', () => {
-  test('createPackageOrder sends POST', async () => {
-    mockSuccess({ code: 200 })
-    await api.createPackageOrder({ packageId: 1, userId: 5 })
-    expect(lastRequest().url).toMatch(/\/api\/package\/order\/create$/)
-  })
-
   test('createWechatPayOrder sends POST', async () => {
     mockSuccess({ code: 200 })
     await api.createWechatPayOrder('NO001', '测试订单', 100)
@@ -963,8 +990,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)
   })
 })

+ 18 - 6
cfc-frontend/components/ArticleBookshelf.vue

@@ -30,9 +30,9 @@
           }"
         >
           <image
-            v-if="article.coverImage"
+            v-if="article.coverSrc"
             class="bs-cover-img"
-            :src="getImageUrl(article.coverImage)"
+            :src="article.coverSrc"
             mode="aspectFill"
           />
 
@@ -43,7 +43,7 @@
             >{{ article.title }}</text>
           </view>
 
-          <view v-if="article.coverImage" class="bs-cover-title-bottom">
+          <view v-if="article.coverSrc" class="bs-cover-title-bottom">
             <text class="bs-cover-title-overlay">{{ article.title }}</text>
           </view>
         </view>
@@ -87,16 +87,24 @@ export default {
   },
   computed: {
     displayArticles: function() {
+      var list
       if (!this.articles || this.articles.length === 0) {
-        return [
+        list = [
           { title: '培养孩子自驱力的关键', category: '家庭成长', summary: '如何帮助孩子建立内在动机' },
           { title: '五维能量与家庭健康管理', category: '健康科普', summary: '科学理解健康的五个维度' },
           { title: '亲子沟通的心理学智慧', category: '心理养育', summary: '有效的倾听与表达技巧' },
           { title: '规划师推荐的书单', category: '成长规划', summary: '培养未来领导力的阅读路径' },
           { title: '儿童营养均衡完全指南', category: '营养健康', summary: '0-12岁各阶段营养需求' }
         ].slice(0, 3)
+      } else {
+        list = this.articles.slice(0, 3)
       }
-      return this.articles.slice(0, 3)
+      var self = this
+      return list.map(function(a) {
+        var item = Object.assign({}, a)
+        item.coverSrc = self.getImageUrl(a && a.coverImage)
+        return item
+      })
     }
   },
   methods: {
@@ -144,7 +152,11 @@ export default {
       return FALLBACK_GRADIENTS[idx % FALLBACK_GRADIENTS.length].text
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 5 - 1
cfc-frontend/components/DimensionActivities.vue

@@ -209,7 +209,11 @@ export default {
       return '¥' + (Number(price) / 100).toFixed(2)
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 5 - 1
cfc-frontend/components/DimensionProducts.vue

@@ -105,7 +105,11 @@ export default {
       return '¥' + (Number(price) / 100).toFixed(2)
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 191 - 172
cfc-frontend/components/MemberChipPicker.vue

@@ -1,49 +1,35 @@
 <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 class="member-picker">
+    <text class="member-load-hint">成员数:{{ memberCount }}</text>
+    <scroll-view class="member-scroll" scroll-x="true" :show-scrollbar="false" v-if="memberCount > 0">
+      <view class="member-chips">
+        <view
+          v-for="item in members"
+          :key="item.id"
+          class="member-chip"
+          :class="{ 'member-chip-on': item.selected }"
+          @click="toggleMember(item.id)">
+          <text class="member-chip-text">{{ item.nickname }}</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>
+    </scroll-view>
+    <view v-if="memberCount === 0 && !loading" class="empty-state">
+      <text class="empty-text">暂无家庭成员,请先添加后再制定方案</text>
+      <view class="empty-action" @click="goAddMember">去添加成员</view>
+      <view class="empty-action reload-btn" @click="reload">重新加载</view>
     </view>
-
-    <view v-else class="mcp-empty">
-      <text class="mcp-empty-text">暂无家庭成员</text>
-      <view class="mcp-empty-btn" @tap="goAddMember">+ 去添加成员</view>
+    <view v-if="loading" class="empty-state">
+      <text class="empty-text">成员加载中...</text>
     </view>
   </view>
 </template>
 
 <script>
-import { getFamilyMemberList } from '@/utils/api.js'
+import { getFamilyMemberList } from '../utils/api.js'
 
 export default {
   name: 'MemberChipPicker',
   props: {
-    // 已选中的成员 id 数组(字符串)
     selectedIds: {
       type: Array,
       default: function() { return [] }
@@ -52,182 +38,215 @@ export default {
   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
+      memberCount: 0,
+      loading: false
     }
   },
-  created: function() {
-    this.reload()
+  mounted: function() {
+    this.loadMembers()
   },
   methods: {
-    // ——— :key 方法(避免模板内运算符表达式) ———
-    getMemberKey: function(m, i) {
-      return String(m.memberId || m.id || i)
+    reload: function() {
+      this.loadMembers()
     },
-    // ——— 首字(无头像兜底) ———
-    getInitial: function(m) {
-      var name = m.name || m.nickname || ''
-      return name ? name.substring(0, 1) : '?'
+    goAddMember: function() {
+      this.$emit('add-member')
+      uni.navigateTo({ url: '/pages/family/add-member' })
     },
-    isSelected: function(id) {
-      return this.innerSelectedIds.indexOf(String(id)) >= 0
+    toggleMember: function(id) {
+      var strId = String(id)
+      var ids = []
+      var i
+      for (i = 0; i < this.members.length; i++) {
+        if (this.members[i].selected) ids.push(String(this.members[i].id))
+      }
+      var idx = ids.indexOf(strId)
+      if (idx >= 0) ids.splice(idx, 1)
+      else ids.push(strId)
+      this.applySelected(ids)
+      this.emitChange()
     },
-    // ——— 加载家庭成员 ———
-    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 = []
-      })
+    applySelected: function(ids) {
+      var selected = {}
+      var i
+      for (i = 0; i < ids.length; i++) {
+        selected[String(ids[i])] = true
+      }
+      var list = []
+      for (i = 0; i < this.members.length; i++) {
+        var m = this.members[i]
+        list.push({
+          id: m.id,
+          memberId: m.memberId,
+          name: m.name,
+          nickname: m.nickname,
+          selected: !!selected[String(m.id)]
+        })
+      }
+      this.members = list
+      this.memberCount = list.length
     },
-    // ——— 切换选中态 ———
-    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)
+    emitChange: function() {
+      var ids = []
+      var i
+      for (i = 0; i < this.members.length; i++) {
+        if (this.members[i].selected) ids.push(String(this.members[i].id))
       }
-      this.emitState()
+      this.$emit('change', { selectedIds: ids, members: this.members })
     },
-    // ——— 通知父组件 ———
-    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()
+    normalizeMemberList: function(data) {
+      if (!data) return []
+      var asList = function(v) {
+        return v && typeof v === 'object' && typeof v.length === 'number' ? v : null
+      }
+      if (asList(data)) return data
+      var sources = [data.children, data.members, data.list, data.records]
+      var merged = []
+      var seen = {}
+      var s, i, m, mid, key
+      for (s = 0; s < sources.length; s++) {
+        var arr = asList(sources[s])
+        if (!arr || arr.length === 0) continue
+        for (i = 0; i < arr.length; i++) {
+          m = arr[i]
+          if (!m) continue
+          mid = m.id != null ? m.id : (m.memberId != null ? m.memberId : m.userId)
+          if (mid == null) continue
+          key = String(mid)
+          if (seen[key]) continue
+          seen[key] = true
+          merged.push(m)
+        }
+      }
+      return merged
+    },
+    applyList: function(rawList) {
+      var list = rawList || []
+      var mapped = []
+      var i, m, mid, nm
+      for (i = 0; i < list.length; i++) {
+        m = list[i]
+        if (!m) continue
+        mid = m.id != null ? m.id : (m.memberId != null ? m.memberId : m.userId)
+        if (mid == null) continue
+        nm = m.nickname || m.name || m.roleLabel || '成员'
+        mapped.push({
+          id: mid,
+          memberId: String(mid),
+          name: String(nm),
+          nickname: String(nm),
+          selected: false
         })
       }
+      var selectedIds = (this.selectedIds || []).map(function(id) { return String(id) })
+      var idSet = {}
+      for (i = 0; i < mapped.length; i++) idSet[String(mapped[i].id)] = true
+      selectedIds = selectedIds.filter(function(id) { return !!idSet[String(id)] })
+      if (selectedIds.length === 0 && mapped.length > 0) {
+        selectedIds = [String(mapped[0].id)]
+      }
+      for (i = 0; i < mapped.length; i++) {
+        mapped[i].selected = selectedIds.indexOf(String(mapped[i].id)) >= 0
+      }
+      this.members = mapped
+      this.memberCount = mapped.length
+      this.emitChange()
+      this.$emit('loaded', { members: mapped, selectedIds: selectedIds })
+      return mapped.length
     },
-    // ——— 添加成员 ———
-    goAddMember: function() {
-      this.$emit('add-member')
-      uni.navigateTo({ url: '/pages/family/add-member' })
+    loadMembers: function() {
+      if (this.loading) return
+      this.loading = true
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var list = self.normalizeMemberList(res && res.data)
+        if (list.length > 0) return list
+        return getFamilyMemberList({ includeFamily: true }).then(function(res2) {
+          var list2 = self.normalizeMemberList(res2 && res2.data)
+          if (list2.length > 0) return list2
+          return getFamilyMemberList({}).then(function(res3) {
+            return self.normalizeMemberList(res3 && res3.data)
+          })
+        })
+      }).then(function(list) {
+        var count = self.applyList(list || [])
+        if (count === 0) {
+          uni.showToast({ title: '成员列表为空', icon: 'none' })
+        }
+      }).catch(function(err) {
+        console.warn('[MemberChipPicker] loadMembers failed', err)
+        self.members = []
+        self.memberCount = 0
+        uni.showToast({ title: '成员加载失败', icon: 'none' })
+      }).then(function() {
+        self.loading = false
+      })
     }
   }
 }
 </script>
 
 <style scoped>
-.member-chip-picker {
+.member-scroll {
   width: 100%;
+  white-space: nowrap;
+  margin-bottom: 16rpx;
 }
-.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;
+.member-chips {
+  display: inline-flex;
+  flex-direction: row;
+  padding: 4rpx 0;
 }
-.mcp-chip {
-  display: flex;
+.member-chip {
+  display: inline-flex;
   align-items: center;
-  padding: 12rpx 24rpx;
-  margin: 0 16rpx 16rpx 0;
-  background: #F3F4F6;
-  border-radius: 999rpx;
+  justify-content: center;
+  padding: 12rpx 28rpx;
+  margin-right: 16rpx;
+  border-radius: 32rpx;
+  background: #F1F5F9;
   border: 2rpx solid transparent;
 }
-.mcp-chip-on {
-  background: #FFEDD5;
+.member-chip-on {
+  background: #FFF7ED;
   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 {
+.member-chip-text {
   font-size: 26rpx;
-  color: #374151;
+  color: #334155;
+  font-weight: 600;
 }
-.mcp-chip-on .mcp-chip-text {
-  color: #C2410C;
-  font-weight: 500;
+.member-chip-on .member-chip-text {
+  color: #F97316;
 }
-.mcp-chip-add {
-  background: transparent;
-  border: 2rpx dashed #D1D5DB;
-  color: #9CA3AF;
-}
-.mcp-chip-add-icon {
-  font-size: 28rpx;
-  margin-right: 6rpx;
-  color: #9CA3AF;
+.member-load-hint {
+  font-size: 22rpx;
+  color: #F97316;
+  margin-bottom: 12rpx;
 }
-.mcp-empty {
-  padding: 40rpx 0;
+.empty-state {
+  width: 100%;
   display: flex;
   flex-direction: column;
   align-items: center;
+  padding: 40rpx 0 24rpx;
+  box-sizing: border-box;
 }
-.mcp-empty-text {
+.empty-text {
   font-size: 26rpx;
-  color: #9CA3AF;
-  margin-bottom: 16rpx;
+  color: #999;
 }
-.mcp-empty-btn {
-  padding: 14rpx 40rpx;
-  background: #F97316;
-  color: #FFFFFF;
+.empty-action {
+  margin-top: 20rpx;
+  padding: 12rpx 32rpx;
   font-size: 26rpx;
-  border-radius: 40rpx;
+  color: #F97316;
+  border: 2rpx solid #F97316;
+  border-radius: 32rpx;
+}
+.reload-btn {
+  margin-top: 16rpx;
+  border-color: #999;
+  color: #666;
 }
 </style>

+ 5 - 3
cfc-frontend/components/RadarChart.vue

@@ -147,7 +147,8 @@ export default {
   },
   watch: {
     scores: {
-      handler: function() {
+      handler: function(val) {
+        if (!Array.isArray(val)) return
         if (this.canvasReady) {
           this._startAnimation()
         }
@@ -275,7 +276,7 @@ export default {
             console.log('[RadarChart] 初始化成功', 'size=' + width + 'x' + height, 'canvasId=' + self.canvasId, 'pixelRatio=' + pixelRatio)
 
             // 有分数则播放动画,否则只画网格
-            if (self.scores && self.scores.length >= self.dimCount && self.dimCount >= 3) {
+            if (Array.isArray(self.scores) && self.scores.length >= self.dimCount && self.dimCount >= 3) {
               self._startAnimation()
             } else {
               self._doRender()
@@ -610,10 +611,11 @@ export default {
     _startAnimation: function() {
       var self = this
       var n = this.dimCount
+      var scores = Array.isArray(this.scores) ? this.scores : []
       var target = []
       for (var i = 0; i < n; i++) {
         // 关键修复:后端可能返回 null score,这里归一化避免 NaN 传播导致 Canvas 静默失败
-        target.push(self._safeScore(this.scores[i]))
+        target.push(self._safeScore(scores[i]))
       }
       var start = []
       for (var si = 0; si < n; si++) { start.push(0) }

+ 16 - 2
cfc-frontend/pages/diet/recommendation.vue

@@ -103,7 +103,7 @@
 </template>
 
 <script>
-import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation } from '@/utils/api.js'
+import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation } from '@/utils/api.js'
 
 export default {
   data() {
@@ -219,7 +219,21 @@ export default {
       uni.showToast({ title: '已跳过', icon: 'none' })
     },
     regenerate: function() {
-      this.generateRecipe()
+      var self = this
+      if (!self.recommendation || !self.recommendation.id) return
+      var regData = { id: self.recommendation.id }
+      uni.showLoading({ title: '重新生成中...' })
+      regenerateDietRecommendation(regData).then(function(res) {
+        uni.hideLoading()
+        if (res && res.data && res.data.id) {
+          self.loadRecommendation()
+        } else {
+          uni.showToast({ title: (res && res.message) || '重新生成失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '重新生成失败', icon: 'none' })
+      })
     },
     completeRecipe: function() {
       var self = this

+ 6 - 1
cfc-frontend/pages/energy/index.vue

@@ -249,9 +249,14 @@ export default {
   onLoad() {
     this.checkLogin()
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.isLoggedIn) this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      if (this.isLoggedIn) this.loadData()
+    }
   },
   methods: {
     checkLogin() {

+ 1 - 1
cfc-frontend/pages/growth-main/index.vue

@@ -309,7 +309,7 @@ export default {
     goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
     goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
-    goToFoods: function() { uni.navigateTo({ url: '/pages/diet/index' }) },
+    goToFoods: function() { uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail' }) },
     goToArticle: function(article) { var id = article && article.id ? article.id : article; if (!id) return; uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id }) },
     goToProduct: function(prod) { var id = prod && prod.id ? prod.id : prod; if (!id) return; uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id }) },
     goMoreProducts: function() { uni.navigateTo({ url: '/pages/shop/index/index' }) },

+ 7 - 1
cfc-frontend/pages/guide/hierarchy/index.vue

@@ -92,9 +92,15 @@ export default {
   },
   onLoad() {
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    // 首次进入:onLoad 已加载,跳过避免双发;切 Tab 返回时刷新
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     async loadData() {

+ 6 - 1
cfc-frontend/pages/guide/invite/share.vue

@@ -120,9 +120,14 @@ export default {
   },
   onLoad() {
     this.loadInviteCodes()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadInviteCodes()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadInviteCodes()
+    }
   },
   methods: {
     async loadInviteCodes() {

+ 9 - 6
cfc-frontend/pages/health-main/index.vue

@@ -145,7 +145,7 @@
       </view>
 
       <view class="section section-chart">
-        <RadarChart :dimensions="currentDimensions" :scores="currentScores" :fillColor="currentColor" :childName="selectedMemberName" :avgScores="currentAvgScores" width="640" height="520" />
+        <RadarChart :dimensions="currentDimensions" :scores="currentScores || []" :fillColor="currentColor" :childName="selectedMemberName" :avgScores="currentAvgScores || []" width="640" height="520" />
         <view class="chart-empty" v-if="chartIsEmpty">
           <text class="empty-text">{{ currentTabLabel }}数据待采集</text>
           <text class="empty-sub">完成测评或上传报告即可解锁{{ currentTabLabel }}数据</text>
@@ -237,7 +237,7 @@ export default {
   components: { LoginGuideCard, RadarChart, ArticleBookshelf, ReportUploadCard },
   data() {
     return {
-      isLoggedIn: false,
+      isLoggedIn: !!uni.getStorageSync('token'),
       currentTab: 'body',
       selectedMemberName: '',
       selectedMemberId: '',
@@ -275,6 +275,7 @@ export default {
         { label: '空间想象', key: 'spatial' },
         { label: '语言表达', key: 'language' }
       ],
+      demoBodyScores: [72, 68, 75, 80, 70, 78, 82],
       demoCogScores: [78, 72, 85, 68, 80],
       // 真实数据缓存
       bodyData: null,
@@ -580,10 +581,12 @@ export default {
             self.relationshipEmpty = true
           }
         }).catch(function() { self.relationshipEmpty = true })
-        // 财富子维度(富维度四子维度:能力/利他/财务/素养)
-        if (memberId) {
-          var memberType = uni.getStorageSync('currentChildId') ? 'child' : 'parent'
-          api.getWealthDetail(memberId, memberType).then(function(res) {
+        // 财富子维度:沙盘里家长用 userId,孩子用 family_members.id,不能混用 currentMemberId
+        var childId = uni.getStorageSync('currentChildId')
+        var wealthMemberId = childId || uni.getStorageSync('userId')
+        var wealthMemberType = childId ? 'child' : 'parent'
+        if (wealthMemberId) {
+          api.getWealthDetail(wealthMemberId, wealthMemberType).then(function(res) {
             if (res.code === 200 && res.data) {
               self.wealthDetail = res.data
             }

+ 6 - 1
cfc-frontend/pages/health/challenge-manage.vue

@@ -252,9 +252,14 @@ export default {
     this.loadMembers()
     this.checkMembership()
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     async loadData() {

+ 145 - 40
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -106,15 +106,27 @@
           <text class="func-dim-summary">{{ analysisData.dietaryPattern.detail }}</text>
         </view>
 
-        <!-- 风险提示 -->
-        <view class="analysis-card">
-          <text class="card-label">风险提示</text>
-          <view class="risk-item" v-for="(risk, ri) in analysisData.riskIndicators" :key="ri">
-            <text class="risk-name">{{ risk.name }}</text>
-            <text :class="'risk-level risk-' + statusKey(risk.level)">{{ risk.level }}</text>
-            <text class="risk-bacteria" v-if="risk.relatedBacteria && risk.relatedBacteria.length > 0">
-              {{ risk.relatedBacteria.join('、') }}
-            </text>
+        <!-- 菌群风险分组手风琴(肥胖/抑郁/过敏等) -->
+        <view class="accordion-section" v-if="analysisData && analysisData.riskIndicators && analysisData.riskIndicators.length > 0">
+          <view class="accordion-header" @tap="toggleAccordion('riskGroup')">
+            <text class="accordion-title">菌群风险分组</text>
+            <text class="accordion-stats">共 {{ analysisData.riskIndicators.length }} 组</text>
+            <text class="accordion-arrow">{{ accordionOpen.riskGroup ? '收起' : '展开' }}</text>
+          </view>
+          <view class="accordion-body" v-if="accordionOpen.riskGroup">
+            <view class="risk-group-item" v-for="(risk, ri) in analysisData.riskIndicators" :key="ri">
+              <view class="risk-group-header" @tap="toggleRiskGroup(ri)">
+                <text class="risk-group-name">{{ risk.name }}</text>
+                <text :class="'risk-level risk-' + statusKey(risk.level)">{{ risk.level }}</text>
+                <text class="risk-group-arrow">{{ riskGroupOpen[ri] ? '收起' : '展开' }}</text>
+              </view>
+              <view class="risk-group-body" v-if="riskGroupOpen[ri]">
+                <text class="risk-bacteria" v-if="risk.relatedBacteria && risk.relatedBacteria.length > 0">
+                  相关菌群:{{ risk.relatedBacteria.join('、') }}
+                </text>
+                <text class="risk-bacteria" v-else>暂无异常相关菌群</text>
+              </view>
+            </view>
           </view>
         </view>
 
@@ -164,26 +176,25 @@
         </view>
       </view>
 
-      <!-- 营养指标手风琴 -->
-      <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">异常 <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">
-          <view class="sub-group" v-for="(group, gIdx) in nutritionGroups" :key="gIdx">
-            <text class="sub-group-title">{{ group.category }}</text>
-              <view class="indicator-card" v-for="(item, idx) in group.items" :key="idx">
-                <view class="indicator-row">
-<text class="ind-help-btn" @tap.stop="showKnowledge(indicatorType(item), item.indicatorName)">?</text>
-                  <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
-                    {{ item.indicatorName }}
-                    <text class="arrow-up" v-if="item.status === '偏高' || item.status === '过多'"> ↑</text>
-                    <text class="arrow-down" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'"> ↓</text>
-                    <text class="arrow-warn" v-else-if="item.status === '异常'"> ⚠</text>
-                  </text>
-                  <view class="indicator-value" v-if="!isEditing">
+      <!-- 营养分组手风琴(每个分组独立一级展示) -->
+      <view class="nutrition-group-section" v-for="(group, gIdx) in nutritionGroups" :key="gIdx">
+        <view class="accordion-section">
+          <view class="accordion-header" @tap="toggleNutriGroup(gIdx)">
+            <text class="accordion-title">{{ group.category }}</text>
+            <text class="accordion-stats" v-if="group.abnormalCount > 0">异常 <text class="abnormal-num">{{ group.abnormalCount }}</text> 项</text>
+            <text class="accordion-arrow">{{ nutriGroupOpen[gIdx] ? '收起' : '展开' }}</text>
+          </view>
+          <view class="accordion-body" v-if="nutriGroupOpen[gIdx]">
+            <view class="indicator-card" v-for="(item, idx) in group.items" :key="idx">
+              <view class="indicator-row">
+                <text class="ind-help-btn" @tap.stop="showKnowledge(indicatorType(item), item.indicatorName)">?</text>
+                <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
+                  {{ item.indicatorName }}
+                  <text class="arrow-up" v-if="item.status === '偏高' || item.status === '过多'"> ↑</text>
+                  <text class="arrow-down" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'"> ↓</text>
+                  <text class="arrow-warn" v-else-if="item.status === '异常'"> ⚠</text>
+                </text>
+                <view class="indicator-value" v-if="!isEditing">
                   <text :class="'val-text val-' + (item._cssClass || 'normal')">{{ item.indicatorValue || '--' }}</text>
                   <text class="indicator-unit" v-if="item.unit">{{ item.unit }}</text>
                 </view>
@@ -194,11 +205,31 @@
                   </picker>
                 </view>
               </view>
-<text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
-            <text class="indicator-symptoms" v-if="item.symptoms">症状: {{ item.symptoms }}</text>
+              <text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
+              <text class="indicator-symptoms" v-if="item.symptoms">症状: {{ item.symptoms }}</text>
             </view>
+            <view class="empty-hint" v-if="group.items.length === 0">暂无数据</view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 疾病风险内联展示(颜色区分) -->
+      <view class="accordion-section" v-if="diseaseRisks.length > 0">
+        <view class="accordion-header" @tap="toggleAccordion('risks')">
+          <text class="accordion-title">疾病风险</text>
+          <text class="accordion-stats" v-if="diseaseRisksAbnormal > 0">需关注 <text class="abnormal-num">{{ diseaseRisksAbnormal }}</text> 项</text>
+          <text class="accordion-arrow">{{ accordionOpen.risks ? '收起' : '展开' }}</text>
+        </view>
+        <view class="accordion-body" v-if="accordionOpen.risks">
+          <view class="risk-card-inline" v-for="(risk, ri) in diseaseRisks" :key="ri"
+                :class="'risk-level-' + (risk._cssClass || 'low')">
+            <text class="risk-name-inline">{{ risk.diseaseName }}</text>
+            <text class="risk-badge-inline" :class="'badge-' + (risk._cssClass || 'low')">
+              {{ risk.riskLevel || '--' }}
+            </text>
+            <text class="risk-value-inline" v-if="risk.riskValue">风险值 {{ risk.riskValue }}</text>
           </view>
-          <view class="empty-hint" v-if="nutritionGroups.length === 0">暂无数据</view>
+          <view class="empty-hint" v-if="diseaseRisks.length === 0">暂无疾病风险数据</view>
         </view>
       </view>
 
@@ -264,11 +295,14 @@ export default {
       report: {},
       reportInterpretation: null,
       isEditing: false,
-      accordionOpen: { gut: false, nutri: false },
+      accordionOpen: { gut: false, risks: false, riskGroup: false },
+      riskGroupOpen: [],
+      nutriGroupOpen: [],
       scoreItems: [],
       allIndicators: [],
       gutIndicators: [],
       nutritionGroups: [],
+      diseaseRisks: [],
       floraCount: 0,
       floraAbnormalCount: 0,
       riskCount: 0,
@@ -314,6 +348,15 @@ export default {
         if (this.isAbnormalStatus(item.status)) abnormal++
       }
       return { total: list.length, valid: valid, abnormal: abnormal }
+    },
+    diseaseRisksAbnormal: function() {
+      var list = this.diseaseRisks || []
+      var count = 0
+      for (var i = 0; i < list.length; i++) {
+        var level = list[i].riskLevel || ''
+        if (level !== '低风险') count++
+      }
+      return count
     }
   },
   onLoad: function(options) {
@@ -372,8 +415,12 @@ export default {
             return self.isAbnormalStatus(i.status)
           }).length
           self.riskCount = (res.data.diseaseRisks || []).length
-          // 疾病风险:统计需关注的数量(风险等级非"低风险"即视为需关注)
+          // 疾病风险:内联展示 + 颜色区分,统计需关注的数量(风险等级非"低风险"即视为需关注)
           var risks = res.data.diseaseRisks || []
+          self.diseaseRisks = risks.map(function(r) {
+            r._cssClass = self._cssForRiskLevel(r.riskLevel)
+            return r
+          })
           self.riskAttentionCount = risks.filter(function(r) {
             return r.riskLevel && r.riskLevel !== '低风险'
           }).length
@@ -541,18 +588,40 @@ report.gutType = s.gutType || ''
           seen[item.category] = true
           groups.push({ category: item.category, items: [] })
         }
-        for (var g = 0; g < groups.length; g++) {
-          if (groups[g].category === item.category) {
-            groups[g].items.push(item)
-            break
-          }
+      for (var g = 0; g < groups.length; g++) {
+        if (groups[g].category === item.category) {
+          groups[g].items.push(item)
+          break
+        }
+      }
+      }
+      for (var gi = 0; gi < groups.length; gi++) {
+        var abnormal = 0
+        for (var ii = 0; ii < groups[gi].items.length; ii++) {
+          if (this.isAbnormalStatus(groups[gi].items[ii].status)) abnormal++
         }
+        groups[gi].abnormalCount = abnormal
       }
       this.nutritionGroups = groups
     },
     toggleAccordion: function(key) {
       this.accordionOpen[key] = !this.accordionOpen[key]
     },
+    toggleNutriGroup: function(idx) {
+      var arr = this.nutriGroupOpen
+      arr[idx] = !arr[idx]
+      this.nutriGroupOpen = [].concat(arr)
+    },
+    toggleRiskGroup: function(idx) {
+      var arr = this.riskGroupOpen
+      arr[idx] = !arr[idx]
+      this.riskGroupOpen = [].concat(arr)
+    },
+    /** 中文风险等级 → CSS安全英文 */
+    _cssForRiskLevel: function(level) {
+      var map = { '低风险': 'low', '注意': 'warning', '需注意': 'warning', '高风险': 'high', '异常': 'abnormal' }
+      return map[level] || 'low'
+    },
     indicatorType: function(item) {
       return INDICATOR_TYPE_MAP[item.category] || 'nutrient'
     },
@@ -719,7 +788,11 @@ report.gutType = s.gutType || ''
       uni.navigateTo({ url: '/pages/growth/caution-detail' })
     },
     goToIndicators: function() {
-      this.accordionOpen.nutri = true
+      // 展开所有营养分组手风琴
+      var arr = []
+      for (var i = 0; i < this.nutritionGroups.length; i++) arr.push(true)
+      this.nutriGroupOpen = arr
+      if (this.nutritionGroups.length === 0) uni.showToast({ title: '暂无营养指标', icon: 'none' })
     },
     // 问答功能已屏蔽
     goAIChat: function() {}
@@ -763,6 +836,38 @@ report.gutType = s.gutType || ''
 .accordion-arrow { font-size: 24rpx; color: #999; }
 .accordion-body { padding: 10rpx 30rpx 20rpx; }
 
+/* 疾病风险内联卡片 */
+.risk-card-inline {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 16rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+  border-left: 8rpx solid #10B981;
+  margin-bottom: 8rpx;
+}
+.risk-card-inline.risk-level-warning { border-left-color: #F59E0B; }
+.risk-card-inline.risk-level-high { border-left-color: #EF4444; }
+.risk-card-inline.risk-level-abnormal { border-left-color: #C62828; }
+.risk-name-inline { font-size: 28rpx; color: #333; flex: 1; }
+.risk-badge-inline { padding: 4rpx 16rpx; border-radius: 20rpx; font-size: 22rpx; flex-shrink: 0; }
+.risk-badge-inline.badge-low { background: #E8F5E9; color: #2E7D32; }
+.risk-badge-inline.badge-warning { background: #FFF3E0; color: #E65100; }
+.risk-badge-inline.badge-high { background: #FFEBEE; color: #C62828; }
+.risk-badge-inline.badge-abnormal { background: #C62828; color: #fff; }
+.risk-value-inline { font-size: 22rpx; color: #999; margin-left: 16rpx; }
+
+/* 菌群风险分组手风琴 */
+.risk-group-item { padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.risk-group-header { display: flex; align-items: center; gap: 12rpx; padding: 8rpx 0; }
+.risk-group-name { font-size: 28rpx; color: #333; flex: 1; }
+.risk-group-arrow { font-size: 24rpx; color: #999; }
+.risk-group-body { padding: 8rpx 0 12rpx; }
+.risk-level { font-size: 22rpx; padding: 4rpx 14rpx; border-radius: 8rpx; }
+.risk-level.risk-good { background: #E8F5E9; color: #2E7D32; }
+.risk-level.risk-warning { background: #FFF3E0; color: #E65100; }
+.risk-level.risk-danger { background: #FFEBEE; color: #C62828; }
+.risk-bacteria { font-size: 24rpx; color: #666; line-height: 1.6; }
+
 .sub-group { margin-bottom: 16rpx; }
 .sub-group-title { font-size: 26rpx; color: #4A9BD7; font-weight: 600; display: block; margin: 12rpx 0 8rpx; }
 

+ 208 - 82
cfc-frontend/pages/health/gut-flora-foods-detail.vue

@@ -1,52 +1,83 @@
 <template>
   <view class="page">
-    <view class="nav-bar">
-      <view class="nav-back" @tap="goBack">
-        <text class="back-text">‹ 返回</text>
+    <!-- 搜索框 -->
+    <view class="search-bar">
+      <view class="search-input-wrap">
+        <text class="search-icon">🔍</text>
+        <input class="search-input" v-model="searchText" placeholder="搜索食材名称,查看推荐值" confirm-type="search" />
+        <text class="search-clear" v-if="searchText" @tap="searchText = ''">✕</text>
       </view>
-      <text class="nav-title">饮食推荐</text>
-      <text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
-    </view>
-
-    <scroll-view class="filter-bar" scroll-x>
-      <view class="filter-item" v-for="(cat, idx) in categories" :key="idx"
-            :class="'filter-' + (selectedCategory === cat ? 'active' : '')"
-            @tap="selectedCategory = cat">
-        <text>{{ cat }}</text>
-      </view>
-    </scroll-view>
-
-    <view class="sort-bar">
-      <text class="sort-text" @tap="sortDesc = !sortDesc">排序: {{ sortDesc ? '推荐↓' : '推荐↑' }}</text>
-      <text class="sort-text" @tap="showRecommendedOnly = !showRecommendedOnly">{{ showRecommendedOnly ? '全部' : '仅推荐' }}</text>
     </view>
 
     <scroll-view class="content" scroll-y>
-      <view class="food-card" v-for="(item, idx) in filteredFoods" :key="idx">
+      <view
+        class="food-card"
+        v-for="(item, idx) in filteredFoods"
+        :key="idx"
+        :class="'score-band-' + _scoreBand(item.score)"
+        @tap="showFoodKnowledge(item)"
+      >
         <view class="food-header">
           <text class="food-name">{{ item.foodName || item.name }}</text>
           <view class="food-cat-tag">
             <text>{{ item.category }}</text>
           </view>
         </view>
-        <view class="food-detail-row" v-if="!isEditing">
-          <text class="food-nutrition">蛋白{{ item.protein || '--' }} | 脂肪{{ item.fat || '--' }} | 碳水{{ item.carb || '--' }} | 纤维{{ item.fiber || '--' }}</text>
-        </view>
-        <view class="food-detail-row" v-if="!isEditing && item.energy">
-          <text class="food-nutrition">能量 {{ item.energy }}KJ</text>
+        <view class="food-score-row">
+          <text class="food-score-label">推荐值</text>
+          <text class="food-score-value">{{ item.score }}</text>
         </view>
         <view class="food-edit-row" v-if="isEditing">
-          <text class="edit-label">推荐指数:</text>
+          <text class="edit-label">推荐值:</text>
           <input class="edit-input" type="number" v-model="item.score" />
         </view>
-        <view class="food-score" :class="(item.score || 0) >= 5 ? 'score-good' : 'score-normal'">
-          <text>推荐: {{ item.score }}</text>
-        </view>
       </view>
       <view class="empty-hint" v-if="filteredFoods.length === 0">暂无数据</view>
       <view class="bottom-spacer"></view>
     </scroll-view>
 
+    <!-- 食材知识库弹窗 -->
+    <view class="knowledge-overlay" v-if="knowledgeVisible" @tap="closeKnowledge">
+      <view class="knowledge-popup" @tap.stop>
+        <view class="knowledge-header">
+          <text class="knowledge-title">{{ knowledgeData.itemName || '食材详情' }}</text>
+          <text class="knowledge-close" @tap="closeKnowledge">✕</text>
+        </view>
+        <view class="knowledge-body">
+          <view class="knowledge-row" v-if="knowledgeData.category">
+            <text class="knowledge-label">分类</text>
+            <text class="knowledge-value">{{ knowledgeData.category }}</text>
+          </view>
+          <view class="knowledge-row" v-if="knowledgeData.protein != null">
+            <text class="knowledge-label">蛋白</text>
+            <text class="knowledge-value">{{ knowledgeData.protein }}g</text>
+          </view>
+          <view class="knowledge-row" v-if="knowledgeData.fat != null">
+            <text class="knowledge-label">脂肪</text>
+            <text class="knowledge-value">{{ knowledgeData.fat }}g</text>
+          </view>
+          <view class="knowledge-row" v-if="knowledgeData.carbs != null">
+            <text class="knowledge-label">碳水</text>
+            <text class="knowledge-value">{{ knowledgeData.carbs }}g</text>
+          </view>
+          <view class="knowledge-row" v-if="knowledgeData.fiber != null">
+            <text class="knowledge-label">纤维</text>
+            <text class="knowledge-value">{{ knowledgeData.fiber }}g</text>
+          </view>
+          <view class="knowledge-row" v-if="knowledgeData.energy != null">
+            <text class="knowledge-label">能量</text>
+            <text class="knowledge-value">{{ knowledgeData.energy }}KJ</text>
+          </view>
+          <view class="knowledge-desc" v-if="knowledgeData.description">
+            <text class="knowledge-desc-text">{{ knowledgeData.description }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="edit-fab" v-if="!isEditing && reportId" @tap="toggleEdit">
+      <text>编辑</text>
+    </view>
     <view class="bottom-bar" v-if="isEditing">
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
@@ -55,7 +86,7 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, getHealthFoods } from '../../utils/api.js'
+import { getHealthFoods, editHealthReport, queryFoodKnowledge } from '../../utils/api.js'
 
 export default {
   data() {
@@ -65,66 +96,124 @@ export default {
       foods: [],
       isEditing: false,
       editFoods: [],
-      selectedCategory: '全部',
-      sortDesc: true,
-      showRecommendedOnly: false,
-      categories: ['全部', '主食', '蔬菜', '水果', '肉类', '其它']
+      searchText: '',
+      knowledgeVisible: false,
+      knowledgeData: {},
+      _kbCache: {}
     }
   },
   computed: {
     filteredFoods: function() {
       var list = this.isEditing ? this.editFoods : this.foods
-      if (this.selectedCategory !== '全部') {
+      var kw = (this.searchText || '').trim()
+      if (kw) {
         list = list.filter(function(item) {
-          return (item.category || '') === this.selectedCategory
-        }.bind(this))
-      }
-      if (this.showRecommendedOnly) {
-        list = list.filter(function(item) {
-          return (item.score || 0) >= 5
+          var name = item.foodName || item.name || ''
+          return name.indexOf(kw) >= 0
         })
       }
-      list = [].concat(list)
-      list.sort(function(a, b) {
-        var sa = a.score || 0
-        var sb = b.score || 0
-        return this.sortDesc ? sb - sa : sa - sb
-      }.bind(this))
       return list
     }
   },
   onLoad: function(options) {
+    // 不传 reportId 时取当前用户最新报告的食材推荐
     this.reportId = options.reportId ? parseInt(options.reportId) : null
-    if (this.reportId) {
-      this.loadData()
-    }
+    this.loadData()
   },
   methods: {
     goBack: function() {
       uni.navigateBack()
     },
+    /** 推荐值 → 背景色分级:<-30 红,>30 绿,中间每 10 换一档 */
+    _scoreBand: function(score) {
+      var s = parseFloat(score)
+      if (isNaN(s)) return 'null'
+      if (s < -30) return 'neg3'
+      if (s < -20) return 'neg2'
+      if (s < -10) return 'neg1'
+      if (s < 0) return 'neg0'
+      if (s < 10) return 'pos0'
+      if (s < 20) return 'pos1'
+      if (s < 30) return 'pos2'
+      return 'pos3'
+    },
     loadData: function() {
       var self = this
-      getHealthFoods(this.reportId).then(function(res) {
+      getHealthFoods(self.reportId).then(function(res) {
         if (res.code === 200 && res.data) {
-          // 后端 Food 实体字段:name, category, score, protein, fat, carbs, fiber, energyKj, status
-          // 兼容 foodName(旧 draft)和 name(实体),carb/carbs 双字段
-          self.foods = (res.data || []).map(function(item) {
+          // 后端 Food 实体字段:name, category, score;兼容 foodName(旧 draft)
+          self.foods = (Array.isArray(res.data) ? res.data : []).map(function(item) {
             return {
-              foodName: item.name || item.foodName || item.name,
+              foodName: item.name || item.foodName || '',
               name: item.name || item.foodName || '',
               score: item.score != null ? parseInt(item.score, 10) : 0,
               category: item.category || '其它',
-              protein: item.protein != null ? parseFloat(item.protein) : 0,
-              fat: item.fat != null ? parseFloat(item.fat) : 0,
-              carb: item.carbs != null ? parseFloat(item.carbs) : (item.carb != null ? parseFloat(item.carb) : 0),
-              fiber: item.fiber != null ? parseFloat(item.fiber) : 0,
-              energy: item.energyKj != null ? parseFloat(item.energyKj) : (item.energy != null ? parseFloat(item.energy) : 0)
+              protein: item.protein != null ? parseFloat(item.protein) : null,
+              fat: item.fat != null ? parseFloat(item.fat) : null,
+              carb: item.carbs != null ? parseFloat(item.carbs) : (item.carb != null ? parseFloat(item.carb) : null),
+              fiber: item.fiber != null ? parseFloat(item.fiber) : null,
+              energy: item.energyKj != null ? parseFloat(item.energyKj) : (item.energy != null ? parseFloat(item.energy) : null)
             }
           })
         }
       })
     },
+    showFoodKnowledge: function(item) {
+      var self = this
+      var name = item.foodName || item.name || ''
+      if (!name) return
+      // 本地营养数据优先(已在列表里)
+      var local = {
+        itemName: name,
+        category: item.category || '',
+        protein: item.protein,
+        fat: item.fat,
+        carbs: item.carb,
+        fiber: item.fiber,
+        energy: item.energy,
+        description: ''
+      }
+      // 有固定营养数据直接展示,否则查知识库
+      if (item.protein != null || item.fat != null || item.carbs != null) {
+        self.knowledgeData = local
+        self.knowledgeVisible = true
+        return
+      }
+      // 查知识库
+      var cacheKey = 'food:' + name
+      if (self._kbCache[cacheKey]) {
+        self.knowledgeData = self._kbCache[cacheKey]
+        self.knowledgeVisible = true
+        return
+      }
+      queryFoodKnowledge(name).then(function(res) {
+        if (res.code === 200 && res.data) {
+          var d = res.data
+          var kd = {
+            itemName: d['名称'] || name,
+            category: d['分类'] || local.category || '',
+            protein: d['蛋白g'] != null ? d['蛋白g'] : null,
+            fat: d['脂肪g'] != null ? d['脂肪g'] : null,
+            carbs: d['碳水化合物g'] != null ? d['碳水化合物g'] : null,
+            fiber: d['总膳食纤维g'] != null ? d['总膳食纤维g'] : null,
+            energy: d['能量KJ'] != null ? d['能量KJ'] : null,
+            description: d['说明'] || ''
+          }
+          self._kbCache[cacheKey] = kd
+          self.knowledgeData = kd
+        } else {
+          self.knowledgeData = local
+        }
+        self.knowledgeVisible = true
+      }).catch(function() {
+        self.knowledgeData = local
+        self.knowledgeVisible = true
+      })
+    },
+    closeKnowledge: function() {
+      this.knowledgeVisible = false
+      this.knowledgeData = {}
+    },
     toggleEdit: function() {
       this.isEditing = true
       this.editFoods = JSON.parse(JSON.stringify(this.foods))
@@ -151,30 +240,67 @@ export default {
 
 <style scoped>
 .page { min-height: 100vh; background: #f8f8f8; }
-.nav-bar { display: flex; align-items: center; padding: 20rpx 30rpx; background: #fff; position: relative; }
-.nav-back { padding: 10rpx 0; }
-.back-text { font-size: 28rpx; color: #4A9BD7; }
-.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; }
-.nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 0; }
-.filter-bar { background: #fff; padding: 10rpx 20rpx; white-space: nowrap; }
-.filter-item { display: inline-block; padding: 12rpx 28rpx; margin: 0 10rpx; border-radius: 30rpx; font-size: 26rpx; background: #f0f0f0; color: #666; }
-.filter-active { background: #4A9BD7; color: #fff; }
-.sort-bar { display: flex; justify-content: space-between; padding: 16rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; }
-.sort-text { font-size: 24rpx; color: #999; }
-.content { padding: 20rpx 30rpx; height: calc(100vh - 200rpx); }
-.food-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
-.food-header { display: flex; align-items: center; margin-bottom: 12rpx; }
-.food-name { font-size: 30rpx; font-weight: 600; color: #333; flex: 1; }
-.food-cat-tag { padding: 4rpx 16rpx; background: #f0f0f0; border-radius: 20rpx; font-size: 22rpx; color: #666; }
-.food-detail-row { margin-bottom: 6rpx; }
-.food-nutrition { font-size: 24rpx; color: #999; }
-.food-edit-row { display: flex; align-items: center; margin-bottom: 6rpx; }
-.edit-label { font-size: 26rpx; color: #666; margin-right: 12rpx; }
-.edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 120rpx; }
-.food-score { margin-top: 8rpx; font-size: 24rpx; }
-.score-good { color: #10B981; }
-.score-normal { color: #999; }
+
+/* 搜索框 */
+.search-bar { padding: 20rpx 30rpx; background: #fff; }
+.search-input-wrap {
+  display: flex;
+  align-items: center;
+  background: #f5f5f5;
+  border-radius: 40rpx;
+  padding: 12rpx 24rpx;
+}
+.search-icon { font-size: 28rpx; margin-right: 12rpx; }
+.search-input { flex: 1; font-size: 28rpx; }
+.search-clear { font-size: 28rpx; color: #999; padding: 0 8rpx; }
+
+.content { padding: 20rpx 30rpx; height: calc(100vh - 120rpx); }
+
+.food-card {
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+/* 推荐值背景色分级:<-30 深红,>30 深绿,中间每10换一档 */
+.score-band-neg3 { background: #C62828; }
+.score-band-neg2 { background: #E65100; }
+.score-band-neg1 { background: #F57C00; }
+.score-band-neg0 { background: #F9A825; }
+.score-band-pos0 { background: #AFB42B; }
+.score-band-pos1 { background: #7CB342; }
+.score-band-pos2 { background: #43A047; }
+.score-band-pos3 { background: #1B5E20; }
+.score-band-null { background: #f0f0f0; }
+
+.food-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
+.food-name { font-size: 30rpx; font-weight: 600; color: #fff; flex: 1; }
+.food-cat-tag { padding: 4rpx 16rpx; background: rgba(255,255,255,0.3); border-radius: 20rpx; font-size: 22rpx; color: #fff; }
+
+.food-score-row { display: flex; align-items: baseline; gap: 12rpx; }
+.food-score-label { font-size: 22rpx; color: rgba(255,255,255,0.85); }
+.food-score-value { font-size: 40rpx; font-weight: 700; color: #fff; }
+
+.food-edit-row { display: flex; align-items: center; margin-top: 8rpx; }
+.edit-label { font-size: 26rpx; color: #fff; margin-right: 12rpx; }
+.edit-input { border: 1rpx solid #fff; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 140rpx; color: #fff; background: rgba(255,255,255,0.2); }
+
 .empty-hint { text-align: center; color: #ccc; padding: 60rpx 0; font-size: 28rpx; }
+
+/* 知识库弹窗 */
+.knowledge-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
+.knowledge-popup { width: 80%; background: #fff; border-radius: 20rpx; overflow: hidden; }
+.knowledge-header { display: flex; align-items: center; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
+.knowledge-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.knowledge-close { font-size: 32rpx; color: #999; padding: 0 8rpx; }
+.knowledge-body { padding: 30rpx; max-height: 60vh; overflow-y: auto; }
+.knowledge-row { display: flex; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.knowledge-label { font-size: 26rpx; color: #999; width: 120rpx; }
+.knowledge-value { font-size: 26rpx; color: #333; flex: 1; }
+.knowledge-desc { margin-top: 16rpx; }
+.knowledge-desc-text { font-size: 26rpx; color: #666; line-height: 1.6; }
+
+.edit-fab { position: fixed; right: 30rpx; bottom: 40rpx; background: #4A9BD7; color: #fff; border-radius: 40rpx; padding: 16rpx 32rpx; font-size: 26rpx; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
 .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; }
 .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; }

+ 13 - 10
cfc-frontend/pages/health/gut-flora-risks-detail.vue

@@ -24,11 +24,12 @@
         <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"
+      <view class="risk-card important-card" v-for="(item, idx) in importantRisks" :key="idx"
             :class="'risk-level-' + (item._cssClass || 'low')">
         <view class="card-top">
           <view class="card-title-row">
-            <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
+            <text class="risk-help" @tap.stop="showDiseaseKnowledge(item)">?</text>
+            <text class="risk-name">{{ item.diseaseName }}</text>
             <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
               {{ riskBadgeText(item.riskLevel) }}
             </text>
@@ -50,7 +51,6 @@
             {{ 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>
@@ -67,11 +67,12 @@
         <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"
+      <view class="risk-card normal-card" v-for="(item, idx) in normalRisks" :key="idx"
             :class="'risk-level-' + (item._cssClass || 'low')">
         <view class="card-top">
           <view class="card-title-row">
-            <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
+            <text class="risk-help" @tap.stop="showDiseaseKnowledge(item)">?</text>
+            <text class="risk-name">{{ item.diseaseName }}</text>
             <text class="risk-badge" :class="'badge-' + (item._cssClass || 'low')">
               {{ riskBadgeText(item.riskLevel) }}
             </text>
@@ -82,8 +83,7 @@
           <text class="desc-content">{{ item.diseaseDesc }}</text>
         </view>
         <view class="risk-detail-row" v-if="!isEditing">
-          <text class="risk-tag tag-low">{{ item.riskLevel || '--' }}</text>
-          <text class="risk-more" @tap.stop="showKnowledge('disease', item.diseaseName)">查看知识库 ›</text>
+          <text class="risk-tag" :class="'tag-' + (item._cssClass || 'low')">{{ item.riskLevel || '--' }}</text>
         </view>
         <view class="risk-edit-row" v-if="isEditing">
           <text class="edit-label">风险值:</text>
@@ -131,13 +131,13 @@ export default {
     importantRisks: function() {
       return this.getRisks().filter(function(item) {
         var level = item.riskLevel || ''
-        return level === '需注意' || level === '高风险' || level === '异常'
+        return level === '需注意' || level === '注意' || level === '高风险' || level === '异常'
       })
     },
     normalRisks: function() {
       return this.getRisks().filter(function(item) {
         var level = item.riskLevel || ''
-        return level !== '需注意' && level !== '高风险' && level !== '异常'
+        return level !== '需注意' && level !== '注意' && level !== '高风险' && level !== '异常'
       })
     }
   },
@@ -151,7 +151,7 @@ export default {
     goBack: function() { uni.navigateBack() },
     /** 中文风险等级 → CSS安全英文 */
     _cssForRiskLevel: function(level) {
-      var map = { '低风险': 'low', '需注意': 'warning', '高风险': 'high', '异常': 'abnormal' }
+      var map = { '低风险': 'low', '注意': 'warning', '需注意': 'warning', '高风险': 'high', '异常': 'abnormal' }
       return map[level] || 'low'
     },
     /** 风险值归一化进度(0~1范围映射到百分比) */
@@ -226,6 +226,9 @@ export default {
       item.riskLevel = this.riskLevels[e.detail.value]
       item._cssClass = this._cssForRiskLevel(item.riskLevel)
     },
+    showDiseaseKnowledge: function(item) {
+      this.showKnowledge('disease', item && item.diseaseName)
+    },
     showKnowledge: function(type, name) {
       var self = this
       // 优先查询疾病知识库

+ 14 - 12
cfc-frontend/pages/health/gut-flora-species-detail.vue

@@ -86,9 +86,18 @@ export default {
     filteredFlora: function() {
       var list = this.isEditing ? this.editFlora : this.gutFlora
       if (this.selectedTab === 'all') return list
+      // 中文 category 映射到英文 tab key(兼容旧数据)
+      var catToTab = {
+        'core': 'core', '核心菌': 'core', '核心菌属': 'core',
+        'probiotic': 'probiotic', '益生菌': 'probiotic',
+        'harmful': 'harmful', '有害菌': 'harmful',
+        'other': 'other', '其他重要菌属': 'other', '其它重要菌属': 'other',
+        'pathogen_genus': 'pathogen_genus', '病原菌属': 'pathogen_genus',
+        'pathogen_detection': 'pathogen_detection', '病原菌': 'pathogen_detection', '致病菌': 'pathogen_detection'
+      }
       if (this.selectedTab === 'probiotic') {
         return this.isEditing
-          ? this.editFlora.filter(function(i) { return i.category === 'probiotic' })
+          ? this.editFlora.filter(function(i) { return (catToTab[i.category] || i.category) === 'probiotic' })
           : this.probioticSpecies
       }
       // Taxonomy tabs: filter by category='taxonomy' + level
@@ -106,18 +115,11 @@ export default {
           return item.category === 'taxonomy' && item.level === targetLevel
         })
       }
-      // Other category tabs: filter by category field
-      var categoryMap = {
-        'core': 'core',
-        'harmful': 'harmful',
-        'other': 'other',
-        'pathogen_genus': 'pathogen_genus',
-        'pathogen_detection': 'pathogen_detection'
-      }
-      var targetCategory = categoryMap[this.selectedTab]
-      if (targetCategory) {
+      // Other category tabs
+      var targetTab = catToTab[this.selectedTab]
+      if (targetTab) {
         return list.filter(function(item) {
-          return item.category === targetCategory
+          return (catToTab[item.category] || item.category) === targetTab
         })
       }
       return []

+ 128 - 50
cfc-frontend/pages/health/report-list.vue

@@ -27,25 +27,35 @@
 
     <!-- 报告列表 -->
     <view class="report-list" v-else-if="reports.length > 0">
-      <swipe-action v-for="item in reports" :key="item.id">
-        <view class="report-card" @click="goToDetail(item.id, item.reportType)">
-          <view class="report-card-left">
-            <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
-              <text>{{ item.reportType === 'gut_flora' ? '肠道检测' : item.reportType === 'tongue' ? '舌诊' : '体检报告' }}</text>
+      <view class="swipe-row" v-for="item in reports" :key="item.id">
+        <!-- 右侧删除按钮(滑动后露出) -->
+        <view class="swipe-actions">
+          <view class="swipe-delete" @click.stop="confirmDelete(item)">删除</view>
+        </view>
+        <!-- 卡片内容(可左滑) -->
+        <view class="swipe-content"
+              :class="{ 'swipe-moving': curId === item.id }"
+              :style="'transform: translateX(' + (curId === item.id ? moveOffset : (openMap[item.id] ? -80 : 0)) + 'px)'"
+              @touchstart="onTouchStart($event, item)"
+              @touchmove="onTouchMove"
+              @touchend="onTouchEnd"
+              @click="onRowClick(item)">
+          <view class="report-card">
+            <view class="report-card-left">
+              <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
+                <text>{{ item.reportType === 'gut_flora' ? '肠道检测' : item.reportType === 'tongue' ? '舌诊' : '体检报告' }}</text>
+              </view>
+              <text class="report-person" v-if="item.personName">{{ item.personName }}</text>
+              <text class="report-date">报告日期:{{ formatDate(item.reportDate) }}</text>
+              <text class="report-upload-date">上传日期:{{ formatDate(item.createdAt) }}</text>
+            </view>
+            <view class="report-card-right">
+              <text class="report-score">{{ item.overallScore || '--' }}<text class="score-unit">分</text></text>
+              <text class="report-arrow">›</text>
             </view>
-            <text class="report-person" v-if="item.personName">{{ item.personName }}</text>
-            <text class="report-date">报告日期:{{ formatDate(item.reportDate) }}</text>
-            <text class="report-upload-date">上传日期:{{ formatDate(item.createdAt) }}</text>
-          </view>
-          <view class="report-card-right">
-            <text class="report-score">{{ item.overallScore || '--' }}<text class="score-unit">分</text></text>
-            <text class="report-arrow">›</text>
           </view>
         </view>
-        <template #right>
-          <button class="swipe-delete" @click.stop="confirmDelete(item)">删除</button>
-        </template>
-      </swipe-action>
+      </view>
     </view>
 
     <!-- 空状态 -->
@@ -72,7 +82,15 @@ export default {
       members: [],
       selectedMemberId: null,
       loading: false,
-      memberId: null
+      memberId: null,
+      // 左滑删除
+      openMap: {},
+      curId: null,
+      moveOffset: 0,
+      startX: 0,
+      startY: 0,
+      touchDidMove: false,
+      baseOffset: 0
     }
   },
   onLoad(options) {
@@ -128,16 +146,64 @@ export default {
       })
     },
     goToDetail(reportId, reportType) {
-		var url
-		if (reportType === 'gut_flora') {
-			url = '/pages/health/gut-flora-detail?reportId=' + reportId
-		} else if (reportType === 'dan') {
-			url = '/pages/dan-assessment/report-result?reportId=' + reportId
-		} else {
-			url = '/pages/health/report-detail?reportType=' + (reportType || 'gut_flora') + '&reportId=' + reportId
-		}
-		uni.navigateTo({ url: url })
-	},
+ 		var url
+ 		if (reportType === 'gut_flora') {
+ 			url = '/pages/health/gut-flora-detail?reportId=' + reportId
+ 		} else if (reportType === 'dan') {
+ 			url = '/pages/dan-assessment/report-result?reportId=' + reportId
+ 		} else {
+ 			url = '/pages/health/report-detail?reportType=' + (reportType || 'gut_flora') + '&reportId=' + reportId
+ 		}
+ 		uni.navigateTo({ url: url })
+ 	},
+    onRowClick(item) {
+      // 刚滑动过,忽略本次点击
+      if (this.touchDidMove) {
+        this.touchDidMove = false
+        return
+      }
+      // 行处于打开态,点击先收起
+      if (this.openMap[item.id]) {
+        this.$set(this.openMap, item.id, false)
+        return
+      }
+      this.goToDetail(item.id, item.reportType)
+    },
+    onTouchStart(e, item) {
+      // 先收起其它已打开行
+      if (this.curId !== null && this.curId !== item.id && this.openMap[this.curId]) {
+        this.$set(this.openMap, this.curId, false)
+      }
+      var touch = e.touches[0]
+      this.curId = item.id
+      this.startX = touch.clientX
+      this.startY = touch.clientY
+      this.touchDidMove = false
+      this.baseOffset = this.openMap[item.id] ? -80 : 0
+      this.moveOffset = this.baseOffset
+    },
+    onTouchMove(e) {
+      if (this.curId === null) return
+      var touch = e.touches[0]
+      var dx = touch.clientX - this.startX
+      var dy = touch.clientY - this.startY
+      // 横向滑动(水平分量大于垂直分量)
+      if (Math.abs(dx) > Math.abs(dy)) {
+        this.touchDidMove = Math.abs(dx) > 10
+        var offset = this.baseOffset + dx
+        if (offset < -80) offset = -80
+        if (offset > 0) offset = 0
+        this.moveOffset = offset
+      }
+    },
+    onTouchEnd() {
+      if (this.curId === null) return
+      // 位移超过阈值则打开,否则收起
+      var shouldOpen = this.moveOffset < -20
+      this.$set(this.openMap, this.curId, shouldOpen)
+      this.moveOffset = shouldOpen ? -80 : 0
+      this.curId = null
+    },
     goToUpload() {
       uni.navigateTo({ url: '/pages/health/report-upload' })
     },
@@ -234,16 +300,47 @@ export default {
 .report-list {
   margin: 20rpx 30rpx;
 }
-.report-card {
-  background: #fff;
+.swipe-row {
+  position: relative;
+  overflow: hidden;
   border-radius: 16rpx;
-  padding: 28rpx 24rpx;
   margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.swipe-actions {
+  position: absolute;
+  top: 0;
+  right: 0;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+}
+.swipe-delete {
+  width: 80px;
+  height: 100%;
+  background: #F56C6C;
+  color: #fff;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.swipe-content {
+  position: relative;
+  z-index: 2;
+  background: #fff;
+  transition: transform 0.25s ease;
+}
+.swipe-moving {
+  transition: none;
+}
+.report-card {
+  border-radius: 0;
+  padding: 28rpx 24rpx;
   display: flex;
   flex-direction: row;
   align-items: center;
   justify-content: space-between;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
 }
 .report-card:active {
   opacity: 0.8;
@@ -346,25 +443,6 @@ export default {
   opacity: 0.8;
 }
 
-/* ===== 滑动删除 ===== */
-.swipe-delete {
-  height: 100%;
-  width: 160rpx;
-  background: #F56C6C;
-  color: #fff;
-  font-size: 28rpx;
-  border: none;
-  border-radius: 0;
-}
-
-/* swipe-action 组件内部样式覆盖 */
-/deep/ .uni-swipe-action__content {
-  border-radius: 16rpx;
-}
-/deep/ .uni-swipe-action__button {
-  border-radius: 0 16rpx 16rpx 0;
-}
-
 /* ===== 底部 ===== */
 .bottom-spacer {
   height: 60rpx;

+ 5 - 1
cfc-frontend/pages/health/survey-history.vue

@@ -32,8 +32,12 @@ export default {
     var ui = uni.getStorageSync('userInfo') || {}
     this.memberId = options.memberId || uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
     this.loadData()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) { this._onLoadFired = false }
+    else if (this.memberId) this.loadData()
   },
-  onShow() { if (this.memberId) this.loadData() },
   methods: {
     getRecordKey(item) { return item && item.id ? 'r-' + item.id : 'r-' + Math.random() },
     getRecordTitle(item) { if (item.title) return item.title; if (this.templateMap[item.templateId]) return this.templateMap[item.templateId]; return '调研记录' },

+ 8 - 2
cfc-frontend/pages/home-pages/parent-index.vue

@@ -716,6 +716,7 @@ export default {
 
         // 优先使用仪表盘接口(包含孩子列表 + 统计数据)
         let dashboardLoaded = false
+        var _membersVisibleSet = false
         try {
           const dashRes = await getParentDashboard()
           const dashData = dashRes.data || {}
@@ -752,6 +753,9 @@ export default {
             const parents = members.filter(function(p) { return p.source === 'parent' && p.id && p.id !== currentUserId }).map(function(p) { return { ...p, role: 'parent' } })
             const children = members.filter(function(c) { return c.source !== 'parent' && c.id }).map(function(c) { return { ...c, role: 'child' } })
             this.familyMembers = [].concat(parents, children)
+            // 同步设置 familyMembersVisible,避免 loadFamilyMembersVisible() 重复调用 getFamilyMemberList
+            this.familyMembersVisible = members
+            _membersVisibleSet = true
             // 不自动选孩子:默认显示家长自己,等待用户从 FamilyMemberStrip 选择
             // 保存 familyId 用于后续请求
             if (data.familyId) {
@@ -900,8 +904,10 @@ export default {
         // 加载推荐商品
         this.loadRecommendedProducts()
 
-        // 加载家庭成员可见列表
-        this.loadFamilyMembersVisible()
+        // 加载家庭成员可见列表(降级路径已在上面设置,此处跳过避免重复请求)
+        if (!_membersVisibleSet) {
+          this.loadFamilyMembersVisible()
+        }
         this.loadSelfCheckStatus()
         // 加载健康预警(P1-2)
         this.loadAlerts()

+ 37 - 24
cfc-frontend/pages/index-home/index.vue

@@ -396,7 +396,7 @@ import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
-import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode } from '../../utils/api.js'
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
@@ -570,6 +570,7 @@ membershipDays: 0,
     }
   },
   onLoad(options) {
+    this._onLoadFired = true
     this._doInit(options)
   },
   onShow() {
@@ -591,8 +592,13 @@ membershipDays: 0,
       }, 5000)
     }
 
-    // 始终执行初始化,确保登录态变更后及时切换视图
-    this._doInit()
+    // 首次加载:onLoad 已触发 _doInit,此处跳过避免重复调用
+    // 后续 onShow(切 Tab 返回):刷新易变数据(会员状态、今日任务、挑战列表)
+    if (this._onLoadFired) {
+      this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
+    } else {
+      this._refreshOnShow()
+    }
     // 定期调研待办角标(内部自检登录态,未登录静默清空)
     this._loadSurveyBadge()
   },
@@ -624,15 +630,18 @@ membershipDays: 0,
 
     // ===== 登录态数据加载 =====
     loadLoggedInData: function() {
-      this.loadFamilyMembers()
-      this.loadChildren()
+      this.loadFamilyData()
       this.loadReferralCode()
     },
-    loadChildren: function() {
+    // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
+    loadFamilyData: function() {
       var self = this
-      getChildren().then(function(res) {
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res.code === 200 && res.data) {
-          self.children = res.data
+          var arr = Array.isArray(res.data) ? res.data : (res.data.list || [])
+          self.familyMembersVisible = arr
+          // 过滤出孩子成员(effectiveRole 为 'child' 的成员)
+          self.children = arr.filter(function(m) { return m.effectiveRole === 'child' })
           var savedChildId = uni.getStorageSync('currentChildId')
           if (savedChildId) {
             self.currentChildId = savedChildId
@@ -640,15 +649,22 @@ membershipDays: 0,
           self.loadDimensionData()
         }
         self.childrenChecked = true
+        self.membersChecked = true
         self.loadTodayTasks()
         self.loadFamilyFeed()
         self.loadNotices()
         self.loadNotifications()
       }).catch(function(e) {
-        console.log('获取孩子列表失败', e)
+        console.log('获取家庭成员失败', e)
+        self.familyMembersVisible = []
+        self.children = []
         self.childrenChecked = true
+        self.membersChecked = true
       })
     },
+    // loadChildren 已废弃:loadFamilyData 统一调用 getFamilyMemberList 一次,
+    // 从响应中过滤出 effectiveRole==='child' 的成员赋给 this.children。
+    // 删除了原 loadChildren 方法(调用 getChildren 会重复打 /api/family/member/list)。
     loadDimensionData: function() {
       this.loadSandboxData()
       this.loadEnergyOverview()
@@ -774,7 +790,8 @@ membershipDays: 0,
     },
     loadFamilyFeed: function() {
       var self = this
-      var children = self.children || []
+      // 仅对孩子成员(effectiveRole === 'child')拉取任务历史,避免对家长也发起请求
+      var children = (self.children || []).filter(function(m) { return m.effectiveRole === 'child' })
       if (children.length === 0) {
         self.familyFeed = []
         return
@@ -906,19 +923,7 @@ membershipDays: 0,
         uni.showToast({ title: '操作失败,请重试', icon: 'none' })
       })
     },
-    loadFamilyMembers: function() {
-      var self = this
-      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.familyMembersVisible = Array.isArray(res.data) ? res.data : (res.data.list || [])
-        }
-        self.membersChecked = true
-      }).catch(function(e) {
-        console.log('获取可见家庭成员失败', e)
-        self.familyMembersVisible = []
-        self.membersChecked = true
-      })
-    },
+    // loadFamilyMembers 已合并到 loadFamilyData(避免重复调用 /api/family/member/list)
     parseRelatedDimensions: function(val) {
       if (!val) return []
       if (Array.isArray(val)) return val
@@ -1045,12 +1050,20 @@ membershipDays: 0,
       }
       // 登录用户:直接在 TabBar 页面内联渲染,保持底签可见
       if (!this.loggedInInitDone) {
+        // 首次加载:全量初始化(数据 + 会员状态),onShow 不再重复
         this.loggedInInitDone = true
         this.loadLoggedInData()
+        this.checkMemberStatus()
       }
-      this.checkMemberStatus()
       this._watchPageReady()
     },
+    _refreshOnShow() {
+      // 切 Tab 返回时刷新易变数据(仅会员状态、今日任务、挑战列表),避免重复加载
+      if (!this.isLoggedIn()) return
+      this.checkMemberStatus()
+      this.loadTodayTasks()
+      this.loadChallenges()
+    },
     _watchPageReady() {
       if (this._watchPageReadyDone) return
       this._watchPageReadyDone = true

+ 6 - 0
cfc-frontend/pages/invite/join.vue

@@ -106,6 +106,7 @@ export default {
     }
   },
   onLoad(query) {
+    this._onLoadFired = true
     query = query || {}
     
     // 1. 解析家庭邀请码(单一参数,兼容 inviteCode/familyInviteCode)
@@ -153,6 +154,11 @@ export default {
     this.checkLoginStatus()
   },
   onShow() {
+    // 首次进入:onLoad 已处理,跳过避免双发;从登录页返回时重新检查
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+      return
+    }
     // 从登录页返回,重新检查
     if (this.familyInviteCode) {
       this.checkLoginStatus()

+ 6 - 1
cfc-frontend/pages/notification/index.vue

@@ -39,9 +39,14 @@ export default {
   },
   onLoad() {
     this.loadList()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadList()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadList()
+    }
   },
   onPullDownRefresh() {
     this.loadList()

+ 6 - 1
cfc-frontend/pages/profile-extra/onboarding.vue

@@ -80,9 +80,14 @@ export default {
   },
   onLoad() {
     this.loadProgress()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadProgress()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadProgress()
+    }
   },
   methods: {
     async loadProgress() {

+ 55 - 110
cfc-frontend/pages/promotion/commission.vue

@@ -4,63 +4,53 @@
     <view class="header-card">
       <view class="header-row">
         <view class="header-item">
-          <text class="header-label">累计佣金</text>
-          <text class="header-value orange" v-if="summary">{{ formatPriceWithSymbol(summary.totalCommission) }}</text>
-          <text class="header-value orange" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">累计获取</text>
+          <text class="header-value orange" v-if="summary">{{ cfToYuan(summary.totalEarned) }}</text>
+          <text class="header-value orange" v-else>¥0.00</text>
         </view>
         <view class="header-item">
-          <text class="header-label">可提现</text>
-          <text class="header-value blue" v-if="summary">{{ formatPriceWithSymbol(summary.availableAmount) }}</text>
-          <text class="header-value blue" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">可用余额</text>
+          <text class="header-value blue" v-if="summary">{{ cfToYuan(summary.available) }}</text>
+          <text class="header-value blue" v-else>¥0.00</text>
         </view>
         <view class="header-item">
-          <text class="header-label">待结算</text>
-          <text class="header-value green" v-if="summary">{{ formatPriceWithSymbol(summary.pendingAmount) }}</text>
-          <text class="header-value green" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">冻结</text>
+          <text class="header-value green" v-if="summary">{{ cfToYuan(summary.frozen) }}</text>
+          <text class="header-value green" v-else>¥0.00</text>
         </view>
       </view>
     </view>
 
-    <!-- Tab 切换 -->
-    <view class="tab-bar">
-      <view class="tab-item" :class="currentTab === 'all' ? 'tab-active' : ''" @click="switchTab('all')">
-        <text class="tab-text">全部</text>
-      </view>
-      <view class="tab-item" :class="currentTab === 'settled' ? 'tab-active' : ''" @click="switchTab('settled')">
-        <text class="tab-text">已结算</text>
-      </view>
-      <view class="tab-item" :class="currentTab === 'pending' ? 'tab-active' : ''" @click="switchTab('pending')">
-        <text class="tab-text">待结算</text>
-      </view>
-    </view>
-
-    <!-- 佣金列表 -->
+    <!-- CF 流水列表 -->
     <view class="list-section">
       <view class="list-item" v-for="item in list" :key="item.id">
         <view class="item-left">
-          <text :class="['type-tag', item.commissionType === 'member' ? 'tag-orange' : 'tag-blue']">
-            {{ item.commissionType === 'member' ? '会员佣金' : '商品佣金' }}
+          <text :class="['type-tag', isPositive(item.type) ? 'tag-orange' : 'tag-blue']">
+            {{ getTypeText(item.type) }}
           </text>
-          <text class="item-desc">{{ getOrderDesc(item) }}</text>
+          <text class="item-desc">{{ item.remark || 'CF值变动' }}</text>
         </view>
         <view class="item-right">
-          <text class="item-amount">+{{ formatPriceWithSymbol(item.commissionAmount) }}</text>
-          <text class="item-status" :class="item.status === 'settled' ? 'status-settled' : item.status === 'cancelled' ? 'status-cancelled' : 'status-pending'">{{ getStatusText(item.status) }}</text>
+          <text :class="['item-amount', isPositive(item.type) ? 'amount-positive' : 'amount-negative']">{{ getAmountText(item) }}</text>
+          <text class="item-status">{{ getRefText(item.refType) }}</text>
           <text class="item-time">{{ formatTime(item.createdAt) }}</text>
         </view>
       </view>
       <view class="empty-state" v-if="!loading && list.length === 0">
-        <text>暂无佣金记录</text>
+        <text>暂无 CF 流水记录</text>
       </view>
       <view class="loading-state" v-if="loading">
         <text>加载中...</text>
       </view>
+      <view class="loadmore-state" v-if="!loading && list.length > 0 && !hasMore">
+        <text>没有更多了</text>
+      </view>
     </view>
   </view>
 </template>
 
 <script>
-import { getCommissionList, getCommissionSummary, getCommissionStats } from '../../utils/api.js'
+import { getCfSummary, getCfList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -70,8 +60,7 @@ export default {
       summary: {},
       page: 1,
       hasMore: true,
-      loading: false,
-      currentTab: 'all'
+      loading: false
     }
   },
   onLoad() {
@@ -85,35 +74,20 @@ export default {
     }
   },
   methods: {
-    switchTab(tab) {
-      if (this.currentTab === tab) return
-      this.currentTab = tab
-      this.page = 1
-      this.list = []
-      this.loadList()
-    },
     async loadSummary() {
       try {
-        const res = await getCommissionSummary()
+        var res = await getCfSummary()
         if (res.data) {
           this.summary = res.data
         }
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
     },
     async loadList() {
       this.loading = true
       try {
-        var page = this.page
-        var size = 20
-        var status = ''
-        if (this.currentTab === 'settled') {
-          status = 'settled'
-        } else if (this.currentTab === 'pending') {
-          status = 'pending'
-        }
-        const res = await getCommissionList(page, size, status)
+        var res = await getCfList(this.page, 20)
         if (res.data && res.data.records) {
           this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
           this.hasMore = res.data.records.length >= 20
@@ -121,33 +95,39 @@ export default {
           this.hasMore = false
         }
       } catch (e) {
-        console.error('获取佣金明细失败', e)
+        console.error('获取 CF 流水失败', e)
       }
       this.loading = false
     },
-    getOrderDesc(item) {
-      var typeMap = {
-        membership: '会员',
-        product: '商品',
-        assessment: '测评',
-        package: '任务模板'
-      }
-      return (typeMap[item.orderType] || item.orderType) + '订单 #' + item.orderId
+    isPositive(type) {
+      return type === 'earn' || type === 'unfreeze'
     },
-    getStatusText(status) {
-      var map = { settled: '已结算', pending: '待结算', cancelled: '已取消' }
-      return map[status] || status || '待结算'
+    getAmountText(item) {
+      var prefix = this.isPositive(item.type) ? '+' : '-'
+      return prefix + this.cfToYuan(item.amount)
     },
-    getStatusClass(status) {
-      if (status === 'settled') return 'status-settled'
-      if (status === 'cancelled') return 'status-cancelled'
-      return 'status-pending'
+    getTypeText(type) {
+      var map = { earn: '获得', spend: '支出', freeze: '冻结', unfreeze: '解冻', withdraw: '提现' }
+      return map[type] || type || '变动'
+    },
+    getRefText(refType) {
+      var map = { product_order: '商品订单', activity: '活动', withdrawal: '提现', migration: '迁移', cf_transfer_out: '转让转出', cf_transfer_in: '转让转入' }
+      return map[refType] || ''
+    },
+    cfToYuan(val) {
+      if (val === null || val === undefined) return '¥0.00'
+      var num = Number(val)
+      if (isNaN(num)) return '¥0.00'
+      return '¥' + (num / 100).toFixed(2)
     },
     formatTime(time) {
       if (!time) return ''
       var d = parseDate(time)
       if (!d) return ''
-      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
+             String(d.getHours()).padStart(2, '0') + ':' +
+             String(d.getMinutes()).padStart(2, '0') + ':' +
+             String(d.getSeconds()).padStart(2, '0')
     }
   }
 }
@@ -187,33 +167,6 @@ export default {
 .header-value.blue { color: #0EA5E9; }
 .header-value.green { color: #10B981; }
 
-/* ===== Tab切换 ===== */
-.tab-bar {
-  display: flex;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 8rpx;
-  margin-bottom: 24rpx;
-  box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.06);
-}
-.tab-item {
-  flex: 1;
-  text-align: center;
-  padding: 16rpx 0;
-  border-radius: 12rpx;
-}
-.tab-active {
-  background: #F97316;
-}
-.tab-active .tab-text {
-  color: #fff;
-  font-weight: 600;
-}
-.tab-text {
-  font-size: 26rpx;
-  color: #64748B;
-}
-
 .list-section {
   background: #fff;
   border-radius: 20rpx;
@@ -258,35 +211,27 @@ export default {
 .item-amount {
   font-size: 30rpx;
   font-weight: bold;
-  color: #F97316;
   display: block;
 }
+.amount-positive {
+  color: #F97316;
+}
+.amount-negative {
+  color: #94A3B8;
+}
 .item-status {
   font-size: 20rpx;
-  padding: 2rpx 10rpx;
-  border-radius: 6rpx;
+  color: #94A3B8;
   display: inline-block;
   margin-top: 4rpx;
 }
-.status-settled {
-  color: #10B981;
-  background: #D1FAE5;
-}
-.status-pending {
-  color: #D97706;
-  background: #FEF3C7;
-}
-.status-cancelled {
-  color: #94A3B8;
-  background: #F1F5F9;
-}
 .item-time {
   font-size: 20rpx;
   color: #94A3B8;
   display: block;
   margin-top: 4rpx;
 }
-.empty-state, .loading-state {
+.empty-state, .loading-state, .loadmore-state {
   text-align: center;
   padding: 60rpx;
   color: #94A3B8;

+ 16 - 17
cfc-frontend/pages/promotion/index.vue

@@ -115,21 +115,21 @@
       </view>
       <view class="earnings-body">
         <view class="earning-item">
-          <text class="earning-label">累计佣金</text>
-          <text class="earning-value orange" v-if="summary">{{ formatPriceWithSymbol(summary.totalCommission) }}</text>
-          <text class="earning-value orange" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">累计获取</text>
+          <text class="earning-value orange" v-if="summary">{{ cfToYuan(summary.totalEarned) }}</text>
+          <text class="earning-value orange" v-else>¥0.00</text>
         </view>
         <view class="earning-divider"></view>
         <view class="earning-item">
-          <text class="earning-label">可提现</text>
-          <text class="earning-value blue" v-if="summary">{{ formatPriceWithSymbol(summary.availableAmount) }}</text>
-          <text class="earning-value blue" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">可用余额</text>
+          <text class="earning-value blue" v-if="summary">{{ cfToYuan(summary.available) }}</text>
+          <text class="earning-value blue" v-else>¥0.00</text>
         </view>
         <view class="earning-divider"></view>
         <view class="earning-item">
-          <text class="earning-label">待结算</text>
-          <text class="earning-value green" v-if="summary">{{ formatPriceWithSymbol(summary.pendingAmount) }}</text>
-          <text class="earning-value green" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">冻结</text>
+          <text class="earning-value green" v-if="summary">{{ cfToYuan(summary.frozen) }}</text>
+          <text class="earning-value green" v-else>¥0.00</text>
         </view>
       </view>
     </view>
@@ -206,7 +206,7 @@
 </template>
 
 <script>
-import { getReferralSummary, getCommissionSummary, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings, getInviteQrCode, getFamilyMemberList, getReferralCode } from '../../utils/api.js'
+import { getReferralSummary, getCfSummary, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings, getInviteQrCode, getFamilyMemberList, getReferralCode } from '../../utils/api.js'
 import SharePoster from '@/components/SharePoster.vue'
 
 export default {
@@ -313,13 +313,13 @@ export default {
         console.error('获取邀请统计失败', e)
       }
       try {
-        var comRes = await getCommissionSummary()
+        var comRes = await getCfSummary()
         if (comRes.data) {
           this.summary = comRes.data
-          this.availableAmount = comRes.data.availableAmount || '0.00'
+          this.availableAmount = comRes.data.available || '0.00'
         }
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
     },
     async loadFamilyData() {
@@ -414,12 +414,11 @@ export default {
       if (isNaN(num)) return '0.00'
       return (num / 100).toFixed(2)
     },
-    formatPriceWithSymbol(val) {
-      if (!val && val !== 0) return '¥0.00'
+    cfToYuan(val) {
+      if (val === null || val === undefined) return '¥0.00'
       var num = Number(val)
       if (isNaN(num)) return '¥0.00'
-      // val 已经是元单位(后端返回的 commission summary 是元)
-      return '¥' + Number(num).toFixed(2)
+      return '¥' + (num / 100).toFixed(2)
     },
     // 管理员操作
     toggleMemberAccess(m) {

+ 12 - 12
cfc-frontend/pages/promotion/team.vue

@@ -3,21 +3,21 @@
     <!-- 团队统计 -->
     <view class="stats-card">
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ teamStats.l1Count || 0 }}</text>
+        <text class="stats-num" v-if="cfRate">{{ cfRate.totalTeamSize || 0 }}</text>
         <text class="stats-num" v-else>0</text>
-        <text class="stats-label">直推(L1)</text>
+        <text class="stats-label">团队规模</text>
       </view>
       <view class="stats-divider"></view>
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ teamStats.l2Count || 0 }}</text>
-        <text class="stats-num" v-else>0</text>
-        <text class="stats-label">间推(L2)</text>
+        <text class="stats-num" v-if="cfRate">{{ (cfRate.ratePercent || 0) + '%' }}</text>
+        <text class="stats-num" v-else>0%</text>
+        <text class="stats-label">返佣比例</text>
       </view>
       <view class="stats-divider"></view>
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ formatPriceWithSymbol(teamStats.totalCommission) }}</text>
-        <text class="stats-num" v-else>{{ formatPriceWithSymbol(0) }}</text>
-        <text class="stats-label">团队佣金</text>
+        <text class="stats-num" v-if="cfRate && cfRate.tierName">{{ cfRate.tierName }}</text>
+        <text class="stats-num" v-else>未入档</text>
+        <text class="stats-label">当前档位</text>
       </view>
     </view>
 
@@ -64,7 +64,7 @@
 </template>
 
 <script>
-import { getCommissionTeam, getTeamList } from '../../utils/api.js'
+import { getCfRate, getTeamList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -76,7 +76,7 @@ export default {
       level: 'L1',
       loading: false,
       hasMore: true,
-      teamStats: null
+      cfRate: null
     }
   },
   onLoad() {
@@ -92,9 +92,9 @@ export default {
   methods: {
     async loadTeamStats() {
       try {
-        var res = await getCommissionTeam(1, 1)
+        var res = await getCfRate()
         if (res.data) {
-          this.teamStats = res.data
+          this.cfRate = res.data
         }
       } catch (e) {
         console.error('获取团队统计失败', e)

+ 6 - 1
cfc-frontend/pages/shop/address/address.vue

@@ -69,9 +69,14 @@ export default {
       this.selectMode = options.selectMode === '1'
     }
     this.loadAddressList()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadAddressList()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadAddressList()
+    }
   },
   methods: {
     loadAddressList() {

+ 4 - 1
cfc-frontend/pages/shop/cart/cart.vue

@@ -126,9 +126,12 @@ export default {
     this.userId = uni.getStorageSync('userId')
     this.checkMemberStatus()
     this.loadCart()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.items.length > 0) {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else if (this.items.length > 0) {
       this.loadCart()
     }
   },

+ 7 - 2
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -537,10 +537,15 @@ export default {
     this.loadCfBalance()
     this.loadUserPoints()
     this.loadDeliveryConfig()
+    this._onLoadFired = true
   },
   onShow() {
-    // 从地址选择/编辑页返回后重新加载地址列表
-    this.loadDefaultAddress()
+    // 从地址选择/编辑页返回后重新加载地址列表(首次进入跳过,避免与 onLoad 双发)
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadDefaultAddress()
+    }
   },
   methods: {
     calcAmount() {

+ 8 - 3
cfc-frontend/pages/shop/index/index.vue

@@ -175,11 +175,16 @@ export default {
     this.loadProducts()
     this.loadRecommended()
     this.loadCartCount()
+    this._onLoadFired = true
   },
   onShow() {
-    this.checkMemberStatus()
-    this.isLoggedIn = !!uni.getStorageSync('token')
-    this.loadCartCount()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.checkMemberStatus()
+      this.isLoggedIn = !!uni.getStorageSync('token')
+      this.loadCartCount()
+    }
   },
   methods: {
     loadCategories() {

+ 6 - 1
cfc-frontend/pages/tasks/daily-tasks.vue

@@ -98,9 +98,14 @@ export default {
   },
   onLoad() {
     this.loadTasks()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadTasks()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadTasks()
+    }
   },
   methods: {
     switchTab(index) {

+ 6 - 1
cfc-frontend/pages/teacher/service-tasks.vue

@@ -81,9 +81,14 @@ export default {
   },
   onLoad() {
     this.loadTasks()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadTasks()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadTasks()
+    }
   },
   methods: {
     async loadTasks() {

+ 6 - 1
cfc-frontend/pages/teacher/teacher-home.vue

@@ -167,9 +167,14 @@ export default {
   },
   onLoad() {
     this.loadData()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   methods: {
     loadData() {

+ 6 - 1
cfc-frontend/pages/wealth-cf/index.vue

@@ -114,9 +114,14 @@ export default {
     this.checkLogin()
     this.loadBalance()
     this.loadLogs()
+    this._onLoadFired = true
   },
   onShow() {
-    if (this.isLoggedIn) this.loadBalance()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      if (this.isLoggedIn) this.loadBalance()
+    }
   },
   methods: {
     checkLogin() {

+ 234 - 0
cfc-frontend/scripts/audit-duplicate-api-calls.js

@@ -0,0 +1,234 @@
+/**
+ * 重复 API 调用审计脚本
+ *
+ * 检测三类违规(违反 AGENTS.md「API 调用去重」规范):
+ *   A. 生命周期重复触发:同一个方法 / API 同时在 onLoad 和 onShow(或 onLoad+mounted 等
+ *      两个生命周期钩子)里被调用,首次进入页面时会双发。
+ *   B. 同接口多封装:utils/api.js 里多个导出函数指向同一个后端 URL(params 不同算
+ *      合法变体,不算违规;纯重复封装算违规)。
+ *   C. N+1 循环调用:在 .map / forEach / for 循环里调用 API 包装函数,N 个元素 → N 次请求。
+ *
+ * 用法:node scripts/audit-duplicate-api-calls.js [--pages-dir=pages]
+ * 退出码:0 = 无违规;1 = 发现违规(CI 可直接用作门禁)
+ */
+var fs = require('fs')
+var path = require('path')
+
+var root = path.resolve(__dirname, '..')
+var pagesDir = process.argv.slice(2).reduce(function (acc, a) {
+  var m = a.match(/^--pages-dir=(.+)$/)
+  return m ? m[1] : acc
+}, 'pages')
+var scanRoot = path.join(root, pagesDir)
+if (!fs.existsSync(scanRoot)) {
+  console.error('[audit] pages dir not found: ' + scanRoot)
+  process.exit(2)
+}
+
+// ---------- 工具:递归收集 .vue ----------
+function walk(dir, out) {
+  var entries = fs.readdirSync(dir)
+  for (var i = 0; i < entries.length; i++) {
+    var p = path.join(dir, entries[i])
+    var st = fs.statSync(p)
+    if (st.isDirectory()) walk(p, out)
+    else if (p.slice(-4) === '.vue') out.push(p)
+  }
+  return out
+}
+
+// ---------- 工具:取 <script> 块 ----------
+function extractScript(src) {
+  var m = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)
+  return m ? m[1] : ''
+}
+
+// ---------- 工具:按大括号深度截取某个方法体 ----------
+// 在 script 文本里定位 `name(args) {`,从 `{` 开始做大括号配对,返回方法体字符串
+function hookBody(script, hookName) {
+  // 匹配 onShow / onLoad / onShow(options) / onTabItemTap(e) 等,允许前面有空格/换行
+  var re = new RegExp('\\b' + hookName + '\\s*\\([^)]*\\)\\s*\\{', 'g')
+  var m
+  while ((m = re.exec(script)) !== null) {
+    var braceStart = script.indexOf('{', m.index)
+    if (braceStart < 0) return null
+    var depth = 0
+    var i = braceStart
+    var inStr = null // ' " `
+    var inLineComment = false
+    var inBlockComment = false
+    for (; i < script.length; i++) {
+      var c = script[i]
+      var next = script[i + 1]
+      if (inLineComment) { if (c === '\n') inLineComment = false; continue }
+      if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i++ }; continue }
+      if (inStr) {
+        if (c === '\\') { i++; continue }
+        if (c === inStr) inStr = null
+        continue
+      }
+      if (c === '/' && next === '/') { inLineComment = true; i++; continue }
+      if (c === '/' && next === '*') { inBlockComment = true; i++; continue }
+      if (c === '"' || c === "'" || c === '`') { inStr = c; continue }
+      if (c === '{') depth++
+      else if (c === '}') {
+        depth--
+        if (depth === 0) return script.slice(braceStart + 1, i)
+      }
+    }
+  }
+  return null
+}
+
+// ---------- 工具:提取方法调用名 ----------
+// this.foo(  -> foo ;以及裸函数调用 foo( 当作直接 API 调用候选
+function callsIn(body) {
+  var calls = []
+  if (!body) return calls
+  // this.xxx(  / that.xxx(  / self.xxx(
+  var reThis = /\b(?:this|that|self)\.([A-Za-z_$][\w$]*)\s*\(/g
+  var m
+  while ((m = reThis.exec(body)) !== null) calls.push(m[1])
+  return calls
+}
+
+// ---------- 规则 A:生命周期双发 ----------
+var HOOKS = ['onLoad', 'onShow', 'onTabItemTap', 'mounted', 'created', 'beforeMount']
+var violations = []
+
+var files = walk(scanRoot, [])
+for (var f = 0; f < files.length; f++) {
+  var file = files[f]
+  var rel = path.relative(root, file).replace(/\\/g, '/')
+  var src = fs.readFileSync(file, 'utf8')
+  var script = extractScript(src)
+  if (!script) continue
+
+  var hookCalls = {} // hookName -> Set(method)
+  HOOKS.forEach(function (h) {
+    var body = hookBody(script, h)
+    hookCalls[h] = callsIn(body)
+  })
+
+  // 任两个钩子共享同一方法名 → 双发风险
+  for (var i = 0; i < HOOKS.length; i++) {
+    for (var j = i + 1; j < HOOKS.length; j++) {
+      var a = HOOKS[i], b = HOOKS[j]
+      if (!hookCalls[a] || !hookCalls[b]) continue
+      var shared = hookCalls[a].filter(function (m) { return hookCalls[b].indexOf(m) !== -1 })
+      if (shared.length === 0) continue
+      // 看该方法体里有没有"已初始化"守卫标志(_initDone / _inited / _loaded 等)
+      // 如果有守卫,降级为 warning 而非 error
+      var guarded = false
+      shared.forEach(function (m) {
+        var mbody = hookBody(script, m)
+        if (mbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(mbody)) guarded = true
+      })
+      // 也检查生命周期钩子本身是否含守卫标志(_onLoadFired 通常写在 onLoad/onShow 里)
+      ;[a, b].forEach(function (h) {
+        var hbody = hookBody(script, h)
+        if (hbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(hbody)) guarded = true
+      })
+      violations.push({
+        type: 'LIFECYCLE_DOUBLE',
+        severity: guarded ? 'warn' : 'error',
+        file: rel,
+        hooks: a + ' + ' + b,
+        methods: shared.join(', '),
+        guarded: guarded
+      })
+    }
+  }
+
+  // 规则 C:N+1 —— methods 里某方法在 .map( / .forEach( / for(...) 里调用 API 包装
+  // 粗略检测:方法体里同时出现 \.(map|forEach)\s*\( 和某个形如 getXxx( 的调用
+  // 进一步限定:只看 methods 块。这里简化:在整个 script 里查 .map(/.forEach( 后续出现
+  // 的 API 包装函数调用(名称以 get/fetch/load/list 开头)。
+  var nplus = script.match(/\.map\s*\([^]*?\b(get|fetch|load|list)[A-Za-z]*\s*\(/)
+  if (nplus) {
+    // 更精确:逐方法体检查
+    var methodRe = /\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g
+    var mm
+    while ((mm = methodRe.exec(script)) !== null) {
+      // 跳过生命周期钩子
+      if (HOOKS.indexOf(mm[1]) !== -1) continue
+      var mbody = hookBody(script, mm[1])
+      if (!mbody) continue
+      if (/\.(map|forEach)\s*\(/.test(mbody) && /\b(get|fetch|load|list)[A-Za-z]*\s*\(/.test(mbody)) {
+        violations.push({
+          type: 'N_PLUS_1',
+          severity: 'warn',
+          file: rel,
+          hooks: 'method:' + mm[1],
+          methods: mm[1],
+          guarded: false
+        })
+      }
+    }
+  }
+}
+
+// ---------- 规则 B:同接口多封装(扫 utils/api.js)----------
+var apiFile = path.join(root, 'utils', 'api.js')
+if (fs.existsSync(apiFile)) {
+  var apiSrc = fs.readFileSync(apiFile, 'utf8')
+  // url -> [{ name, params }]  params = request() 第 3 个参数字符串
+  var urlMap = {}
+  var reWrap = /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*\{[^}]*?request\(\s*([^,)]+),\s*['"`]POST['"`]\s*(?:,\s*([^)]+?))?\s*\)/g
+  var mw
+  while ((mw = reWrap.exec(apiSrc)) !== null) {
+    var name = mw[1]
+    var urlRaw = mw[2].trim()
+    var url = urlRaw.replace(/['"`]/g, '')
+    if (/\$\{/.test(urlRaw)) url = url.replace(/\$\{[^}]+\}/g, '{id}')
+    var params = (mw[3] || '').trim()
+    if (!urlMap[url]) urlMap[url] = []
+    if (!urlMap[url].some(function (e) { return e.name === name })) urlMap[url].push({ name: name, params: params })
+  }
+  Object.keys(urlMap).forEach(function (url) {
+    var entries = urlMap[url]
+    if (entries.length <= 1) return
+    // 判断"真重复"(参数实质相同)vs"过滤变体/不同操作"(参数不同)
+    var isTrueDup = true
+    for (var i = 0; i < entries.length - 1; i++) {
+      var a = entries[i].params, b = entries[i + 1].params
+      var am = a.match(/_method:\s*['"`](\w+)['"`]/)
+      var bm = b.match(/_method:\s*['"`](\w+)['"`]/)
+      if (am && bm && am[1] !== bm[1]) { isTrueDup = false; break }
+      var aVar = /^[A-Za-z_$][\w$]*$/.test(a)
+      var bVar = /^[A-Za-z_$][\w$]*$/.test(b)
+      var aEmpty = a === '' || a === '{}' || /\|\|\s*\{\}/.test(a)
+      var bEmpty = b === '' || b === '{}' || /\|\|\s*\{\}/.test(b)
+      if ((aVar && bEmpty) || (bVar && aEmpty)) { isTrueDup = false; break }
+      if (a && b && a !== b && !/\|\|\s*\{\}/.test(a) && !/\|\|\s*\{\}/.test(b)) { isTrueDup = false; break }
+    }
+    violations.push({
+      type: 'DUPLICATE_WRAPPER',
+      severity: isTrueDup ? 'error' : 'warn',
+      file: 'utils/api.js',
+      hooks: url,
+      methods: entries.map(function (e) { return e.name }).join(', '),
+      guarded: !isTrueDup
+    })
+  })
+}
+
+// ---------- 输出 ----------
+var errors = violations.filter(function (v) { return v.severity === 'error' })
+var warns = violations.filter(function (v) { return v.severity === 'warn' })
+
+function print(list) {
+  list.forEach(function (v) {
+    var tag = v.severity === 'error' ? 'ERROR' : 'WARN '
+    var extra = v.guarded ? ' [guarded]' : ''
+    console.log('[' + tag + '] ' + v.type + ' ' + v.file + ' | ' + v.hooks + ' | ' + v.methods + extra)
+  })
+}
+
+console.log('=== Duplicate API Call Audit ===')
+console.log('Scanned ' + files.length + ' .vue files in ' + pagesDir + '/\n')
+if (errors.length) { console.log('--- ERRORS (' + errors.length + ') ---'); print(errors) }
+if (warns.length) { console.log('\n--- WARNINGS (' + warns.length + ') ---'); print(warns) }
+if (!errors.length && !warns.length) console.log('No violations found.')
+console.log('\nSummary: ' + errors.length + ' error(s), ' + warns.length + ' warning(s)')
+process.exit(errors.length > 0 ? 1 : 0)

+ 91 - 33
cfc-frontend/utils/api.js

@@ -5,6 +5,47 @@ const BASE_URL = config.API_BASE_URL
 var _redirectingToLogin = false
 var _reloginLock = false
 
+// === 请求去重(Dedup)全局安全网 ===
+// 同 URL + method + body 在 DEDUP_WINDOW_MS 窗口内复用同一个 Promise,
+// 防止 onLoad/onShow 双触发、组件嵌套、双击等场景导致的重复请求。
+// 401 续期重放请求传 options._skipDedup=true 跳过去重。
+var _dedupMap = {}
+var DEDUP_WINDOW_MS = 3000
+
+function _makeDedupKey(method, url, body) {
+  var bodyStr = ''
+  try { bodyStr = JSON.stringify(body || {}) } catch (e) { bodyStr = '' }
+  return method + '|' + url + '|' + bodyStr
+}
+
+function _tryDedup(method, fullUrl, body) {
+  var key = _makeDedupKey(method, fullUrl, body)
+  var cached = _dedupMap[key]
+  if (cached && Date.now() - cached.ts < DEDUP_WINDOW_MS) {
+    return cached.promise
+  }
+  return null
+}
+
+function _registerDedup(method, fullUrl, body, promise) {
+  var key = _makeDedupKey(method, fullUrl, body)
+  _dedupMap[key] = { promise: promise, ts: Date.now() }
+  var _cleanup = function() {
+    // 窗口到期后清理,确保缓存不常驻
+    setTimeout(function() {
+      if (_dedupMap[key] && _dedupMap[key].promise === promise) {
+        delete _dedupMap[key]
+      }
+    }, DEDUP_WINDOW_MS)
+  }
+  promise.then(_cleanup, _cleanup)
+}
+
+function _clearDedup() {
+  _dedupMap = {}
+}
+export { _clearDedup as clearApiDedup }
+
 function _clearAuthAndRedirect() {
   if (_redirectingToLogin) return
   _redirectingToLogin = true
@@ -73,7 +114,9 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
       console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
       _applyLoginData(res.data)
       // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
-      request(url, method, data, options).then(function(r) {
+      // 401 重放请求跳过去重(原请求已 reject,缓存中的 promise 已过期)
+      var _retryOpts = Object.assign({}, options, { _skipDedup: true })
+      request(url, method, data, _retryOpts).then(function(r) {
         _reloginLock = false
         resolve(r)
       }, function(e) {
@@ -121,8 +164,18 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
     }
     if (!hasFiltered) body = data
   }
-  
-  return new Promise((resolve, reject) => {
+
+  // === 请求去重:同 URL + method + body 在 3 秒窗口内复用 Promise ===
+  // 防止 onLoad/onShow 双触发、组件嵌套、双击等导致的重复请求
+  if (!options._skipDedup) {
+    var _cached = _tryDedup(method, fullUrl, body)
+    if (_cached) {
+      // console.log('[API] dedup hit ' + method + ' ' + fullUrl)
+      return _cached
+    }
+  }
+
+  var _promise = new Promise((resolve, reject) => {
     uni.request({
       url: fullUrl,
       method: method,
@@ -171,6 +224,13 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
       }
     })
   })
+
+  // 注册去重缓存,3 秒窗口内复用
+  if (!options._skipDedup) {
+    _registerDedup(method, fullUrl, body, _promise)
+  }
+
+  return _promise
 }
 
 // 登录模块
@@ -419,9 +479,10 @@ export const getAuthorizations = () => {
 	return request('/api/parent/authorizations', 'POST')
 }
 
-export const getChildren = () => {
-  return request('/api/family/member/list', 'POST', {})
-}
+// @deprecated 别名:统一使用 getFamilyMemberList,避免同 URL 多封装导致重复调用
+// @deprecated 使用 getFamilyMemberList 代替。getChildren 与 getFamilyMemberList 调用同一接口 /api/family/member/list,
+// 同一页面同时调用两者会导致重复请求。新代码请直接使用 getFamilyMemberList。
+export const getChildren = () => getFamilyMemberList({})
 
 export const getParentDashboard = () => {
   return request('/api/parent/dashboard', 'POST')
@@ -515,8 +576,10 @@ export const reviewTask = (taskId, data) => {
   return request(`/api/tasks/${taskId}/review`, 'POST', data)
 }
 
-export const getTaskHistory = (memberId, page = 1, size = 10) => {
-  return request('/api/tasks/history', 'POST', { memberId, page, size })
+export const getTaskHistory = (memberId, page = 1, size = 10, category) => {
+  var data = { memberId, page, size }
+  if (category) data.category = category
+  return request('/api/tasks/history', 'POST', data)
 }
 
 export const deleteTask = (taskId) => {
@@ -924,10 +987,6 @@ export const getAvailableGuides = (packageId) => {
   return request('/api/package/available-guides', 'POST', { packageId })
 }
 
-// 创建套餐订单
-export const createPackageOrder = (data) => {
-  return request('/api/package/order/create', 'POST', data)
-}
 
 // 获取订单状态
 export const getOrderStatus = (orderNo) => {
@@ -1336,18 +1395,11 @@ export const cancelMyJoinRequest = (requestId) => {
 }
 
 // ===== 家庭加入请求 API 别名(供组件统一引用)=====
-export const getPendingFamilyRequests = () => {
-  return request('/api/family/invite/pending-requests', 'POST', {})
-}
-export const approveFamilyJoinRequest = (requestId) => {
-  return request('/api/family/invite/approve-request', 'POST', { requestId: requestId })
-}
-export const rejectFamilyJoinRequest = (requestId) => {
-  return request('/api/family/invite/reject-request', 'POST', { requestId: requestId })
-}
-export const getMyPendingRequest = () => {
-  return request('/api/family/invite/my-request', 'POST', {})
-}
+// @deprecated 别名:委托到上面的规范函数,确保每个 URL 只有一处 request() 调用
+export const getPendingFamilyRequests = () => getPendingJoinRequests()
+export const approveFamilyJoinRequest = (requestId) => approveJoinRequest(requestId, '')
+export const rejectFamilyJoinRequest = (requestId) => rejectJoinRequest(requestId, '')
+export const getMyPendingRequest = () => getMyJoinRequest()
 
 // ===== AI 聊天 =====
 export const aiSendMessage = (data) => {
@@ -1765,15 +1817,13 @@ export const getWealthDetail = (memberId, memberType) => {
 }
 
 // ===== 成就徽章 =====
-export const getUserBadges = (memberId) => {
-  return request('/api/badges/child/list', 'POST', { memberId })
-}
+// @deprecated 别名:委托到 getChildBadges,确保 /api/badges/child/list 只有一处封装
+export const getUserBadges = (memberId) => getChildBadges(memberId)
 
 
 // ===== 成长报告 =====
-export const getGrowthReport = (memberId) => {
-  return request('/api/stats/child/overview', 'POST', { memberId })
-}
+// @deprecated 别名:委托到 getChildOverview,确保 /api/stats/child/overview 只有一处封装
+export const getGrowthReport = (memberId) => getChildOverview(memberId)
 
 // ===== 用户行动统计(我的页面) =====
 export const getUserActionStats = () => {
@@ -1829,6 +1879,14 @@ export const getCommissionBalance = () => {
   return request('/api/commission/balance', 'POST')
 }
 
+// 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 })
+// 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 })
+
 // ===== 财富维度 · 财商打卡 =====
 export const getCheckinList = (data) => {
   return request('/api/wealth/checkin/list', 'POST', data)
@@ -2019,9 +2077,8 @@ export const getTodayTasksByCategory = (memberId, category) => {
   return request('/api/tasks/today', 'POST', { memberId: memberId, category: category })
 
 }
-export const getTaskHistoryByCategory = (memberId, category, page, size) => {
-  return request('/api/tasks/history', 'POST', { memberId: memberId, category: category, page: page || 1, size: size || 10 })
-}
+// @deprecated 别名:委托到 getTaskHistory(已支持 category 参数),确保 /api/tasks/history 只有一处封装
+export const getTaskHistoryByCategory = (memberId, category, page, size) => getTaskHistory(memberId, page || 1, size || 10, category)
 
 // ===== 维度商品(domain 筛选,使用 productList) =====
 export const getProductsByDomain = (domain, page, size) => {
@@ -2610,6 +2667,7 @@ export const removeDietIngredient = (data) => request('/api/diet/ingredients/rem
 export const confirmDietIngredients = (data) => request('/api/diet/ingredients/confirm', 'POST', data)
 export const getDietRecommendation = (data) => request('/api/diet/recommendation/today', 'POST', data)
 export const generateDietRecommendation = (data) => request('/api/diet/recommendation/generate', 'POST', data)
+export const regenerateDietRecommendation = (data) => request('/api/diet/recommendation/regenerate', 'POST', data)
 export const updateDietRecommendation = (data) => request('/api/diet/recommendation/update', 'POST', data)
 export const completeDietRecommendation = (data) => request('/api/diet/recommendation/complete', 'POST', data)
 export const recognizeDietFood = (data) => request('/api/diet/record/recognize', 'POST', data)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-767c29ce9c79fbcf1ea0a074f781fc563cbab279
+35ed064b9440fc86d8000b406a7c0f89bf1552a5

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1278",
+  "version": "1.0.1285",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1278",
+      "version": "1.0.1285",
       "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.1279",
+  "version": "1.0.1286",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,136 @@
 
 ---
 
+## v1.0.1286 (2026-09-02)
+
+### Bug 修复
+- 根治重复API调用——首页合并 family/member/list 与 membership/my、全页面 onLoad/onShow 防双发 + 请求层 3 秒去重 + getChildren 废弃委托
+
+### 其他
+- - 首页 index-home: checkMemberStatus 移入 loggedInInitDone 守卫,onLoad/onShow 用 _onLoadFired 防首次双发
+- - 首页 index-home: loadFamilyFeed 循环前过滤孩子成员,避免对家长发起 getTaskHistory
+- - 全页面: energy/hierarchy/share/survey-history/parent-index/join/notification/onboarding/address/cart/checkout/shop-index/daily-tasks/service-tasks/teacher-home/wealth-cf 统一 _onLoadFired 模式
+- - parent-index: dashboard 降级路径同步设置 familyMembersVisible,避免重复请求 getFamilyMemberList
+- - utils/api.js: 全局请求去重(同 URL+method+body 3 秒窗口复用 Promise),401 重放跳过去重
+- - utils/api.js: getChildren/getUserBadges/getGrowthReport/getTaskHistoryByCategory 等别名统一委托规范函数
+- - 新增 scripts/audit-duplicate-api-calls.js CI 审计脚本(0 error)
+- - AGENTS.md: 新增规则 1-4 + 4a/4b/4c(生命周期双发、同接口单封装、N+1 过滤、getChildren 废弃)
+- 
+
+
+## v1.0.1285 (2026-09-02)
+
+### 新功能
+- 优化谷禾菌群报告详情页展示与饮食推荐
+
+### 其他
+- - 疾病风险内联展示并颜色区分(注意/低风险),修复详情页与风险页统计不一致
+- - 疾病风险页知识库入口改为 ? 图标放疾病名之前
+- - 菌群详情页修复分类 tab 过滤(中文 category 映射英文 key)
+- - 菌群风险分组(肥胖/抑郁/过敏等)改手风琴展示
+- - 饮食推荐页:去重标题、按推荐值分级背景色(<-30红/>30绿/每10一档)、去蛋白能量固定信息、增加食材搜索、复用知识库弹窗
+- - 成长页饮食推荐复用食材页,不传 reportId 取最新建议
+- 
+
+
+## v1.0.1284 (2026-09-02)
+
+### 重构
+- 清理遗留项(未用注入/静默catch/Set去重/UUID导入/参数校验/Javadoc)
+
+
+## v1.0.1282 (2026-09-02)
+
+### Bug 修复
+- 饮食推荐查询改为 selectList 避免 NPE + auto bump version
+- 会员中心等级列表/统计/升级记录N+1三处修复
+- 废弃旧套餐支付链路,修复组合商品 bundle 部分扣减 bug
+- 修复最终审查问题(幂等唯一索引兜底 + NPE判空 + delete参数校验 + 前端路由/时间格式)
+- CfCommissionServiceTest 改调生产 CfReferralService.matchRateTier(注入 mock Mapper,消除 test-the-copy 反模式)
+- PromotionTierService.refreshRate 无匹配时重置 tier 为 R0
+- 修复 CfCommissionService.getSysBps 空 catch 违反规格
+- 修复推荐树 level 倒置 + referral_tree 唯一键改为 (parent_id, child_id) 联合
+
+### 其他
+-   (listMembershipLevels/getMembershipStats 走拦截器返回已解包 {code,data},
+-    误按裸 axios 判定 res.data.code) 导致等级配置列表永远为空、统计卡不更新
+- - StatsController: freeCount 查询 .or() 位置错误导致恒为 0,改为嵌套 and(w->eq.or.isNull)
+- - upgrade-records 后端批量填充 userName,消除前端逐条调 user/search 的 N+1 请求
+- - 删除测试中引用不存在方法 getAdvicesForLowScores 的死桩,恢复 testCompile
+- 
+- - PackagePurchaseController.createOrder/getOrder:返回 410 标记废弃
+-   保留 getPackagePrice/getAvailableGuides(测评链路 VirtualPayService 复用)
+- - 删除 PackagePaymentService + PackagePaymentServiceInterface(已无调用方)
+- - ProductOrderService.handlePaymentSuccess 加 @Transactional,
+-   修复 bundle 多子商品库存扣减部分失败残留(原子性保障)
+- - PackagePaymentController /wechat params 空格路径 bug 修复
+- - 清理前端废弃 API createPackageOrder 及对应测试用例
+- 
+- 优化修复
+- 优化修复
+- 优化修复
+
+### 文档
+- API 文档补充 CF 分佣/转让/阶梯配置接口 + 单元测试
+- 修正任务1 schema.sql 同步为两张新表(cf_rate_tier/cf_transfer_record)
+
+### 新功能
+- 小程序推广中心切换到 CF 分佣接口(团队规模/比例/流水/钱包)
+- 管理端 CF 返佣阶梯配置接口(list/save/delete)
+- CF 成员间转让接口(同家庭校验 + 双账本 + 流水)
+- 用户查询接口(团队规模/返佣比例/CF汇总/流水)
+- 全部订单结算迁移到 CfCommissionService(统一 CF 值分佣),废弃旧佣金调用
+- 推荐绑定物化推荐树 + 团队规模全层级统计 + 阶梯比例刷新(替代 promotion_tier_config)
+- CfCommissionService 统一分佣核心(双返/只返推荐人/同家庭上溯/阶梯比例)
+- CfReferralService 推荐树物化 + 全层级团队规模 + 阶梯比例匹配
+- PlatformPointsService.earn 增加 (ref_type, ref_id) 幂等去重
+- 新增 cf_rate_tier 与 cf_transfer_record 实体/Mapper
+- 数据库迁移:cf_rate_tier/cf_transfer_record 建表 + promotion_tier 精简 + coupon 加 family_id
+
+
+## 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)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1279
+> 当前版本: v1.0.1286
 
 ## 历史版本
 
@@ -8,6 +8,136 @@
 
 ---
 
+## v1.0.1286 (2026-09-02)
+
+### Bug 修复
+- 根治重复API调用——首页合并 family/member/list 与 membership/my、全页面 onLoad/onShow 防双发 + 请求层 3 秒去重 + getChildren 废弃委托
+
+### 其他
+- - 首页 index-home: checkMemberStatus 移入 loggedInInitDone 守卫,onLoad/onShow 用 _onLoadFired 防首次双发
+- - 首页 index-home: loadFamilyFeed 循环前过滤孩子成员,避免对家长发起 getTaskHistory
+- - 全页面: energy/hierarchy/share/survey-history/parent-index/join/notification/onboarding/address/cart/checkout/shop-index/daily-tasks/service-tasks/teacher-home/wealth-cf 统一 _onLoadFired 模式
+- - parent-index: dashboard 降级路径同步设置 familyMembersVisible,避免重复请求 getFamilyMemberList
+- - utils/api.js: 全局请求去重(同 URL+method+body 3 秒窗口复用 Promise),401 重放跳过去重
+- - utils/api.js: getChildren/getUserBadges/getGrowthReport/getTaskHistoryByCategory 等别名统一委托规范函数
+- - 新增 scripts/audit-duplicate-api-calls.js CI 审计脚本(0 error)
+- - AGENTS.md: 新增规则 1-4 + 4a/4b/4c(生命周期双发、同接口单封装、N+1 过滤、getChildren 废弃)
+- 
+
+
+## v1.0.1285 (2026-09-02)
+
+### 新功能
+- 优化谷禾菌群报告详情页展示与饮食推荐
+
+### 其他
+- - 疾病风险内联展示并颜色区分(注意/低风险),修复详情页与风险页统计不一致
+- - 疾病风险页知识库入口改为 ? 图标放疾病名之前
+- - 菌群详情页修复分类 tab 过滤(中文 category 映射英文 key)
+- - 菌群风险分组(肥胖/抑郁/过敏等)改手风琴展示
+- - 饮食推荐页:去重标题、按推荐值分级背景色(<-30红/>30绿/每10一档)、去蛋白能量固定信息、增加食材搜索、复用知识库弹窗
+- - 成长页饮食推荐复用食材页,不传 reportId 取最新建议
+- 
+
+
+## v1.0.1284 (2026-09-02)
+
+### 重构
+- 清理遗留项(未用注入/静默catch/Set去重/UUID导入/参数校验/Javadoc)
+
+
+## v1.0.1282 (2026-09-02)
+
+### Bug 修复
+- 饮食推荐查询改为 selectList 避免 NPE + auto bump version
+- 会员中心等级列表/统计/升级记录N+1三处修复
+- 废弃旧套餐支付链路,修复组合商品 bundle 部分扣减 bug
+- 修复最终审查问题(幂等唯一索引兜底 + NPE判空 + delete参数校验 + 前端路由/时间格式)
+- CfCommissionServiceTest 改调生产 CfReferralService.matchRateTier(注入 mock Mapper,消除 test-the-copy 反模式)
+- PromotionTierService.refreshRate 无匹配时重置 tier 为 R0
+- 修复 CfCommissionService.getSysBps 空 catch 违反规格
+- 修复推荐树 level 倒置 + referral_tree 唯一键改为 (parent_id, child_id) 联合
+
+### 其他
+-   (listMembershipLevels/getMembershipStats 走拦截器返回已解包 {code,data},
+-    误按裸 axios 判定 res.data.code) 导致等级配置列表永远为空、统计卡不更新
+- - StatsController: freeCount 查询 .or() 位置错误导致恒为 0,改为嵌套 and(w->eq.or.isNull)
+- - upgrade-records 后端批量填充 userName,消除前端逐条调 user/search 的 N+1 请求
+- - 删除测试中引用不存在方法 getAdvicesForLowScores 的死桩,恢复 testCompile
+- 
+- - PackagePurchaseController.createOrder/getOrder:返回 410 标记废弃
+-   保留 getPackagePrice/getAvailableGuides(测评链路 VirtualPayService 复用)
+- - 删除 PackagePaymentService + PackagePaymentServiceInterface(已无调用方)
+- - ProductOrderService.handlePaymentSuccess 加 @Transactional,
+-   修复 bundle 多子商品库存扣减部分失败残留(原子性保障)
+- - PackagePaymentController /wechat params 空格路径 bug 修复
+- - 清理前端废弃 API createPackageOrder 及对应测试用例
+- 
+- 优化修复
+- 优化修复
+- 优化修复
+
+### 文档
+- API 文档补充 CF 分佣/转让/阶梯配置接口 + 单元测试
+- 修正任务1 schema.sql 同步为两张新表(cf_rate_tier/cf_transfer_record)
+
+### 新功能
+- 小程序推广中心切换到 CF 分佣接口(团队规模/比例/流水/钱包)
+- 管理端 CF 返佣阶梯配置接口(list/save/delete)
+- CF 成员间转让接口(同家庭校验 + 双账本 + 流水)
+- 用户查询接口(团队规模/返佣比例/CF汇总/流水)
+- 全部订单结算迁移到 CfCommissionService(统一 CF 值分佣),废弃旧佣金调用
+- 推荐绑定物化推荐树 + 团队规模全层级统计 + 阶梯比例刷新(替代 promotion_tier_config)
+- CfCommissionService 统一分佣核心(双返/只返推荐人/同家庭上溯/阶梯比例)
+- CfReferralService 推荐树物化 + 全层级团队规模 + 阶梯比例匹配
+- PlatformPointsService.earn 增加 (ref_type, ref_id) 幂等去重
+- 新增 cf_rate_tier 与 cf_transfer_record 实体/Mapper
+- 数据库迁移:cf_rate_tier/cf_transfer_record 建表 + promotion_tier 精简 + coupon 加 family_id
+
+
+## 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)
 
 ### 新功能

+ 13 - 36
cfc-web/src/views/admin/MembershipCenter.vue

@@ -353,12 +353,14 @@ export default {
           getMembershipStats(),
           this._request('/api/admin/membership/overview')
         ])
-        if (statsRes.data && statsRes.data.code === 200) {
-          const s = statsRes.data.data
+        // getMembershipStats 走 request 拦截器,返回 {code, message, data}
+        if (statsRes.code === 200 && statsRes.data) {
+          const s = statsRes.data
           this.statCards[0].value = s.freeCount || 0
           this.statCards[1].value = s.familyCount || 0
           this.statCards[2].value = s.providerCount || 0
         }
+        // _request 是裸 axios,需通过 res.data.code 解包
         if (overviewRes.data && overviewRes.data.code === 200) {
           const o = overviewRes.data.data || {}
           this.statCards[3].value = o.newFamilyThisMonth || 0
@@ -373,9 +375,10 @@ export default {
     async loadLevels() {
       this.levelsLoading = true
       try {
+        // listMembershipLevels 走 request 拦截器,返回已解包的 {code, message, data},data 即等级数组
         const res = await listMembershipLevels()
-        if (res.data && res.data.code === 200) {
-          const list = res.data.data || []
+        if (res.code === 200) {
+          const list = res.data || []
           this.levels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' }) : []
         }
       } catch (e) {
@@ -397,7 +400,6 @@ export default {
           const records = (res.data.data && res.data.data.records) || []
           this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
           this.recordsTotal = res.data.data.total || 0
-          await this.resolveUserNames(this.upgradeRecords, 'userId')
         }
       } catch (e) {
         this.$message.error('加载升级记录失败')
@@ -448,12 +450,12 @@ export default {
       this.editSubmitting = true
       try {
         const res = await updateMembershipLevel(this.editForm)
-        if (res.data && res.data.code === 200) {
-          this.$message && this.$message.success && this.$message.success(res.data.data || '更新成功')
+        if (res.code === 200) {
+          this.$message && this.$message.success && this.$message.success(res.data || '更新成功')
           this.editVisible = false
           this.loadLevels()
         } else {
-          this.$message && this.$message.error && this.$message.error((res.data && res.data.message) || '更新失败')
+          this.$message && this.$message.error && this.$message.error(res.message || '更新失败')
         }
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('更新失败')
@@ -462,31 +464,6 @@ export default {
       }
     },
 
-    resolveUserNames(items, idField) {
-      if (!items || items.length === 0) return Promise.resolve()
-      var ids = []
-      items.forEach(function(item) {
-        if (item[idField] && ids.indexOf(item[idField]) === -1) ids.push(item[idField])
-      })
-      var nameMap = {}
-      return Promise.all(ids.map(function(id) {
-        return searchUsers({ keyword: String(id) })
-          .then(function(res) {
-            if (res.data && res.data.length > 0) {
-              var u = res.data[0]
-              nameMap[id] = u.nickname || u.realName || ('用户' + id)
-            } else {
-              nameMap[id] = ('用户' + id)
-            }
-          })
-          .catch(function() { nameMap[id] = ('用户' + id) })
-      })).then(function() {
-        items.forEach(function(item) {
-          item.userName = nameMap[item[idField]] || ('用户' + item[idField])
-        })
-      })
-    },
-
     async handleMaintainSearch() {
       const kw = (this.maintainSearch || '').trim()
       if (!kw) {
@@ -532,13 +509,13 @@ export default {
         if (refStr === '0') payload.referrerId = ''
         else if (refStr !== '') payload.referrerId = refStr
         const res = await updateMembershipUser(payload)
-        if (res.data && res.data.code === 200) {
-          this.$message && this.$message.success && this.$message.success(res.data.data || '更新成功')
+        if (res.code === 200) {
+          this.$message && this.$message.success && this.$message.success(res.data || '更新成功')
           this.maintainVisible = false
           this.handleMaintainSearch()
           this.refreshAll()
         } else {
-          this.$message && this.$message.error && this.$message.error(res.data && res.data.message || '更新失败')
+          this.$message && this.$message.error && this.$message.error(res.message || '更新失败')
         }
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('更新失败')

+ 37 - 5
cfc-web/src/views/admin/ProductEdit.vue

@@ -338,6 +338,13 @@ import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 
+function resolveUploadedUrl(res) {
+  const url = res && res.data && res.data.url ? res.data.url : (res && res.data)
+  if (!url) return ''
+  if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) return url
+  return (process.env.VUE_APP_BASE_API || '') + url
+}
+
 export default {
   name: 'ProductEdit',
   components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
@@ -412,14 +419,33 @@ export default {
             },
             customInsert(res, insertFn) {
               if (res.code === 200) {
-                const baseURL = process.env.VUE_APP_BASE_API || ''
-                const url = res.data && res.data.url ? res.data.url : res.data
-                insertFn(baseURL + url)
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          },
+          uploadVideo: {
+            server: '',
+            headers: {},
+            maxNumberOfFiles: 1,
+            maxFileSize: 50 * 1024 * 1024,
+            fieldName: 'file',
+            timeout: 5 * 60 * 1000,
+            allowedFileTypes: ['video/*'],
+            customAlert: (msg, type) => {
+              this.$message && this.$message({ message: msg, type: type || 'error' })
+            },
+            onError: (file, err, res) => {
+              const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
+              this.$message && this.$message({ message: msg, type: 'error' })
+            },
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
               }
             }
           }
-}
-    },
+        }
+      },
     addBundleItem() {
       this.bundleItems.push({
         childProductId: null,
@@ -502,6 +528,10 @@ export default {
     const baseURL = process.env.VUE_APP_BASE_API || ''
     return baseURL + '/api/admin/articles/upload/image'
     },
+    uploadVideoActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/articles/upload/video'
+    },
     uploadHeaders() {
       const token = localStorage.getItem('token')
       return { Authorization: 'Bearer ' + token }
@@ -526,6 +556,8 @@ export default {
     this.loadApprovedGuides()
     this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
     this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
+    this.editorConfig.MENU_CONF.uploadVideo.server = this.uploadVideoActionUrl
+    this.editorConfig.MENU_CONF.uploadVideo.headers = this.uploadHeaders
   },
   mounted() {
     this.loadSupplySystems()

+ 47 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -69,6 +69,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `ArticleController` | `/api/articles` | 文章 | — |
 | `NotificationController` | `/api/notification` | 通知 | — |
 | `StatsController` | `/api/stats` | 统计 | — |
+| `CfCommissionController` | `/api/commission/cf` | CF 分佣(团队规模/比例/钱包/流水) | — |
+| `CfTransferController` | `/api/cf/transfer` | CF 成员间转让 | — |
+| `AdminCfRateTierController` | `/api/admin/cf-rate-tier` | CF 返佣阶梯配置(admin) | — |
 
 ---
 
@@ -884,6 +887,49 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
+### 4.33 CF 值分佣(`/api/commission/cf/*`)
+
+> CF 值(平台积分)统一分佣体系:普通订单双返(当前消费人 + 推荐人按团队规模阶梯分润),会员/订阅只返推荐人,同家庭互推跳过本人上溯。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/commission/cf/rate` | 我的团队规模 + 当前返佣比例 + 档位名。返回 `{ totalTeamSize, ratePercent, tierName }`(未入档时 ratePercent=0、tierName="未入档") |
+| `POST /api/commission/cf/summary` | 我的 CF 钱包汇总,返回 `PlatformBalanceService.getBalance(userId)` 结果 |
+| `POST /api/commission/cf/list` | 我的 CF 流水(分页),请求 `{ page, size }`,返回 `Page<PlatformBalanceLog>` |
+
+**阶梯档位(cf_rate_tier,管理端可配置):**
+
+| 团队规模(min_team_size) | 档位 | 返佣比例 |
+|:---:|:---:|:---:|
+| 0~2 | 铜牌 | 5% |
+| 3~9 | 银牌 | 10% |
+| 10~29 | 金牌 | 15% |
+| 30~99 | 铂金 | 20% |
+| 100+ | 钻石 | 25% |
+
+> 匹配语义:`enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC LIMIT 1`。
+
+### 4.34 CF 成员间转让(`/api/cf/transfer/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/cf/transfer/send` | 成员间转让 CF,请求 `{ toUserId, amount }`。仅限同一家庭(校验 familyId 一致);amount 必须为正数。转出扣减 + 转入增加在同一事务 |
+| `POST /api/cf/transfer/list` | 转让记录(分页,按当前用户家庭过滤),请求 `{ page, size }`,返回 `Page<CfTransferRecord>` |
+
+### 4.35 CF 返佣阶梯配置(admin,`/api/admin/cf-rate-tier/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/cf-rate-tier/list` | 阶梯列表(按 sort_order 升序),返回 `List<CfRateTier>` |
+| `POST /api/admin/cf-rate-tier/save` | 新增/更新阶梯,请求体为 `CfRateTier`(含 id 则更新,否则新增,默认 enabled=1),返回 "已保存" |
+| `POST /api/admin/cf-rate-tier/delete` | 删除阶梯,请求 `{ id }`,返回 "已删除" |
+
+**`CfRateTier` 字段:** `id` / `tierName`(档位名)/ `minTeamSize`(团队规模下限含)/ `ratePercent`(返佣比例%)/ `sortOrder`(排序,越大越高)/ `enabled`(1启用/0停用)/ `createdAt` / `updatedAt`。
+
+> 注意:家庭券接口为 `POST /api/coupon/my` 与 `POST /api/coupon/list`(见 4.32),不存在 `/api/coupon/family/list` 路由。
+
+---
+
 ## 六、待清理的废弃接口
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
@@ -898,4 +944,4 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
-*文档最后更新:2026-08-31*
+*文档最后更新:2026-09-02*

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

@@ -146,7 +146,7 @@ try {
 
 - [ ] **步骤 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 为完整快照。
+在 `schema.sql` 末尾追加两张新表(`cf_rate_tier`、`cf_transfer_record`)的 `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:编译验证**
 

+ 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()