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

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

# Conflicts:
#	cfc-web/src/views/admin/ProductEdit.vue
jiapu 2 месяцев назад
Родитель
Сommit
c7ba6422e4
88 измененных файлов с 4790 добавлено и 977 удалено
  1. 59 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 0 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java
  3. 9 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java
  4. 21 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  5. 34 77
      cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java
  6. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  7. 129 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  8. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java
  9. 8 18
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PackagePurchaseController.java
  10. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
  11. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java
  12. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  13. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java
  14. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  15. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java
  16. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  17. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MemberUpgradeRecord.java
  18. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java
  19. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java
  20. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java
  21. 15 20
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java
  22. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftConfigMapper.java
  23. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftItemMapper.java
  24. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderGiftMapper.java
  25. 33 6
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  26. 0 4
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java
  27. 10 3
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  28. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java
  29. 24 3
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  30. 101 15
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  31. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java
  32. 0 3
      cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java
  33. 0 3
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  34. 0 344
      cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java
  35. 184 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java
  36. 122 4
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  37. 25 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  38. 0 13
      cfc-backend/src/main/java/com/etotem/cfc/service/api/PackagePaymentServiceInterface.java
  39. 44 0
      cfc-backend/src/main/resources/schema.sql
  40. 6 6
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
  41. 0 1
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/FiveDimensionSelfCheckServiceTest.java
  42. 63 2
      cfc-frontend/AGENTS.md
  43. 0 6
      cfc-frontend/__tests__/api/api.test.js
  44. 2 2
      cfc-frontend/components/wuxing-sandbox.vue
  45. 16 2
      cfc-frontend/pages/diet/recommendation.vue
  46. 140 0
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  47. 6 1
      cfc-frontend/pages/energy/index.vue
  48. 1 1
      cfc-frontend/pages/family/self-check-result.vue
  49. 1 1
      cfc-frontend/pages/growth-main/index.vue
  50. 7 1
      cfc-frontend/pages/guide/hierarchy/index.vue
  51. 6 1
      cfc-frontend/pages/guide/invite/share.vue
  52. 6 1
      cfc-frontend/pages/health/challenge-manage.vue
  53. 145 40
      cfc-frontend/pages/health/gut-flora-detail.vue
  54. 208 82
      cfc-frontend/pages/health/gut-flora-foods-detail.vue
  55. 8 8
      cfc-frontend/pages/health/gut-flora-risks-detail.vue
  56. 14 12
      cfc-frontend/pages/health/gut-flora-species-detail.vue
  57. 128 50
      cfc-frontend/pages/health/report-list.vue
  58. 5 1
      cfc-frontend/pages/health/survey-history.vue
  59. 8 4
      cfc-frontend/pages/home-pages/child-index.vue
  60. 17 15
      cfc-frontend/pages/home-pages/parent-index.vue
  61. 55 51
      cfc-frontend/pages/index-home/index.vue
  62. 6 0
      cfc-frontend/pages/invite/join.vue
  63. 6 1
      cfc-frontend/pages/notification/index.vue
  64. 6 1
      cfc-frontend/pages/profile-extra/onboarding.vue
  65. 6 1
      cfc-frontend/pages/shop/address/address.vue
  66. 4 1
      cfc-frontend/pages/shop/cart/cart.vue
  67. 13 3
      cfc-frontend/pages/shop/checkout/checkout.vue
  68. 8 3
      cfc-frontend/pages/shop/index/index.vue
  69. 6 1
      cfc-frontend/pages/tasks/daily-tasks.vue
  70. 6 1
      cfc-frontend/pages/teacher/service-tasks.vue
  71. 6 1
      cfc-frontend/pages/teacher/teacher-home.vue
  72. 6 1
      cfc-frontend/pages/wealth-cf/index.vue
  73. 234 0
      cfc-frontend/scripts/audit-duplicate-api-calls.js
  74. 83 33
      cfc-frontend/utils/api.js
  75. 1 1
      cfc-web/.last_build_commit
  76. 2 2
      cfc-web/package-lock.json
  77. 1 1
      cfc-web/package.json
  78. 150 0
      cfc-web/public/CHANGELOG-v1.0.md
  79. 151 1
      cfc-web/public/CHANGELOG.md
  80. 16 0
      cfc-web/src/api/admin.js
  81. 8 0
      cfc-web/src/api/coupon.js
  82. 180 2
      cfc-web/src/views/Families.vue
  83. 11 6
      cfc-web/src/views/admin/ArticleManage.vue
  84. 13 36
      cfc-web/src/views/admin/MembershipCenter.vue
  85. 197 61
      cfc-web/src/views/admin/ProductEdit.vue
  86. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  87. 1354 0
      docs/superpowers/plans/2026-09-02-product-gift-selection.md
  88. 383 0
      docs/superpowers/specs/2026-09-02-product-gift-selection-design.md

+ 59 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10195,5 +10195,64 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("cf_transfer_record.uk_ref_type_id 已存在: {}", e.getMessage());
             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, " +
+                    "product_id BIGINT NOT NULL COMMENT '关联products表', " +
+                    "level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)', " +
+                    "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_product_level (product_id, level_code), " +
+                    "INDEX idx_product_id (product_id), " +
+                    "INDEX idx_level_code (level_code)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)'");
+            log.info("已创建product_gift_configs表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+
+        // 迁移286: 创建 product_gift_items 表(商品赠品项)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_items (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id', " +
+                    "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
+                    "gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)', " +
+                    "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
+                    "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
+                    "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)', " +
+                    "sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_config_id (config_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项'");
+            log.info("已创建product_gift_items表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+
+        // 迁移287: 创建 product_order_gifts 表(订单赠品记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_order_gifts (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "order_id BIGINT NOT NULL COMMENT '关联product_orders.id', " +
+                    "order_no VARCHAR(64) NOT NULL COMMENT '订单号快照', " +
+                    "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
+                    "gift_product_id BIGINT COMMENT '赠品商品ID', " +
+                    "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
+                    "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
+                    "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)', " +
+                    "quantity INT DEFAULT 1 COMMENT '数量(默认1)', " +
+                    "fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_order_id (order_id), " +
+                    "INDEX idx_order_no (order_no)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录'");
+            log.info("已创建product_order_gifts表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
     }
     }
 }
 }

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

@@ -6,7 +6,6 @@ import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.PlatformBalanceLog;
 import com.etotem.cfc.entity.PlatformBalanceLog;
 import com.etotem.cfc.service.CfReferralService;
 import com.etotem.cfc.service.CfReferralService;
 import com.etotem.cfc.service.PlatformPointsService;
 import com.etotem.cfc.service.PlatformPointsService;
-import com.etotem.cfc.service.PromotionTierService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -25,8 +24,6 @@ public class CfCommissionController {
     private CfReferralService cfReferralService;
     private CfReferralService cfReferralService;
     @Resource
     @Resource
     private PlatformPointsService platformPointsService;
     private PlatformPointsService platformPointsService;
-    @Resource
-    private PromotionTierService promotionTierService;
 
 
     /** 我的团队规模 + 当前返佣比例 + 档位名 */
     /** 我的团队规模 + 当前返佣比例 + 档位名 */
     @PostMapping("/rate")
     @PostMapping("/rate")

+ 9 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java

@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.Date;
 import java.util.Date;
 import java.util.Map;
 import java.util.Map;
+import java.util.UUID;
 
 
 @RestController
 @RestController
 @RequestMapping("/api/cf/transfer")
 @RequestMapping("/api/cf/transfer")
@@ -35,8 +36,13 @@ public class CfTransferController {
     @Transactional
     @Transactional
     public Result<String> send(@RequestAttribute("userId") Long fromUserId,
     public Result<String> send(@RequestAttribute("userId") Long fromUserId,
                                @RequestBody Map<String, Object> params) {
                                @RequestBody Map<String, Object> params) {
-        Long toUserId = ((Number) params.get("toUserId")).longValue();
-        int amount = ((Number) params.get("amount")).intValue();
+        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) {
         if (amount <= 0) {
             return Result.error("CF值必须为正数");
             return Result.error("CF值必须为正数");
         }
         }
@@ -49,7 +55,7 @@ public class CfTransferController {
             return Result.error("仅限同一家庭成员间转让");
             return Result.error("仅限同一家庭成员间转让");
         }
         }
         // 每次转让使用唯一 refId(避免 earn 幂等键 (ref_type, ref_id) 碰撞)
         // 每次转让使用唯一 refId(避免 earn 幂等键 (ref_type, ref_id) 碰撞)
-        Long transferRefId = java.util.UUID.randomUUID().getMostSignificantBits();
+        Long transferRefId = UUID.randomUUID().getMostSignificantBits();
         if (transferRefId == null || transferRefId == 0) transferRefId = System.currentTimeMillis();
         if (transferRefId == null || transferRefId == 0) transferRefId = System.currentTimeMillis();
         // 转出扣减 + 转入增加(同一事务)
         // 转出扣减 + 转入增加(同一事务)
         platformPointsService.spend(fromUserId, amount, "cf_transfer_out", transferRefId,
         platformPointsService.spend(fromUserId, amount, "cf_transfer_out", transferRefId,

+ 21 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1404,14 +1404,9 @@ public class HealthReportController {
             // 指定报告:返回该报告的食材推荐
             // 指定报告:返回该报告的食材推荐
             foods = foodService.listByReportId(reportId);
             foods = foodService.listByReportId(reportId);
         } else {
         } else {
-            // 未指定报告:返回当前用户最新报告的食材推荐(当前最适合)
+            // 未指定报告:返回维护的"当前食材"推荐(food_recommend_idx,有新报告时 upsert 更新)
             Long uid = userId != null ? userId : -1L;
             Long uid = userId != null ? userId : -1L;
-            HealthReport latest = healthReportService.getLatestReport(uid);
-            if (latest == null) {
-                foods = java.util.Collections.emptyList();
-            } else {
-                foods = foodService.listByReportId(latest.getId());
-            }
+            foods = foodRecommendService.getCurrentFoodsAsFoodList(uid);
         }
         }
         return Result.success(foods);
         return Result.success(foods);
     }
     }
@@ -1597,6 +1592,14 @@ public class HealthReportController {
 
 
         try {
         try {
             Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, auditorId);
             Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, auditorId);
+            Long confirmedSubjectId = ParamUtils.getLong(result.get("subjectId"));
+            if (confirmedSubjectId != null) {
+                try {
+                    foodRecommendService.recalculateForUser(confirmedSubjectId);
+                } catch (Exception e) {
+                    log.warn("审核确认后刷新食材推荐失败: {}", e.getMessage());
+                }
+            }
             return Result.success(result);
             return Result.success(result);
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("审核确认失败", e);
             log.error("审核确认失败", e);
@@ -1901,6 +1904,7 @@ public class HealthReportController {
         report.setSubjectId(subjectId);
         report.setSubjectId(subjectId);
         report.setReportType(draft.getReportType());
         report.setReportType(draft.getReportType());
         report.setFileUrl(draft.getFileUrl());
         report.setFileUrl(draft.getFileUrl());
+        report.setReportFormat(payload.getReportFormat());
 
 
         ParsedReportPayload.Summary s = payload.getSummary();
         ParsedReportPayload.Summary s = payload.getSummary();
         if (s != null) {
         if (s != null) {
@@ -2219,6 +2223,16 @@ public class HealthReportController {
             Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
             Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
             Long reportId = (Long) result.get("reportId");
             Long reportId = (Long) result.get("reportId");
 
 
+            // 维护"当前食材"推荐(有则更新,无则添加)
+            Long confirmedSubjectId = ParamUtils.getLong(result.get("subjectId"));
+            if (confirmedSubjectId != null) {
+                try {
+                    foodRecommendService.recalculateForUser(confirmedSubjectId);
+                } catch (Exception e) {
+                    log.warn("代确认后刷新食材推荐失败: {}", e.getMessage());
+                }
+            }
+
             // 设置代上传信息
             // 设置代上传信息
             if (reportId != null) {
             if (reportId != null) {
                 healthReportService.setProxyUploader(reportId, userId);
                 healthReportService.setProxyUploader(reportId, userId);

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

@@ -1,129 +1,86 @@
 package com.etotem.cfc.controller;
 package com.etotem.cfc.controller;
-import javax.annotation.Resource;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.TaskPlanInstance;
 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.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
 import java.util.Map;
 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
 @RestController
 @RequestMapping("/api/payment")
 @RequestMapping("/api/payment")
 public class PackagePaymentController {
 public class PackagePaymentController {
 
 
-    @Resource
-    private PackagePaymentService packagePaymentService;
-
-    @Resource
-    private TaskPlanService planService;
-
-    @Resource
-    private MembershipService membershipService;
-
     /**
     /**
-     * 创建支付订单
+     * 创建支付订单(已废弃)
      */
      */
-    @Operation(summary = "创建支付订单")
+    @Deprecated
+    @Operation(summary = "创建支付订单(已废弃)")
     @PostMapping("/create")
     @PostMapping("/create")
     public Result<PackageOrder> createOrder(
     public Result<PackageOrder> createOrder(
             @RequestAttribute("userId") Long userId,
             @RequestAttribute("userId") Long userId,
             @RequestBody Map<String, Object> params) {
             @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) {
     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")
     @PostMapping("/notify")
     public String handlePayNotify(@RequestBody String xmlData) {
     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}")
     @PostMapping("/orders/{orderId}")
     public Result<PackageOrder> getOrderStatus(@PathVariable Long 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")
     @PostMapping("/apply")
     public Result<TaskPlanInstance> applyAfterPayment(@RequestBody Map<String, Object> params) {
     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")
     @PostMapping("/orders/{orderId}/cancel")
     public Result<Boolean> cancelOrder(@PathVariable Long orderId) {
     public Result<Boolean> cancelOrder(@PathVariable Long orderId) {
-        boolean success = packagePaymentService.cancelOrder(orderId);
-        return Result.success(success);
+        return Result.error(410, "该接口已废弃,套餐购买请走组合商品下单");
     }
     }
 }
 }

+ 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);
         Page<MemberUpgradeRecord> pageParam = new Page<>(page, size);
         SortUtil.applySort(wrapper);
         SortUtil.applySort(wrapper);
         Page<MemberUpgradeRecord> result = memberUpgradeRecordMapper.selectPage(pageParam, 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);
         return Result.success(result);
     }
     }
 
 

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -1,20 +1,27 @@
 package com.etotem.cfc.controller.admin;
 package com.etotem.cfc.controller.admin;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 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.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.service.CouponService;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
 
 
@@ -34,6 +41,9 @@ public class AdminCouponController {
     @Resource
     @Resource
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
 
 
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+
     @PostMapping("/list")
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
         if (!"admin".equals(role)) {
@@ -153,4 +163,123 @@ public class AdminCouponController {
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
         return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
     }
+
+    /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
+    @PostMapping("/family-coupons")
+    public Result<Page<Map<String, Object>>> familyCoupons(@RequestBody Map<String, Object> params,
+                                                           @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        String status = params.get("status") == null ? null : params.get("status").toString();
+        int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
+        int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());
+
+        LambdaQueryWrapper<FamilyCoupon> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(FamilyCoupon::getFamilyId, familyId);
+        }
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(FamilyCoupon::getStatus, status);
+        }
+        wrapper.orderByDesc(FamilyCoupon::getReceivedAt);
+
+        Page<FamilyCoupon> fcPage = familyCouponMapper.selectPage(new Page<>(page, size), wrapper);
+
+        // 批量取券模板信息
+        Set<Long> couponIds = new LinkedHashSet<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            couponIds.add(fc.getCouponId());
+        }
+        Map<Long, Coupon> couponMap = new HashMap<>();
+        if (!couponIds.isEmpty()) {
+            List<Coupon> coupons = couponMapper.selectBatchIds(couponIds);
+            for (Coupon c : coupons) {
+                couponMap.put(c.getId(), c);
+            }
+        }
+
+        List<Map<String, Object>> voList = new ArrayList<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            Coupon c = couponMap.get(fc.getCouponId());
+            Map<String, Object> vo = new HashMap<>();
+            vo.put("id", fc.getId());
+            vo.put("familyId", fc.getFamilyId());
+            vo.put("couponId", fc.getCouponId());
+            vo.put("status", fc.getStatus());
+            vo.put("receivedAt", fc.getReceivedAt());
+            vo.put("usedAt", fc.getUsedAt());
+            vo.put("orderId", fc.getOrderId());
+            if (c != null) {
+                vo.put("name", c.getName());
+                vo.put("type", c.getType());
+                vo.put("value", c.getValue());
+                vo.put("discountRate", c.getDiscountRate());
+                vo.put("minSpend", c.getMinSpend());
+                vo.put("applicableTo", c.getApplicableTo());
+            }
+            voList.add(vo);
+        }
+
+        Page<Map<String, Object>> result = new Page<>(fcPage.getCurrent(), fcPage.getSize(), fcPage.getTotal());
+        result.setRecords(voList);
+        return Result.success(result);
+    }
+
+    /** 家庭券使用记录(status=USED,JOIN coupon 取模板信息) */
+    @PostMapping("/family-coupon-usage")
+    public Result<Page<Map<String, Object>>> familyCouponUsage(@RequestBody Map<String, Object> params,
+                                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
+        int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());
+
+        LambdaQueryWrapper<FamilyCoupon> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FamilyCoupon::getStatus, "USED");
+        if (familyId != null) {
+            wrapper.eq(FamilyCoupon::getFamilyId, familyId);
+        }
+        wrapper.orderByDesc(FamilyCoupon::getUsedAt);
+
+        Page<FamilyCoupon> fcPage = familyCouponMapper.selectPage(new Page<>(page, size), wrapper);
+
+        Set<Long> couponIds = new LinkedHashSet<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            couponIds.add(fc.getCouponId());
+        }
+        Map<Long, Coupon> couponMap = new HashMap<>();
+        if (!couponIds.isEmpty()) {
+            List<Coupon> coupons = couponMapper.selectBatchIds(couponIds);
+            for (Coupon c : coupons) {
+                couponMap.put(c.getId(), c);
+            }
+        }
+
+        List<Map<String, Object>> voList = new ArrayList<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            Coupon c = couponMap.get(fc.getCouponId());
+            Map<String, Object> vo = new HashMap<>();
+            vo.put("id", fc.getId());
+            vo.put("familyId", fc.getFamilyId());
+            vo.put("couponId", fc.getCouponId());
+            vo.put("usedAt", fc.getUsedAt());
+            vo.put("orderId", fc.getOrderId());
+            if (c != null) {
+                vo.put("name", c.getName());
+                vo.put("type", c.getType());
+                vo.put("value", c.getValue());
+                vo.put("discountRate", c.getDiscountRate());
+                vo.put("minSpend", c.getMinSpend());
+            }
+            voList.add(vo);
+        }
+
+        Page<Map<String, Object>> result = new Page<>(fcPage.getCurrent(), fcPage.getSize(), fcPage.getTotal());
+        result.setRecords(voList);
+        return Result.success(result);
+    }
 }
 }

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.ProductGiftItemDTO;
+import com.etotem.cfc.service.ProductGiftService;
+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.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/product-gift")
+public class AdminProductGiftController {
+
+    @Resource
+    private ProductGiftService giftService;
+
+    /** 保存某商品某等级的赠品配置(幂等:先删后插) */
+    @PostMapping("/save")
+    public Result<Void> save(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        String levelCode = params.get("levelCode").toString();
+        Integer status = Integer.parseInt(params.get("status").toString());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> itemsMap = (List<Map<String, Object>>) params.get("items");
+        List<ProductGiftItemDTO> items = null;
+        if (itemsMap != null) {
+            items = new ArrayList<>();
+            for (Map<String, Object> m : itemsMap) {
+                ProductGiftItemDTO dto = new ProductGiftItemDTO();
+                Object id = m.get("id");
+                dto.setId(id != null ? Long.parseLong(id.toString()) : null);
+                dto.setGiftType((String) m.get("giftType"));
+                Object gpId = m.get("giftProductId");
+                dto.setGiftProductId(gpId != null ? Long.parseLong(gpId.toString()) : null);
+                Object cf = m.get("cfValue");
+                dto.setCfValue(cf != null ? Integer.parseInt(cf.toString()) : 0);
+                Object sort = m.get("sortOrder");
+                dto.setSortOrder(sort != null ? Integer.parseInt(sort.toString()) : 0);
+                items.add(dto);
+            }
+        }
+        return giftService.saveGiftConfig(productId, levelCode, status, items);
+    }
+
+    /** 查询某商品的全部等级赠品配置(管理端回显) */
+    @PostMapping("/list")
+    public Result<List<GiftConfigVO>> list(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        return Result.success(giftService.listConfigsByProduct(productId));
+    }
+}

+ 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")
     @PostMapping("/order/create")
     public Result<PackageOrder> createOrder(@RequestBody PackagePurchaseDTO dto) {
     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}")
     @PostMapping("/order/{orderNo}")
     public Result<PackageOrder> getOrder(@PathVariable String 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, "该接口已废弃,套餐购买请走组合商品下单");
     }
     }
 }
 }

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -47,10 +47,11 @@ public class ProductController {
     }
     }
 
 
     @PostMapping("/detail")
     @PostMapping("/detail")
-    public Result<ProductDTO> detail(@RequestBody Map<String, Object> params) {
+    public Result<ProductDTO> detail(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute(value = "userId", required = false) Long userId) {
         if (params.get("id") == null) return Result.error("缺少商品ID");
         if (params.get("id") == null) return Result.error("缺少商品ID");
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
-        return productService.detail(id);
+        return productService.detail(id, userId);
     }
     }
 
 
     @PostMapping("/create")
     @PostMapping("/create")

+ 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<>();
         Map<String, Object> data = new HashMap<>();
         try {
         try {
             CompletableFuture<Long> freeFuture = CompletableFuture.supplyAsync(() ->
             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(() ->
             CompletableFuture<Long> familyFuture = CompletableFuture.supplyAsync(() ->
                 userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getMemberLevel, "FAMILY")), taskExecutor);
                 userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getMemberLevel, "FAMILY")), taskExecutor);
             CompletableFuture<Long> providerFuture = CompletableFuture.supplyAsync(() ->
             CompletableFuture<Long> providerFuture = CompletableFuture.supplyAsync(() ->

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -20,6 +20,11 @@ public class CreateProductOrderDTO {
     private Long consigneeId;         // 收货人ID
     private Long consigneeId;         // 收货人ID
     private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
     private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
 
 
+    private List<Long> giftItemIds; // 选中的赠品项ID列表(来自 product_gift_items.id)
+
+    public List<Long> getGiftItemIds() { return giftItemIds; }
+    public void setGiftItemIds(List<Long> giftItemIds) { this.giftItemIds = giftItemIds; }
+
     // 配送与履约扩展
     // 配送与履约扩展
     private Long pickupLocationId;      // 自取地点ID
     private Long pickupLocationId;      // 自取地点ID
     private Long timeSlotId;             // 时间段ID(自取/线上)
     private Long timeSlotId;             // 时间段ID(自取/线上)

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class GiftConfigVO {
+    private Long id;
+    private String levelCode;
+    private String levelName;
+    private Integer status;
+    private List<GiftItemVO> items;
+
+    @Data
+    public static class GiftItemVO {
+        private Long id;
+        private String giftType;
+        private Long giftProductId;
+        private String giftProductName;
+        private String giftProductImage;
+        private Integer cfValue;
+        private Integer sortOrder;
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -49,6 +49,9 @@ public class ProductDTO {
     private String giftLevelCode;
     private String giftLevelCode;
     private String giftLevelName;
     private String giftLevelName;
 
 
+    // 商品可选赠品配置(当前用户等级对应,空=无赠品)
+    private List<GiftConfigVO> giftConfigs;
+
     // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
     // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
     private Map<String, Object> deliveryConfig;
     private Map<String, Object> deliveryConfig;
 
 

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class ProductGiftItemDTO {
+    private Long id;           // null=新增,有值=更新
+    private String giftType;   // "product" or "cf"
+    private Long giftProductId;
+    private Integer cfValue;
+    private Integer sortOrder;
+}

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 package com.etotem.cfc.entity;
 
 
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.Data;
@@ -48,6 +49,9 @@ public class Article implements Serializable {
     private Integer isFeatured;
     private Integer isFeatured;
     private Date publishedAt;
     private Date publishedAt;
     private Integer viewCount;
     private Integer viewCount;
+    /** 总阅读时长(秒,瞬态字段,由 article_reading_records 聚合) */
+    @TableField(exist = false)
+    private Integer totalDurationSeconds;
     private Long createdBy;
     private Long createdBy;
     /** 作者类型: admin/parent/child */
     /** 作者类型: admin/parent/child */
     private String authorType;
     private String authorType;

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

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

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java

@@ -0,0 +1,27 @@
+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("product_gift_configs")
+public class ProductGiftConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    private String levelCode;
+
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java

@@ -0,0 +1,33 @@
+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("product_gift_items")
+public class ProductGiftItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long configId;
+
+    private String giftType;
+
+    private Long giftProductId;
+
+    private String giftProductName;
+
+    private String giftProductImage;
+
+    private Integer cfValue;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java

@@ -0,0 +1,37 @@
+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("product_order_gifts")
+public class ProductOrderGift implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long orderId;
+
+    private String orderNo;
+
+    private String giftType;
+
+    private Long giftProductId;
+
+    private String giftProductName;
+
+    private String giftProductImage;
+
+    private Integer cfValue;
+
+    private Integer quantity;
+
+    private Integer fulfilled;
+
+    private Date createdAt;
+}

+ 15 - 20
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java

@@ -19,37 +19,32 @@ public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRec
                                             @Param("since") java.util.Date since);
                                             @Param("since") java.util.Date since);
 
 
     /**
     /**
-     * 按文章 ID 分页查询阅读记录,JOIN family_members 获取昵称,按最后阅读时间倒序
+     * 按文章 ID 分页查询阅读记录(每人聚合一行),JOIN family_members 获取昵称
      */
      */
     @Select("SELECT " +
     @Select("SELECT " +
-            "  r.id, " +
-            "  r.user_id, " +
-            "  r.child_id, " +
-            "  r.duration_seconds, " +
-            "  r.read_at, " +
-            "  r.updated_at, " +
             "  m.nickname, " +
             "  m.nickname, " +
-            "  DATE(r.read_at) AS read_date " +
+            "  SUM(r.duration_seconds) AS total_duration_seconds, " +
+            "  COUNT(*) AS read_count, " +
+            "  MAX(r.updated_at) AS last_read_at, " +
+            "  MAX(DATE(r.read_at)) AS last_read_date " +
             "FROM article_reading_records r " +
             "FROM article_reading_records r " +
             "LEFT JOIN family_members m ON r.child_id = m.id " +
             "LEFT JOIN family_members m ON r.child_id = m.id " +
             "WHERE r.article_id = #{articleId} " +
             "WHERE r.article_id = #{articleId} " +
-            "ORDER BY r.updated_at DESC, r.read_at DESC " +
+            "GROUP BY r.child_id, m.nickname " +
+            "ORDER BY total_duration_seconds DESC " +
             "LIMIT #{limit} OFFSET #{offset}")
             "LIMIT #{limit} OFFSET #{offset}")
-    @Results(id = "readRecordResultMap", value = {
-            @Result(column = "id", property = "id"),
-            @Result(column = "user_id", property = "user_id"),
-            @Result(column = "child_id", property = "child_id"),
-            @Result(column = "duration_seconds", property = "duration_seconds"),
-            @Result(column = "read_at", property = "read_at"),
-            @Result(column = "updated_at", property = "updated_at"),
+    @Results(id = "readRecordAggregatedMap", value = {
             @Result(column = "nickname", property = "nickname"),
             @Result(column = "nickname", property = "nickname"),
-            @Result(column = "read_date", property = "read_date")
+            @Result(column = "total_duration_seconds", property = "total_duration_seconds"),
+            @Result(column = "read_count", property = "read_count"),
+            @Result(column = "last_read_at", property = "last_read_at"),
+            @Result(column = "last_read_date", property = "last_read_date")
     })
     })
-    List<Map<String, Object>> selectByArticle(
+    List<Map<String, Object>> selectByArticleAggregated(
             @Param("articleId") Long articleId,
             @Param("articleId") Long articleId,
             @Param("offset") int offset,
             @Param("offset") int offset,
             @Param("limit") int limit);
             @Param("limit") int limit);
 
 
-    @Select("SELECT COUNT(*) FROM article_reading_records WHERE article_id = #{articleId}")
-    long countByArticle(@Param("articleId") Long articleId);
+    @Select("SELECT COUNT(DISTINCT child_id) FROM article_reading_records WHERE article_id = #{articleId}")
+    long countDistinctByArticle(@Param("articleId") Long articleId);
 }
 }

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

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

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

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

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

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

+ 33 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -29,6 +29,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.*;
+import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 
 
 @Slf4j
 @Slf4j
@@ -93,7 +94,8 @@ public class ArticleService {
             wrapper.eq(Article::getCategoryId, categoryId);
             wrapper.eq(Article::getCategoryId, categoryId);
         }
         }
         if (keyword != null && !keyword.trim().isEmpty()) {
         if (keyword != null && !keyword.trim().isEmpty()) {
-            wrapper.like(Article::getTitle, keyword.trim());
+            String kw = keyword.trim();
+            wrapper.and(w -> w.like(Article::getTitle, kw).or().like(Article::getSummary, kw).or().like(Article::getTags, kw));
         }
         }
         if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
         if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
             String dim = dimensionCode.trim();
             String dim = dimensionCode.trim();
@@ -355,10 +357,10 @@ public class ArticleService {
     }
     }
 
 
     /**
     /**
-     * 按文章 ID 分页查询阅读记录,返回用户昵称、时长、最后阅读时间
+     * 按文章 ID 分页查询阅读记录(每人聚合一行),返回昵称、累计时长、阅读次数、最后阅读时间
      */
      */
     public Page<Map<String, Object>> getArticleReadingRecords(Long articleId, int page, int size) {
     public Page<Map<String, Object>> getArticleReadingRecords(Long articleId, int page, int size) {
-        long total = articleReadingRecordMapper.countByArticle(articleId);
+        long total = articleReadingRecordMapper.countDistinctByArticle(articleId);
         if (total == 0) {
         if (total == 0) {
             Page<Map<String, Object>> empty = new Page<>(page, size);
             Page<Map<String, Object>> empty = new Page<>(page, size);
             empty.setTotal(0);
             empty.setTotal(0);
@@ -366,7 +368,7 @@ public class ArticleService {
             return empty;
             return empty;
         }
         }
         int offset = (page - 1) * size;
         int offset = (page - 1) * size;
-        List<Map<String, Object>> records = articleReadingRecordMapper.selectByArticle(articleId, offset, size);
+        List<Map<String, Object>> records = articleReadingRecordMapper.selectByArticleAggregated(articleId, offset, size);
         Page<Map<String, Object>> result = new Page<>(page, size);
         Page<Map<String, Object>> result = new Page<>(page, size);
         result.setTotal(total);
         result.setTotal(total);
         result.setRecords(records);
         result.setRecords(records);
@@ -395,7 +397,8 @@ public class ArticleService {
             wrapper.eq(Article::getCategoryId, categoryId);
             wrapper.eq(Article::getCategoryId, categoryId);
         }
         }
         if (keyword != null && !keyword.trim().isEmpty()) {
         if (keyword != null && !keyword.trim().isEmpty()) {
-            wrapper.like(Article::getTitle, keyword.trim());
+            String kw = keyword.trim();
+            wrapper.and(w -> w.like(Article::getTitle, kw).or().like(Article::getSummary, kw).or().like(Article::getTags, kw));
         }
         }
         if (contentType != null && !contentType.isEmpty()) {
         if (contentType != null && !contentType.isEmpty()) {
             wrapper.eq(Article::getContentType, contentType);
             wrapper.eq(Article::getContentType, contentType);
@@ -414,7 +417,31 @@ public class ArticleService {
         }
         }
         SortUtil.applySort(wrapper, sortSpecs, null);
         SortUtil.applySort(wrapper, sortSpecs, null);
 
 
-        return articleMapper.selectPage(new Page<>(page, size), wrapper);
+        Page<Article> pageResult = articleMapper.selectPage(new Page<>(page, size), wrapper);
+
+        // 批量填充每篇文章的总阅读时长(按 child 聚合去重后求和)
+        List<Article> records = pageResult.getRecords();
+        if (records != null && !records.isEmpty()) {
+            String ids = records.stream()
+                    .map(a -> String.valueOf(a.getId()))
+                    .collect(Collectors.joining(","));
+            List<Map<String, Object>> durationRows = jdbcTemplate.queryForList(
+                    "SELECT article_id, SUM(duration_seconds) AS total_duration " +
+                    "FROM article_reading_records WHERE article_id IN (" + ids + ") GROUP BY article_id");
+            Map<Long, Integer> durationMap = new HashMap<>();
+            for (Map<String, Object> row : durationRows) {
+                Number articleId = (Number) row.get("article_id");
+                Number total = (Number) row.get("total_duration");
+                if (articleId != null && total != null) {
+                    durationMap.put(articleId.longValue(), total.intValue());
+                }
+            }
+            for (Article article : records) {
+                article.setTotalDurationSeconds(durationMap.getOrDefault(article.getId(), 0));
+            }
+        }
+
+        return pageResult;
     }
     }
 
 
     @Transactional
     @Transactional

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

@@ -11,7 +11,6 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -32,9 +31,6 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     @Resource
     @Resource
     private AssessmentAppointmentMapper assessmentAppointmentMapper;
     private AssessmentAppointmentMapper assessmentAppointmentMapper;
 
 
-    @Resource
-    private CommissionService commissionService;
-
     @Resource
     @Resource
     private CfCommissionService cfCommissionService;
     private CfCommissionService cfCommissionService;
 
 

+ 10 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -16,9 +16,10 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
-import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
 import java.util.List;
+import java.util.Set;
 
 
 /**
 /**
  * 统一 CF 值分佣服务。
  * 统一 CF 值分佣服务。
@@ -96,6 +97,7 @@ public class CfCommissionService {
                 int shareBps = getSysBps("product_platform_points_share", 1000);
                 int shareBps = getSysBps("product_platform_points_share", 1000);
                 return amountYuan * effectivePpoint / 100 * shareBps / 10000;
                 return amountYuan * effectivePpoint / 100 * shareBps / 10000;
             } catch (Exception e) {
             } catch (Exception e) {
+                log.warn("计算当前人返CF失败: productId={}, err={}", productId, e.getMessage());
                 return 0;
                 return 0;
             }
             }
         }
         }
@@ -118,7 +120,7 @@ public class CfCommissionService {
                         .orderByAsc(ReferralTree::getLevel));
                         .orderByAsc(ReferralTree::getLevel));
         if (referrals == null || referrals.isEmpty()) return;
         if (referrals == null || referrals.isEmpty()) return;
 
 
-        List<Long> processed = new ArrayList<>();
+        Set<Long> processed = new HashSet<>();
         for (ReferralTree ref : referrals) {
         for (ReferralTree ref : referrals) {
             Long referrerId = ref.getParentId();
             Long referrerId = ref.getParentId();
             if (processed.contains(referrerId)) continue;
             if (processed.contains(referrerId)) continue;
@@ -159,6 +161,7 @@ public class CfCommissionService {
                 if (p == null) return 0;
                 if (p == null) return 0;
                 return ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
                 return ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
             } catch (Exception e) {
             } catch (Exception e) {
+                log.warn("计算推荐人分润基数失败: productId={}, err={}", productId, e.getMessage());
                 return 0;
                 return 0;
             }
             }
         }
         }
@@ -188,6 +191,10 @@ public class CfCommissionService {
         r.setRefId(refId);
         r.setRefId(refId);
         r.setRemark(remark);
         r.setRemark(remark);
         r.setCreatedAt(new Date());
         r.setCreatedAt(new Date());
-        try { cfTransferRecordMapper.insert(r); } catch (Exception e) { log.warn("写流转记录失败: {}", e.getMessage()); }
+        try {
+            cfTransferRecordMapper.insert(r);
+        } catch (Exception e) {
+            log.warn("写流转记录失败: {}", e.getMessage());
+        }
     }
     }
 }
 }

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

@@ -29,7 +29,7 @@ public class CfReferralService {
     private CfRateTierMapper cfRateTierMapper;
     private CfRateTierMapper cfRateTierMapper;
 
 
     /**
     /**
-     * 绑定推荐关系时物化整条祖先链(含本人 L1)到 referral_tree。
+     * 绑定推荐关系时物化整条祖先链(不含本人,直接推荐人为 level 1)到 referral_tree。
      * 调用方:CommissionService.bindReferral() 设置 user.referrerId 之后。
      * 调用方:CommissionService.bindReferral() 设置 user.referrerId 之后。
      */
      */
     @Transactional
     @Transactional
@@ -75,7 +75,7 @@ public class CfReferralService {
 
 
     /**
     /**
      * 按团队规模匹配阶梯比例:cf_rate_tier WHERE enabled=1 AND min_team_size <= size ORDER BY min_team_size DESC LIMIT 1
      * 按团队规模匹配阶梯比例:cf_rate_tier WHERE enabled=1 AND min_team_size <= size ORDER BY min_team_size DESC LIMIT 1
-     * 无匹配返回 0。
+     * 无匹配返回 null。
      */
      */
     public CfRateTier matchRateTier(int teamSize) {
     public CfRateTier matchRateTier(int teamSize) {
         List<CfRateTier> tiers = cfRateTierMapper.selectList(
         List<CfRateTier> tiers = cfRateTierMapper.selectList(

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

@@ -42,10 +42,11 @@ public class DietRecommendationService {
                .eq(DietRecommendation::getRecommendationDate, date)
                .eq(DietRecommendation::getRecommendationDate, date)
                .in(DietRecommendation::getStatus, Arrays.asList("pending", "accepted", "completed"));
                .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<>();
         Map<String, Object> result = new HashMap<>();
-        if (record != null) {
+        if (!records.isEmpty()) {
+            DietRecommendation record = records.get(0);
             result.put("id", record.getId());
             result.put("id", record.getId());
             result.put("date", dateStr);
             result.put("date", dateStr);
             result.put("mealType", record.getMealType());
             result.put("mealType", record.getMealType());
@@ -96,7 +97,27 @@ public class DietRecommendationService {
         String normalizedMenuJson = safeWrite(normalized);
         String normalizedMenuJson = safeWrite(normalized);
         Map<String, Object> nutritionSummary = aggregateNutrition(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();
         DietRecommendation record = new DietRecommendation();
         record.setFamilyId(familyId);
         record.setFamilyId(familyId);
         record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
         record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 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.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
@@ -14,6 +15,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.math.BigDecimal;
 import java.util.*;
 import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
@@ -36,6 +38,9 @@ public class FoodRecommendService {
     @Resource
     @Resource
     private HealthReportService healthReportService;
     private HealthReportService healthReportService;
 
 
+    @Resource
+    private FoodService foodService;
+
     @Transactional
     @Transactional
     public void recalculateForUser(Long userId) {
     public void recalculateForUser(Long userId) {
         HealthReport report = healthReportService.getLatestReport(userId);
         HealthReport report = healthReportService.getLatestReport(userId);
@@ -46,16 +51,29 @@ public class FoodRecommendService {
             return;
             return;
         }
         }
 
 
-        List<ReportFoodSuitability> suitList = reportFoodSuitabilityMapper.selectList(
-                new LambdaQueryWrapper<ReportFoodSuitability>()
-                        .eq(ReportFoodSuitability::getReportId, report.getId())
-        );
-
-        if (suitList.isEmpty()) {
-            foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
-                    .eq(FoodRecommendIndex::getUserId, userId));
-            log.info("报告{}无食材适宜度数据,已清除用户{}的推荐指数", report.getId(), userId);
-            return;
+        // 优先从 foods 表读取(正常确认流程写入 foods 表);
+        // 若 foods 表无数据(审核路径只写 report_food_suitability),回退到 report_food_suitability
+        List<Food> reportFoods = foodService.listByReportId(report.getId());
+        if (reportFoods.isEmpty()) {
+            List<ReportFoodSuitability> fallback = reportFoodSuitabilityMapper.selectList(
+                    new LambdaQueryWrapper<ReportFoodSuitability>()
+                            .eq(ReportFoodSuitability::getReportId, report.getId()));
+            if (fallback.isEmpty()) {
+                foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
+                        .eq(FoodRecommendIndex::getUserId, userId));
+                log.info("报告{}无食材推荐数据,已清除用户{}的推荐指数", report.getId(), userId);
+                return;
+            }
+            // 回退:将 report_food_suitability 映射为 Food 列表后继续处理
+            reportFoods = new ArrayList<>();
+            for (ReportFoodSuitability rfs : fallback) {
+                Food f = new Food();
+                f.setId(rfs.getFoodId() != null ? rfs.getFoodId() : 0L);
+                f.setName(rfs.getFoodName());
+                f.setCategory(rfs.getCategory());
+                f.setScore(rfs.getScore());
+                reportFoods.add(f);
+            }
         }
         }
 
 
         List<FoodRecommendIndex> existingIndices = foodRecommendIndexMapper.selectList(
         List<FoodRecommendIndex> existingIndices = foodRecommendIndexMapper.selectList(
@@ -69,15 +87,17 @@ public class FoodRecommendService {
         Date now = new Date();
         Date now = new Date();
         Long reportId = report.getId();
         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);
             FoodRecommendIndex existing = existingMap.get(foodId);
 
 
             if (existing != null) {
             if (existing != null) {
                 if (!Objects.equals(existing.getIndexScore(), newIndex)) {
                 if (!Objects.equals(existing.getIndexScore(), newIndex)) {
                     Integer oldIndex = existing.getIndexScore();
                     Integer oldIndex = existing.getIndexScore();
                     existing.setIndexScore(newIndex);
                     existing.setIndexScore(newIndex);
+                    existing.setFoodName(foodName);
                     existing.setHealthReportId(reportId);
                     existing.setHealthReportId(reportId);
                     existing.setCalculatedAt(now);
                     existing.setCalculatedAt(now);
                     foodRecommendIndexMapper.updateById(existing);
                     foodRecommendIndexMapper.updateById(existing);
@@ -85,7 +105,7 @@ public class FoodRecommendService {
                     FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
                     FoodRecommendIdxLog logEntry = new FoodRecommendIdxLog();
                     logEntry.setUserId(userId);
                     logEntry.setUserId(userId);
                     logEntry.setFoodId(foodId);
                     logEntry.setFoodId(foodId);
-                    logEntry.setFoodName(suit.getFoodName());
+                    logEntry.setFoodName(foodName);
                     logEntry.setPreviousIndex(oldIndex);
                     logEntry.setPreviousIndex(oldIndex);
                     logEntry.setNewIndex(newIndex);
                     logEntry.setNewIndex(newIndex);
                     logEntry.setHealthReportId(reportId);
                     logEntry.setHealthReportId(reportId);
@@ -99,7 +119,7 @@ public class FoodRecommendService {
                 FoodRecommendIndex index = new FoodRecommendIndex();
                 FoodRecommendIndex index = new FoodRecommendIndex();
                 index.setUserId(userId);
                 index.setUserId(userId);
                 index.setFoodId(foodId);
                 index.setFoodId(foodId);
-                index.setFoodName(suit.getFoodName());
+                index.setFoodName(foodName);
                 index.setIndexScore(newIndex);
                 index.setIndexScore(newIndex);
                 index.setHealthReportId(reportId);
                 index.setHealthReportId(reportId);
                 index.setCalculatedAt(now);
                 index.setCalculatedAt(now);
@@ -135,6 +155,72 @@ public class FoodRecommendService {
         );
         );
     }
     }
 
 
+    /**
+     * 获取用户"当前食材"推荐列表(含分类与营养信息,供饮食推荐页展示)。
+     * 数据来自 food_recommend_idx(有新报告时 recalculateForUser 有则更新/无则添加),
+     * join foods 表补充 category/protein 等字段。
+     */
+    public List<Map<String, Object>> getCurrentFoods(Long userId) {
+        List<FoodRecommendIndex> indices = getIndexByUser(userId);
+        if (indices.isEmpty()) return new ArrayList<>();
+
+        Set<Long> foodIds = new HashSet<>();
+        for (FoodRecommendIndex idx : indices) {
+            if (idx.getFoodId() != null) foodIds.add(idx.getFoodId());
+        }
+        Map<Long, Food> foodMap = new HashMap<>();
+        if (!foodIds.isEmpty()) {
+            List<Food> foods = foodService.listByIds(foodIds);
+            for (Food f : foods) {
+                if (f != null) foodMap.put(f.getId(), f);
+            }
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (FoodRecommendIndex idx : indices) {
+            Food food = foodMap.get(idx.getFoodId());
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("name", food != null && food.getName() != null ? food.getName() : idx.getFoodName());
+            m.put("foodName", m.get("name"));
+            m.put("score", idx.getIndexScore());
+            m.put("category", food != null ? food.getCategory() : "");
+            if (food != null) {
+                m.put("protein", food.getProtein());
+                m.put("fat", food.getFat());
+                m.put("carbs", food.getCarbs());
+                m.put("fiber", food.getFiber());
+                m.put("energyKj", food.getEnergyKj());
+            }
+            result.add(m);
+        }
+        return result;
+    }
+
+    /** 将当前食材数据转换为 Food 实体列表,供 /api/health/foods 无 reportId 时使用 */
+    public List<Food> getCurrentFoodsAsFoodList(Long userId) {
+        List<Map<String, Object>> current = getCurrentFoods(userId);
+        List<Food> result = new ArrayList<>();
+        for (Map<String, Object> m : current) {
+            Food food = new Food();
+            food.setName((String) m.get("name"));
+            food.setCategory((String) m.get("category"));
+            Integer score = (Integer) m.get("score");
+            if (score != null) food.setScore(score);
+            BigDecimal protein = (BigDecimal) m.get("protein");
+            if (protein != null) food.setProtein(protein);
+            BigDecimal fat = (BigDecimal) m.get("fat");
+            if (fat != null) food.setFat(fat);
+            BigDecimal carbs = (BigDecimal) m.get("carbs");
+            if (carbs != null) food.setCarbs(carbs);
+            BigDecimal fiber = (BigDecimal) m.get("fiber");
+            if (fiber != null) food.setFiber(fiber);
+            BigDecimal energyKj = (BigDecimal) m.get("energyKj");
+            if (energyKj != null) food.setEnergyKj(energyKj);
+            result.add(food);
+        }
+        return result;
+    }
+
     public List<FoodRecommendIdxLog> getIndexHistory(Long userId, Long foodId) {
     public List<FoodRecommendIdxLog> getIndexHistory(Long userId, Long foodId) {
         return foodRecommendIdxLogMapper.selectList(
         return foodRecommendIdxLogMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIdxLog>()
                 new LambdaQueryWrapper<FoodRecommendIdxLog>()

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -183,4 +183,9 @@ public class FoodService {
         }
         }
         log.info("已保存{}条食材推荐到报告{}", foods.size(), reportId);
         log.info("已保存{}条食材推荐到报告{}", foods.size(), reportId);
     }
     }
+
+    public List<Food> listByIds(Collection<Long> ids) {
+        if (ids == null || ids.isEmpty()) return new ArrayList<>();
+        return foodMapper.selectBatchIds(ids);
+    }
 }
 }

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

@@ -31,9 +31,6 @@ public class MemberSubscriptionService {
     @Resource
     @Resource
     private FamilyMapper familyMapper;
     private FamilyMapper familyMapper;
 
 
-    @Resource
-    private CommissionService commissionService;
-
     @Resource
     @Resource
     private CfCommissionService cfCommissionService;
     private CfCommissionService cfCommissionService;
 
 

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

@@ -42,9 +42,6 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     @Resource
     private PaymentOrderMapper paymentOrderMapper;
     private PaymentOrderMapper paymentOrderMapper;
 
 
-    @Resource
-    private CommissionService commissionService;
-
     @Resource
     @Resource
     private CfCommissionService cfCommissionService;
     private CfCommissionService cfCommissionService;
 
 

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

@@ -1,344 +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 CfCommissionService cfCommissionService;
-
-    @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());
-                }
-
-                cfCommissionService.settle(order.getId(), "package", order.getUserId(),
-                        order.getFamilyId(), 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;
-    }
-}

+ 184 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java

@@ -0,0 +1,184 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.ProductGiftItemDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductGiftConfig;
+import com.etotem.cfc.entity.ProductGiftItem;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.ProductGiftConfigMapper;
+import com.etotem.cfc.mapper.ProductGiftItemMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class ProductGiftService {
+
+    @Resource
+    private ProductGiftConfigMapper configMapper;
+    @Resource
+    private ProductGiftItemMapper itemMapper;
+    @Resource
+    private ProductMapper productMapper;
+    @Resource
+    private MembershipLevelMapper levelMapper;
+
+    /** 保存赠品配置(幂等:先删后插 config + items) */
+    @Transactional
+    public Result<Void> saveGiftConfig(Long productId, String levelCode, Integer status, List<ProductGiftItemDTO> items) {
+        if (productId == null || levelCode == null || status == null) {
+            return Result.error("参数不完整");
+        }
+        // 校验等级存在且非 FREE
+        MembershipLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
+                .eq(MembershipLevel::getLevelCode, levelCode));
+        if (level == null || "FREE".equals(level.getLevelCode())) {
+            return Result.error("无效的赠送等级");
+        }
+        // 校验赠品商品(giftType=product)存在且启用
+        if (items != null) {
+            for (ProductGiftItemDTO it : items) {
+                if ("product".equals(it.getGiftType()) && it.getGiftProductId() != null) {
+                    Product giftProduct = productMapper.selectById(it.getGiftProductId());
+                    if (giftProduct == null || !"on_shelf".equals(giftProduct.getStatus())) {
+                        return Result.error("赠品商品不存在或未上架");
+                    }
+                }
+            }
+        }
+        // 先删后插(整个 config + 其下所有 items)
+        configMapper.delete(new LambdaQueryWrapper<ProductGiftConfig>()
+                .eq(ProductGiftConfig::getProductId, productId)
+                .eq(ProductGiftConfig::getLevelCode, levelCode));
+        if (status == 1) {
+            ProductGiftConfig config = new ProductGiftConfig();
+            config.setProductId(productId);
+            config.setLevelCode(levelCode);
+            config.setStatus(1);
+            config.setCreatedAt(new Date());
+            config.setUpdatedAt(new Date());
+            configMapper.insert(config);
+            if (items != null) {
+                for (ProductGiftItemDTO it : items) {
+                    ProductGiftItem item = new ProductGiftItem();
+                    item.setConfigId(config.getId());
+                    item.setGiftType(it.getGiftType());
+                    item.setGiftProductId(it.getGiftProductId());
+                    item.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+                    item.setSortOrder(it.getSortOrder() != null ? it.getSortOrder() : 0);
+                    // 快照商品名称和图
+                    if (it.getGiftProductId() != null) {
+                        Product gp = productMapper.selectById(it.getGiftProductId());
+                        if (gp != null) {
+                            item.setGiftProductName(gp.getName());
+                            item.setGiftProductImage(gp.getCoverImage());
+                        }
+                    }
+                    item.setCreatedAt(new Date());
+                    itemMapper.insert(item);
+                }
+            }
+        }
+        return Result.success(null);
+    }
+
+    /** 查询商品全部等级赠品配置(管理端用) */
+    public List<GiftConfigVO> listConfigsByProduct(Long productId) {
+        List<ProductGiftConfig> configs = configMapper.selectList(
+                new LambdaQueryWrapper<ProductGiftConfig>()
+                        .eq(ProductGiftConfig::getProductId, productId)
+                        .orderByAsc(ProductGiftConfig::getLevelCode));
+        return toVOList(configs);
+    }
+
+    /** 查询某商品某等级下启用的赠品配置(含库存充足校验,供商品详情展示) */
+    public List<GiftConfigVO> getActiveConfigsByProductAndLevel(Long productId, String levelCode) {
+        ProductGiftConfig config = configMapper.selectOne(new LambdaQueryWrapper<ProductGiftConfig>()
+                .eq(ProductGiftConfig::getProductId, productId)
+                .eq(ProductGiftConfig::getLevelCode, levelCode)
+                .eq(ProductGiftConfig::getStatus, 1)
+                .last("LIMIT 1"));
+        if (config == null) {
+            return Collections.emptyList();
+        }
+        List<ProductGiftItem> items = itemMapper.selectList(
+                new LambdaQueryWrapper<ProductGiftItem>()
+                        .eq(ProductGiftItem::getConfigId, config.getId())
+                        .orderByAsc(ProductGiftItem::getSortOrder));
+        GiftConfigVO vo = new GiftConfigVO();
+        vo.setId(config.getId());
+        vo.setLevelCode(config.getLevelCode());
+        MembershipLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
+                .eq(MembershipLevel::getLevelCode, levelCode));
+        vo.setLevelName(level != null ? level.getLevelName() : levelCode);
+        vo.setStatus(config.getStatus());
+        List<GiftConfigVO.GiftItemVO> itemVOs = new ArrayList<>();
+        for (ProductGiftItem it : items) {
+            GiftConfigVO.GiftItemVO itemVO = new GiftConfigVO.GiftItemVO();
+            itemVO.setId(it.getId());
+            itemVO.setGiftType(it.getGiftType());
+            itemVO.setGiftProductId(it.getGiftProductId());
+            itemVO.setGiftProductName(it.getGiftProductName());
+            itemVO.setGiftProductImage(it.getGiftProductImage());
+            itemVO.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+            itemVO.setSortOrder(it.getSortOrder());
+            // 库存充足校验:实物赠品必须库存>0
+            if ("product".equals(it.getGiftType()) && it.getGiftProductId() != null) {
+                Product gp = productMapper.selectById(it.getGiftProductId());
+                if (gp == null || gp.getStock() == null || gp.getStock() <= 0) {
+                    continue; // 库存不足则不展示
+                }
+            }
+            itemVOs.add(itemVO);
+        }
+        vo.setItems(itemVOs);
+        return Collections.singletonList(vo);
+    }
+
+    /** 根据 ID 查询赠品项 */
+    public ProductGiftItem getActiveItemById(Long itemId) {
+        return itemMapper.selectById(itemId);
+    }
+
+    private List<GiftConfigVO> toVOList(List<ProductGiftConfig> configs) {
+        List<GiftConfigVO> result = new ArrayList<>();
+        for (ProductGiftConfig cfg : configs) {
+            GiftConfigVO vo = new GiftConfigVO();
+            vo.setId(cfg.getId());
+            vo.setLevelCode(cfg.getLevelCode());
+            MembershipLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
+                    .eq(MembershipLevel::getLevelCode, cfg.getLevelCode()));
+            vo.setLevelName(level != null ? level.getLevelName() : cfg.getLevelCode());
+            vo.setStatus(cfg.getStatus());
+            List<ProductGiftItem> items = itemMapper.selectList(
+                    new LambdaQueryWrapper<ProductGiftItem>()
+                            .eq(ProductGiftItem::getConfigId, cfg.getId())
+                            .orderByAsc(ProductGiftItem::getSortOrder));
+            List<GiftConfigVO.GiftItemVO> itemVOs = new ArrayList<>();
+            for (ProductGiftItem it : items) {
+                GiftConfigVO.GiftItemVO itemVO = new GiftConfigVO.GiftItemVO();
+                itemVO.setId(it.getId());
+                itemVO.setGiftType(it.getGiftType());
+                itemVO.setGiftProductId(it.getGiftProductId());
+                itemVO.setGiftProductName(it.getGiftProductName());
+                itemVO.setGiftProductImage(it.getGiftProductImage());
+                itemVO.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+                itemVO.setSortOrder(it.getSortOrder());
+                itemVOs.add(itemVO);
+            }
+            vo.setItems(itemVOs);
+            result.add(vo);
+        }
+        return result;
+    }
+}

+ 122 - 4
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.ProductServicePersonSlot;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
+import com.etotem.cfc.service.PlatformPointsService;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
 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.entity.ProductSku;
 import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.service.ProductSkuService;
 import com.etotem.cfc.service.ProductSkuService;
-import com.etotem.cfc.service.CommissionService;
 import com.etotem.cfc.service.AssessmentProductService;
 import com.etotem.cfc.service.AssessmentProductService;
 import com.etotem.cfc.service.AssessmentQuotaService;
 import com.etotem.cfc.service.AssessmentQuotaService;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
@@ -47,6 +48,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import com.etotem.cfc.dto.ChildInfoDTO;
 import com.etotem.cfc.dto.ChildInfoDTO;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.MembershipLevelDTO;
 import com.etotem.cfc.service.EnergyService;
 import com.etotem.cfc.service.EnergyService;
 import com.etotem.cfc.service.UnlockGateService;
 import com.etotem.cfc.service.UnlockGateService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.UserService;
@@ -54,6 +57,10 @@ import com.alibaba.fastjson.JSON;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.entity.ProductGiftItem;
+import com.etotem.cfc.entity.ProductOrderGift;
+import com.etotem.cfc.mapper.ProductOrderGiftMapper;
+import java.util.Set;
 
 
 @Service
 @Service
 public class ProductOrderService {
 public class ProductOrderService {
@@ -81,9 +88,6 @@ public class ProductOrderService {
     @Resource
     @Resource
     private ProductBundleItemMapper bundleItemMapper;
     private ProductBundleItemMapper bundleItemMapper;
 
 
-    @Resource
-    private CommissionService commissionService;
-
     @Resource
     @Resource
     private CfCommissionService cfCommissionService;
     private CfCommissionService cfCommissionService;
 
 
@@ -120,6 +124,9 @@ public class ProductOrderService {
     @Resource
     @Resource
     private FamilyPlatformPointsService familyPlatformPointsService;
     private FamilyPlatformPointsService familyPlatformPointsService;
 
 
+    @Resource
+    private PlatformPointsService platformPointsService;
+
     @Resource
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
     private SupplyHierarchyService supplyHierarchyService;
 
 
@@ -150,6 +157,12 @@ public class ProductOrderService {
     @Resource
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
 
+    @Resource
+    private ProductOrderGiftMapper productOrderGiftMapper;
+
+    @Resource
+    private ProductGiftService productGiftService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
         if (buyerId == null) {
             return Result.error("请先登录");
             return Result.error("请先登录");
@@ -239,6 +252,11 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
         orderMapper.insert(order);
+        // ===== 赠品校验与记录 =====
+        Result<Void> giftResult = processGifts(order, dto, product);
+        if (giftResult.getCode() != 200) {
+            return Result.error(giftResult.getMessage());
+        }
         return Result.success(ProductOrderDTO.from(order));
         return Result.success(ProductOrderDTO.from(order));
     }
     }
 
 
@@ -509,9 +527,76 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
         orderMapper.insert(order);
+        // ===== 赠品校验与记录(仅单商品下单支持赠品) =====
+        if (dto.getGiftItemIds() != null && !dto.getGiftItemIds().isEmpty()) {
+            if (dto.getItems().size() > 1) {
+                return Result.error("赠品仅支持单商品下单");
+            }
+            Result<Void> giftResult = processGifts(order, dto, firstProduct);
+            if (giftResult.getCode() != 200) {
+                return Result.error(giftResult.getMessage());
+            }
+        }
         return Result.success(ProductOrderDTO.from(order));
         return Result.success(ProductOrderDTO.from(order));
     }
     }
 
 
+    /**
+     * 赠品校验与记录:校验 giftItemIds 属于该商品该等级启用配置,写入 product_order_gifts(fulfilled=0)。
+     * 返回 code!=200 表示校验失败(调用方返回 Result.error(message))。
+     */
+    private Result<Void> processGifts(ProductOrder order, CreateProductOrderDTO dto, Product product) {
+        if (dto.getGiftItemIds() == null || dto.getGiftItemIds().isEmpty()) {
+            return Result.success(null);
+        }
+        // 家庭创建者等级(MembershipService.getCurrentLevel 无会员时返回 FREE)
+        MembershipLevelDTO currentLevel = order.getFamilyId() != null
+                ? membershipService.getCurrentLevel(order.getFamilyId())
+                : null;
+        String userLevel = currentLevel != null ? currentLevel.getLevelCode() : null;
+        if (userLevel == null || "FREE".equals(userLevel)) {
+            return Result.error("您暂无会员等级,无法享受赠品");
+        }
+        // 查询该商品该等级下启用的赠品项(含库存校验)
+        List<GiftConfigVO> activeConfigs = productGiftService.getActiveConfigsByProductAndLevel(
+                product.getId(), userLevel);
+        if (activeConfigs.isEmpty()) {
+            return Result.error("您所在等级暂无该商品赠品");
+        }
+        Set<Long> allowedItemIds = activeConfigs.stream()
+                .flatMap(c -> c.getItems().stream())
+                .map(GiftConfigVO.GiftItemVO::getId)
+                .collect(Collectors.toSet());
+        for (Long itemId : dto.getGiftItemIds()) {
+            if (!allowedItemIds.contains(itemId)) {
+                return Result.error("赠品项不存在或超出权限");
+            }
+            ProductGiftItem giftItem = productGiftService.getActiveItemById(itemId);
+            if (giftItem == null) {
+                return Result.error("赠品项不存在");
+            }
+            // 赠品商品库存复核(下单时再次校验,防止详情页与下单之间库存耗尽)
+            if ("product".equals(giftItem.getGiftType()) && giftItem.getGiftProductId() != null) {
+                Product gp = productMapper.selectById(giftItem.getGiftProductId());
+                if (gp == null || gp.getStock() == null || gp.getStock() <= 0) {
+                    return Result.error("赠品商品库存不足: " + (giftItem.getGiftProductName() != null ? giftItem.getGiftProductName() : "赠品"));
+                }
+            }
+            ProductOrderGift orderGift = new ProductOrderGift();
+            orderGift.setOrderId(order.getId());
+            orderGift.setOrderNo(order.getOrderNo());
+            orderGift.setGiftType(giftItem.getGiftType());
+            orderGift.setGiftProductId(giftItem.getGiftProductId());
+            orderGift.setGiftProductName(giftItem.getGiftProductName());
+            orderGift.setGiftProductImage(giftItem.getGiftProductImage());
+            orderGift.setCfValue(giftItem.getCfValue() != null ? giftItem.getCfValue() : 0);
+            orderGift.setQuantity(1);
+            orderGift.setFulfilled(0);
+            orderGift.setCreatedAt(new Date());
+            productOrderGiftMapper.insert(orderGift);
+        }
+        return Result.success(null);
+    }
+
     public Result<Map<String, Object>> pay(String orderNo, Long userId) {
     public Result<Map<String, Object>> pay(String orderNo, Long userId) {
         ProductOrder order = orderMapper.selectOne(
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -556,6 +641,12 @@ public class ProductOrderService {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    /**
+     * 支付成功处理:扣减库存(physical 单商品 / bundle 穿透子商品)+ 更新订单状态
+     * 事务保证 bundle 多子商品扣减的原子性——任一子商品库存不足时整体回滚,
+     * 避免部分出库(已扣子商品库存 + 已写 outbound 流水残留)
+     */
+    @Transactional(rollbackFor = Exception.class)
     public Result<String> handlePaymentSuccess(String orderNo, String transactionId) {
     public Result<String> handlePaymentSuccess(String orderNo, String transactionId) {
         ProductOrder order = orderMapper.selectOne(
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -693,6 +784,33 @@ public class ProductOrderService {
             log.error("商品赠送会员失败: orderNo={}", orderNo, e);
             log.error("商品赠送会员失败: orderNo={}", orderNo, e);
             // 不阻塞支付流程
             // 不阻塞支付流程
         }
         }
+        // 发放订单赠品(CF值 + 实物库存扣减)
+        try {
+            List<ProductOrderGift> pendingGifts = productOrderGiftMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrderGift>()
+                            .eq(ProductOrderGift::getOrderId, order.getId())
+                            .eq(ProductOrderGift::getFulfilled, 0));
+            for (ProductOrderGift gift : pendingGifts) {
+                if ("cf".equals(gift.getGiftType()) && gift.getCfValue() != null && gift.getCfValue() > 0) {
+                    platformPointsService.earn(order.getBuyerId(), gift.getCfValue(),
+                            "product_gift", order.getId(),
+                            "购买商品赠品: " + (gift.getGiftProductName() != null ? gift.getGiftProductName() : "CF值"));
+                } else if ("product".equals(gift.getGiftType()) && gift.getGiftProductId() != null) {
+                    int qty = gift.getQuantity() != null ? gift.getQuantity() : 1;
+                    boolean stockOk = productService.decreaseStock(gift.getGiftProductId(), qty);
+                    if (!stockOk) {
+                        log.error("赠品库存扣减失败: orderNo={}, giftProductId={}", orderNo, gift.getGiftProductId());
+                    } else {
+                        inventoryService.recordOutbound(gift.getGiftProductId(), null, qty,
+                                "gift", order.getId(), null, "商品赠品:" + orderNo);
+                    }
+                }
+                gift.setFulfilled(1);
+                productOrderGiftMapper.updateById(gift);
+            }
+        } catch (Exception e) {
+            log.error("订单{}赠品发放异常: {}", orderNo, e.getMessage());
+        }
         // 触发通关解锁刷新:购物成功(PURCHASE 关卡)
         // 触发通关解锁刷新:购物成功(PURCHASE 关卡)
         try {
         try {
             if (order.getFamilyId() != null) {
             if (order.getFamilyId() != null) {

+ 25 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.MembershipLevelDTO;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.AssessmentProduct;
 import com.etotem.cfc.entity.AssessmentProduct;
@@ -45,6 +47,9 @@ public class ProductService {
     @Resource
     @Resource
     private ProductGiftRuleService productGiftRuleService;
     private ProductGiftRuleService productGiftRuleService;
 
 
+    @Resource
+    private ProductGiftService productGiftService;
+
     @Resource
     @Resource
     private AssessmentProductService assessmentProductService;
     private AssessmentProductService assessmentProductService;
 
 
@@ -128,7 +133,7 @@ wrapper.in(Product::getId, productIds)
         return Result.success(data);
         return Result.success(data);
     }
     }
 
 
-    public Result<ProductDTO> detail(Long id) {
+    public Result<ProductDTO> detail(Long id, Long userId) {
         Product product = productMapper.selectById(id);
         Product product = productMapper.selectById(id);
         if (product == null) {
         if (product == null) {
             return Result.error("商品不存在");
             return Result.error("商品不存在");
@@ -165,6 +170,25 @@ wrapper.in(Product::getId, productIds)
             dto.setGiftLevelCode(giftRule.getLevelCode());
             dto.setGiftLevelCode(giftRule.getLevelCode());
             dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
             dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
         }
         }
+        // 可选赠品配置(当前用户等级)
+        if (userId != null) {
+            try {
+                User buyer = userMapper.selectById(userId);
+                if (buyer != null && buyer.getFamilyId() != null) {
+                    MembershipLevelDTO currentLevel = membershipService.getCurrentLevel(buyer.getFamilyId());
+                    String userLevel = currentLevel != null ? currentLevel.getLevelCode() : null;
+                    if (userLevel != null && !"FREE".equals(userLevel)) {
+                        List<GiftConfigVO> activeConfigs = productGiftService
+                                .getActiveConfigsByProductAndLevel(id, userLevel);
+                        if (!activeConfigs.isEmpty()) {
+                            dto.setGiftConfigs(activeConfigs);
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                // 赠品配置加载失败不影响商品详情
+            }
+        }
         // 配送与履约配置(按 deliveryMethod 附带自取地点/服务者/线上时间段)
         // 配送与履约配置(按 deliveryMethod 附带自取地点/服务者/线上时间段)
         Map<String, Object> deliveryConfig = new HashMap<>();
         Map<String, Object> deliveryConfig = new HashMap<>();
         productDeliveryConfigService.enrichProductDetail(id, product.getDeliveryMethod(), deliveryConfig);
         productDeliveryConfigService.enrichProductDetail(id, product.getDeliveryMethod(), deliveryConfig);

+ 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);
-}

+ 44 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4308,6 +4308,50 @@ CREATE TABLE IF NOT EXISTS product_gift_rules (
     INDEX idx_product_id (product_id)
     INDEX idx_product_id (product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠送会员规则';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠送会员规则';
 
 
+-- 商品赠品配置(商品×等级)
+CREATE TABLE IF NOT EXISTS product_gift_configs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '关联products表',
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)',
+    status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_product_level (product_id, level_code),
+    INDEX idx_product_id (product_id),
+    INDEX idx_level_code (level_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)';
+
+-- 商品赠品项
+CREATE TABLE IF NOT EXISTS product_gift_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)',
+    sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_config_id (config_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项';
+
+-- 订单赠品记录
+CREATE TABLE IF NOT EXISTS product_order_gifts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_id BIGINT NOT NULL COMMENT '关联product_orders.id',
+    order_no VARCHAR(64) NOT NULL COMMENT '订单号快照',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)',
+    quantity INT DEFAULT 1 COMMENT '数量(默认1)',
+    fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_order_id (order_id),
+    INDEX idx_order_no (order_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录';
+
 CREATE TABLE IF NOT EXISTS interaction_logs (
 CREATE TABLE IF NOT EXISTS interaction_logs (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     family_id BIGINT NOT NULL COMMENT '所属家庭ID',
     family_id BIGINT NOT NULL COMMENT '所属家庭ID',

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

@@ -145,9 +145,9 @@ public class ProductControllerTest {
         mockProduct.setStock(100);
         mockProduct.setStock(100);
         mockProduct.setSalesCount(58);
         mockProduct.setSalesCount(58);
 
 
-        when(productService.detail(1L)).thenReturn(Result.success(mockProduct));
+        when(productService.detail(1L, null)).thenReturn(Result.success(mockProduct));
 
 
-        Result<ProductDTO> result = controller.detail(params);
+        Result<ProductDTO> result = controller.detail(params, null);
 
 
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertNotNull(result.getData());
@@ -160,9 +160,9 @@ public class ProductControllerTest {
         Map<String, Object> params = new HashMap<>();
         Map<String, Object> params = new HashMap<>();
         params.put("id", 999L);
         params.put("id", 999L);
 
 
-        when(productService.detail(999L)).thenReturn(Result.error("商品不存在"));
+        when(productService.detail(999L, null)).thenReturn(Result.error("商品不存在"));
 
 
-        Result<ProductDTO> result = controller.detail(params);
+        Result<ProductDTO> result = controller.detail(params, null);
 
 
         assertEquals(500, result.getCode());
         assertEquals(500, result.getCode());
     }
     }
@@ -175,9 +175,9 @@ public class ProductControllerTest {
         ProductDTO mockProduct = createProductDTO(10L, "待发布任务模板", 9800, "draft");
         ProductDTO mockProduct = createProductDTO(10L, "待发布任务模板", 9800, "draft");
         mockProduct.setStock(0);
         mockProduct.setStock(0);
 
 
-        when(productService.detail(10L)).thenReturn(Result.success(mockProduct));
+        when(productService.detail(10L, null)).thenReturn(Result.success(mockProduct));
 
 
-        Result<ProductDTO> result = controller.detail(params);
+        Result<ProductDTO> result = controller.detail(params, null);
 
 
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals("draft", result.getData().getStatus());
         assertEquals("draft", result.getData().getStatus());

+ 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);
         setField(service, "familyMemberMapper", familyMemberMapper);
 
 
         // 默认 mock:全部维度健康,无寻源建议
         // 默认 mock:全部维度健康,无寻源建议
-        when(wuxingSourcingService.getAdvicesForLowScores(anyMap())).thenReturn(new ArrayList<>());
         when(wuxingSourcingService.levelOf(anyInt())).thenReturn("healthy");
         when(wuxingSourcingService.levelOf(anyInt())).thenReturn("healthy");
         when(wuxingSourcingService.levelName(anyString())).thenReturn("健康");
         when(wuxingSourcingService.levelName(anyString())).thenReturn("健康");
     }
     }

+ 63 - 2
cfc-frontend/AGENTS.md

@@ -86,7 +86,68 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **地址选择:** 使用 `components/address-picker.vue` 四级联动组件
 - **地址选择:** 使用 `components/address-picker.vue` 四级联动组件
 - **角色切换:** 页面根据用户角色显示不同内容
 - **角色切换:** 页面根据用户角色显示不同内容
 - **页面标题:** 所有页面的标题必须在 `pages.json` 的 `navigationBarTitleText` 中配置,禁止在 Vue 模板中手动写 `<text class="nav-title">`。仅动态标题页面(根据数据实时变化)可保留自定义 nav-bar
 - **页面标题:** 所有页面的标题必须在 `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** 在页面模板中写 `<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
 ## UNIQUE FEATURES
 
 

+ 0 - 6
cfc-frontend/__tests__/api/api.test.js

@@ -628,12 +628,6 @@ describe('guide & packages API', () => {
 // --------------- payment module ---------------
 // --------------- payment module ---------------
 
 
 describe('payment API', () => {
 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 () => {
   test('createWechatPayOrder sends POST', async () => {
     mockSuccess({ code: 200 })
     mockSuccess({ code: 200 })
     await api.createWechatPayOrder('NO001', '测试订单', 100)
     await api.createWechatPayOrder('NO001', '测试订单', 100)

+ 2 - 2
cfc-frontend/components/wuxing-sandbox.vue

@@ -9,8 +9,8 @@
     </view>
     </view>
   </slot>
   </slot>
 
 
-  <!-- 个人/全家切换开关(仅 familyUnlocked 时显示) -->
-  <view class="sandbox-toggle" v-if="familyUnlocked && hasMultipleMembers && mode !== 'preview'">
+  <!-- 个人/全家切换开关(有 2+ 成员时显示) -->
+  <view class="sandbox-toggle" v-if="hasMultipleMembers && mode !== 'preview'">
     <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
     <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
       <text class="toggle-icon">👤</text>
       <text class="toggle-icon">👤</text>
       <text class="toggle-text">个人</text>
       <text class="toggle-text">个人</text>

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

@@ -103,7 +103,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation } from '@/utils/api.js'
+import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation } from '@/utils/api.js'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -219,7 +219,21 @@ export default {
       uni.showToast({ title: '已跳过', icon: 'none' })
       uni.showToast({ title: '已跳过', icon: 'none' })
     },
     },
     regenerate: function() {
     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() {
     completeRecipe: function() {
       var self = this
       var self = this

+ 140 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -25,6 +25,26 @@
           <view v-if="hasMemberPrice && !isMember" class="member-hint" @click="goUpgrade">加入会员享会员价 {{ formatPriceWithSymbol(memberPriceVal) }} ›</view>
           <view v-if="hasMemberPrice && !isMember" class="member-hint" @click="goUpgrade">加入会员享会员价 {{ formatPriceWithSymbol(memberPriceVal) }} ›</view>
         </view>
         </view>
         <view v-if="product.giftLevelName" class="gift-tag">购买即送 {{ product.giftLevelName }} 会员</view>
         <view v-if="product.giftLevelName" class="gift-tag">购买即送 {{ product.giftLevelName }} 会员</view>
+        <!-- 可选赠品选择区 -->
+        <view v-if="giftItems.length > 0" class="gift-select-section">
+          <text class="gift-select-title">选择赠品(可多选)</text>
+          <view class="gift-select-list">
+            <view
+              v-for="item in giftItems"
+              :key="getGiftItemKey(item)"
+              :class="item._selected ? 'gift-select-item gift-select-item-active' : 'gift-select-item'"
+              @click="toggleGift(item)"
+            >
+              <image v-if="item.giftType === 'product' && item.giftProductImage" class="gift-select-img" :src="getImageUrl(item.giftProductImage)" mode="aspectFill" />
+              <view v-else class="gift-select-cf">💎</view>
+              <view class="gift-select-info">
+                <text class="gift-select-name">{{ item.giftType === 'product' ? item.giftProductName : (item.cfValue + ' CF值') }}</text>
+                <text class="gift-select-type">{{ item.giftType === 'product' ? '赠品商品' : 'CF值赠品' }}</text>
+              </view>
+              <view v-if="item._selected" class="gift-select-check">✓</view>
+            </view>
+          </view>
+        </view>
         <view class="meta-row">
         <view class="meta-row">
           <text class="meta-item" v-if="product.productType">{{ typeLabel }}</text>
           <text class="meta-item" v-if="product.productType">{{ typeLabel }}</text>
           <text class="meta-item" v-if="product.domain">{{ domainLabel }}</text>
           <text class="meta-item" v-if="product.domain">{{ domainLabel }}</text>
@@ -280,6 +300,20 @@ export default {
     },
     },
     buyButtonText: function() {
     buyButtonText: function() {
       return '立即购买'
       return '立即购买'
+    },
+    // 展平 giftConfigs 内所有赠品项(后端按当前等级返回,通常单配置)
+    giftItems: function() {
+      var list = []
+      var configs = this.product && this.product.giftConfigs
+      if (configs && configs.length > 0) {
+        for (var i = 0; i < configs.length; i++) {
+          var items = configs[i].items || []
+          for (var j = 0; j < items.length; j++) {
+            list.push(items[j])
+          }
+        }
+      }
+      return list
     }
     }
   },
   },
   onLoad(options) {
   onLoad(options) {
@@ -338,6 +372,16 @@ export default {
         if (res.code === 200 && res.data) {
         if (res.code === 200 && res.data) {
           that.product = res.data
           that.product = res.data
           that.bundleItems = (res.data && res.data.bundleItems) || []
           that.bundleItems = (res.data && res.data.bundleItems) || []
+          // 赠品项初始化选择状态(响应式)
+          var cfgList = res.data.giftConfigs
+          if (cfgList && cfgList.length > 0) {
+            for (var gi = 0; gi < cfgList.length; gi++) {
+              var itemsArr = cfgList[gi].items || []
+              for (var gj = 0; gj < itemsArr.length; gj++) {
+                that.$set(itemsArr[gj], '_selected', false)
+              }
+            }
+          }
           that.setShareInfo(
           that.setShareInfo(
             that.product.name,
             that.product.name,
             '/pages/discover-detail/product-detail/product-detail?id=' + that.product.id
             '/pages/discover-detail/product-detail/product-detail?id=' + that.product.id
@@ -536,6 +580,12 @@ export default {
       var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
       var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
       return map[method] || '快递配送'
       return map[method] || '快递配送'
     },
     },
+    getGiftItemKey: function(item) {
+      return item.id
+    },
+    toggleGift: function(item) {
+      this.$set(item, '_selected', !item._selected)
+    },
 
 
     onBuy: function() {
     onBuy: function() {
       var userId = uni.getStorageSync('userId')
       var userId = uni.getStorageSync('userId')
@@ -561,6 +611,20 @@ export default {
         productData.specDesc = this.buildSpecDesc()
         productData.specDesc = this.buildSpecDesc()
       }
       }
       uni.setStorageSync('checkoutItems', [productData])
       uni.setStorageSync('checkoutItems', [productData])
+      // 透传赠品选择到 checkout(收集所有 _selected=true 的赠品项ID)
+      var selectedGiftIds = []
+      var cfgList = this.product.giftConfigs
+      if (cfgList && cfgList.length > 0) {
+        for (var gi = 0; gi < cfgList.length; gi++) {
+          var itemsArr = cfgList[gi].items || []
+          for (var gj = 0; gj < itemsArr.length; gj++) {
+            if (itemsArr[gj]._selected) {
+              selectedGiftIds.push(itemsArr[gj].id)
+            }
+          }
+        }
+      }
+      uni.setStorageSync('checkoutGiftItemIds', selectedGiftIds)
       var deliveryMethod = this.product.deliveryMethod || 1
       var deliveryMethod = this.product.deliveryMethod || 1
       if (deliveryMethod > 1 && this.product.deliveryConfig) {
       if (deliveryMethod > 1 && this.product.deliveryConfig) {
         productData.deliveryMethod = deliveryMethod
         productData.deliveryMethod = deliveryMethod
@@ -1063,4 +1127,80 @@ export default {
 .btn-share::after {
 .btn-share::after {
   border: none;
   border: none;
 }
 }
+
+/* 赠品选择 */
+.gift-select-section {
+  background: #fff;
+  margin: 20rpx;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.gift-select-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.gift-select-list {
+  display: flex;
+  flex-direction: column;
+}
+.gift-select-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx;
+  border: 2rpx solid #eee;
+  border-radius: 12rpx;
+  margin-bottom: 12rpx;
+  background: #fff;
+}
+.gift-select-item-active {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.gift-select-img {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 12rpx;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+  background: #f7f7f7;
+}
+.gift-select-cf {
+  width: 80rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  font-size: 40rpx;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+  background: linear-gradient(135deg, #667eea22, #764ba222);
+  border-radius: 12rpx;
+}
+.gift-select-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.gift-select-name {
+  font-size: 28rpx;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.gift-select-type {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.gift-select-check {
+  color: #F97316;
+  font-size: 32rpx;
+  font-weight: bold;
+  margin-left: 16rpx;
+  flex-shrink: 0;
+}
 </style>
 </style>

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

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

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

@@ -83,7 +83,7 @@
           <text class="scr-advice-interpretation" v-if="adv.interpretation">{{ adv.interpretation }}</text>
           <text class="scr-advice-interpretation" v-if="adv.interpretation">{{ adv.interpretation }}</text>
           <view class="scr-advice-actions" v-if="adv.microActions && adv.microActions.length > 0">
           <view class="scr-advice-actions" v-if="adv.microActions && adv.microActions.length > 0">
             <text class="scr-advice-action-label">本周行动:</text>
             <text class="scr-advice-action-label">本周行动:</text>
-            <text v-for="(action, i) in adv.microActions" :key="'ma'+i" class="scr-advice-action">{{ i+1 }}. {{ action }}</text>
+            <text v-for="(action, i) in adv.microActions" :key="i" class="scr-advice-action">{{ i+1 }}. {{ action }}</text>
           </view>
           </view>
         </view>
         </view>
         <view class="scr-family-insight" v-if="result.advices[0] && result.advices[0].familyInsight">
         <view class="scr-family-insight" v-if="result.advices[0] && result.advices[0].familyInsight">

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

@@ -309,7 +309,7 @@ export default {
     goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
     goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
     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 }) },
     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 }) },
     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' }) },
     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() {
   onLoad() {
     this.loadData()
     this.loadData()
+    this._onLoadFired = true
   },
   },
   onShow() {
   onShow() {
-    this.loadData()
+    // 首次进入:onLoad 已加载,跳过避免双发;切 Tab 返回时刷新
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   },
   methods: {
   methods: {
     async loadData() {
     async loadData() {

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

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

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

@@ -252,9 +252,14 @@ export default {
     this.loadMembers()
     this.loadMembers()
     this.checkMembership()
     this.checkMembership()
     this.loadData()
     this.loadData()
+    this._onLoadFired = true
   },
   },
   onShow() {
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
   },
   methods: {
   methods: {
     async loadData() {
     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>
           <text class="func-dim-summary">{{ analysisData.dietaryPattern.detail }}</text>
         </view>
         </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>
         </view>
         </view>
 
 
@@ -164,26 +176,25 @@
         </view>
         </view>
       </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="'val-text val-' + (item._cssClass || 'normal')">{{ item.indicatorValue || '--' }}</text>
                   <text class="indicator-unit" v-if="item.unit">{{ item.unit }}</text>
                   <text class="indicator-unit" v-if="item.unit">{{ item.unit }}</text>
                 </view>
                 </view>
@@ -194,11 +205,31 @@
                   </picker>
                   </picker>
                 </view>
                 </view>
               </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>
+            <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>
-          <view class="empty-hint" v-if="nutritionGroups.length === 0">暂无数据</view>
+          <view class="empty-hint" v-if="diseaseRisks.length === 0">暂无疾病风险数据</view>
         </view>
         </view>
       </view>
       </view>
 
 
@@ -264,11 +295,14 @@ export default {
       report: {},
       report: {},
       reportInterpretation: null,
       reportInterpretation: null,
       isEditing: false,
       isEditing: false,
-      accordionOpen: { gut: false, nutri: false },
+      accordionOpen: { gut: false, risks: false, riskGroup: false },
+      riskGroupOpen: [],
+      nutriGroupOpen: [],
       scoreItems: [],
       scoreItems: [],
       allIndicators: [],
       allIndicators: [],
       gutIndicators: [],
       gutIndicators: [],
       nutritionGroups: [],
       nutritionGroups: [],
+      diseaseRisks: [],
       floraCount: 0,
       floraCount: 0,
       floraAbnormalCount: 0,
       floraAbnormalCount: 0,
       riskCount: 0,
       riskCount: 0,
@@ -314,6 +348,15 @@ export default {
         if (this.isAbnormalStatus(item.status)) abnormal++
         if (this.isAbnormalStatus(item.status)) abnormal++
       }
       }
       return { total: list.length, valid: valid, abnormal: 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) {
   onLoad: function(options) {
@@ -372,8 +415,12 @@ export default {
             return self.isAbnormalStatus(i.status)
             return self.isAbnormalStatus(i.status)
           }).length
           }).length
           self.riskCount = (res.data.diseaseRisks || []).length
           self.riskCount = (res.data.diseaseRisks || []).length
-          // 疾病风险:统计需关注的数量(风险等级非"低风险"即视为需关注)
+          // 疾病风险:内联展示 + 颜色区分,统计需关注的数量(风险等级非"低风险"即视为需关注)
           var risks = res.data.diseaseRisks || []
           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) {
           self.riskAttentionCount = risks.filter(function(r) {
             return r.riskLevel && r.riskLevel !== '低风险'
             return r.riskLevel && r.riskLevel !== '低风险'
           }).length
           }).length
@@ -541,18 +588,40 @@ report.gutType = s.gutType || ''
           seen[item.category] = true
           seen[item.category] = true
           groups.push({ category: item.category, items: [] })
           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
       this.nutritionGroups = groups
     },
     },
     toggleAccordion: function(key) {
     toggleAccordion: function(key) {
       this.accordionOpen[key] = !this.accordionOpen[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) {
     indicatorType: function(item) {
       return INDICATOR_TYPE_MAP[item.category] || 'nutrient'
       return INDICATOR_TYPE_MAP[item.category] || 'nutrient'
     },
     },
@@ -719,7 +788,11 @@ report.gutType = s.gutType || ''
       uni.navigateTo({ url: '/pages/growth/caution-detail' })
       uni.navigateTo({ url: '/pages/growth/caution-detail' })
     },
     },
     goToIndicators: function() {
     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() {}
     goAIChat: function() {}
@@ -763,6 +836,38 @@ report.gutType = s.gutType || ''
 .accordion-arrow { font-size: 24rpx; color: #999; }
 .accordion-arrow { font-size: 24rpx; color: #999; }
 .accordion-body { padding: 10rpx 30rpx 20rpx; }
 .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 { margin-bottom: 16rpx; }
 .sub-group-title { font-size: 26rpx; color: #4A9BD7; font-weight: 600; display: block; margin: 12rpx 0 8rpx; }
 .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>
 <template>
   <view class="page">
   <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>
       </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>
     </view>
 
 
     <scroll-view class="content" scroll-y>
     <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">
         <view class="food-header">
           <text class="food-name">{{ item.foodName || item.name }}</text>
           <text class="food-name">{{ item.foodName || item.name }}</text>
           <view class="food-cat-tag">
           <view class="food-cat-tag">
             <text>{{ item.category }}</text>
             <text>{{ item.category }}</text>
           </view>
           </view>
         </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>
         <view class="food-edit-row" v-if="isEditing">
         <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" />
           <input class="edit-input" type="number" v-model="item.score" />
         </view>
         </view>
-        <view class="food-score" :class="(item.score || 0) >= 5 ? 'score-good' : 'score-normal'">
-          <text>推荐: {{ item.score }}</text>
-        </view>
       </view>
       </view>
       <view class="empty-hint" v-if="filteredFoods.length === 0">暂无数据</view>
       <view class="empty-hint" v-if="filteredFoods.length === 0">暂无数据</view>
       <view class="bottom-spacer"></view>
       <view class="bottom-spacer"></view>
     </scroll-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="bottom-bar" v-if="isEditing">
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
@@ -55,7 +86,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getReportDetail, editHealthReport, getHealthFoods } from '../../utils/api.js'
+import { getHealthFoods, editHealthReport, queryFoodKnowledge } from '../../utils/api.js'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -65,66 +96,124 @@ export default {
       foods: [],
       foods: [],
       isEditing: false,
       isEditing: false,
       editFoods: [],
       editFoods: [],
-      selectedCategory: '全部',
-      sortDesc: true,
-      showRecommendedOnly: false,
-      categories: ['全部', '主食', '蔬菜', '水果', '肉类', '其它']
+      searchText: '',
+      knowledgeVisible: false,
+      knowledgeData: {},
+      _kbCache: {}
     }
     }
   },
   },
   computed: {
   computed: {
     filteredFoods: function() {
     filteredFoods: function() {
       var list = this.isEditing ? this.editFoods : this.foods
       var list = this.isEditing ? this.editFoods : this.foods
-      if (this.selectedCategory !== '全部') {
+      var kw = (this.searchText || '').trim()
+      if (kw) {
         list = list.filter(function(item) {
         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
       return list
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
+    // 不传 reportId 时取当前用户最新报告的食材推荐
     this.reportId = options.reportId ? parseInt(options.reportId) : null
     this.reportId = options.reportId ? parseInt(options.reportId) : null
-    if (this.reportId) {
-      this.loadData()
-    }
+    this.loadData()
   },
   },
   methods: {
   methods: {
     goBack: function() {
     goBack: function() {
       uni.navigateBack()
       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() {
     loadData: function() {
       var self = this
       var self = this
-      getHealthFoods(this.reportId).then(function(res) {
+      getHealthFoods(self.reportId).then(function(res) {
         if (res.code === 200 && res.data) {
         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 {
             return {
-              foodName: item.name || item.foodName || item.name,
+              foodName: item.name || item.foodName || '',
               name: item.name || item.foodName || '',
               name: item.name || item.foodName || '',
               score: item.score != null ? parseInt(item.score, 10) : 0,
               score: item.score != null ? parseInt(item.score, 10) : 0,
               category: item.category || '其它',
               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() {
     toggleEdit: function() {
       this.isEditing = true
       this.isEditing = true
       this.editFoods = JSON.parse(JSON.stringify(this.foods))
       this.editFoods = JSON.parse(JSON.stringify(this.foods))
@@ -151,30 +240,67 @@ export default {
 
 
 <style scoped>
 <style scoped>
 .page { min-height: 100vh; background: #f8f8f8; }
 .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; }
 .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; }
 .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-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; }
 .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }

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

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

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

@@ -86,9 +86,18 @@ export default {
     filteredFlora: function() {
     filteredFlora: function() {
       var list = this.isEditing ? this.editFlora : this.gutFlora
       var list = this.isEditing ? this.editFlora : this.gutFlora
       if (this.selectedTab === 'all') return list
       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') {
       if (this.selectedTab === 'probiotic') {
         return this.isEditing
         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
           : this.probioticSpecies
       }
       }
       // Taxonomy tabs: filter by category='taxonomy' + level
       // Taxonomy tabs: filter by category='taxonomy' + level
@@ -106,18 +115,11 @@ export default {
           return item.category === 'taxonomy' && item.level === targetLevel
           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 list.filter(function(item) {
-          return item.category === targetCategory
+          return (catToTab[item.category] || item.category) === targetTab
         })
         })
       }
       }
       return []
       return []

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

@@ -27,25 +27,35 @@
 
 
     <!-- 报告列表 -->
     <!-- 报告列表 -->
     <view class="report-list" v-else-if="reports.length > 0">
     <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>
             </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>
         </view>
         </view>
-        <template #right>
-          <button class="swipe-delete" @click.stop="confirmDelete(item)">删除</button>
-        </template>
-      </swipe-action>
+      </view>
     </view>
     </view>
 
 
     <!-- 空状态 -->
     <!-- 空状态 -->
@@ -72,7 +82,15 @@ export default {
       members: [],
       members: [],
       selectedMemberId: null,
       selectedMemberId: null,
       loading: false,
       loading: false,
-      memberId: null
+      memberId: null,
+      // 左滑删除
+      openMap: {},
+      curId: null,
+      moveOffset: 0,
+      startX: 0,
+      startY: 0,
+      touchDidMove: false,
+      baseOffset: 0
     }
     }
   },
   },
   onLoad(options) {
   onLoad(options) {
@@ -128,16 +146,64 @@ export default {
       })
       })
     },
     },
     goToDetail(reportId, reportType) {
     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() {
     goToUpload() {
       uni.navigateTo({ url: '/pages/health/report-upload' })
       uni.navigateTo({ url: '/pages/health/report-upload' })
     },
     },
@@ -234,16 +300,47 @@ export default {
 .report-list {
 .report-list {
   margin: 20rpx 30rpx;
   margin: 20rpx 30rpx;
 }
 }
-.report-card {
-  background: #fff;
+.swipe-row {
+  position: relative;
+  overflow: hidden;
   border-radius: 16rpx;
   border-radius: 16rpx;
-  padding: 28rpx 24rpx;
   margin-bottom: 16rpx;
   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;
   display: flex;
   flex-direction: row;
   flex-direction: row;
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
 }
 }
 .report-card:active {
 .report-card:active {
   opacity: 0.8;
   opacity: 0.8;
@@ -346,25 +443,6 @@ export default {
   opacity: 0.8;
   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 {
 .bottom-spacer {
   height: 60rpx;
   height: 60rpx;

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

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

+ 8 - 4
cfc-frontend/pages/home-pages/child-index.vue

@@ -543,8 +543,12 @@ export default {
         }
         }
         this.nickname = (userInfo && userInfo.nickname) ? userInfo.nickname : '小朋友'
         this.nickname = (userInfo && userInfo.nickname) ? userInfo.nickname : '小朋友'
 
 
-        const childrenRes = await getChildren()
-        const children = childrenRes.data || []
+        const childrenRes = await getFamilyMemberList({ visibleOnly: true })
+        var allMembers = childrenRes.data || []
+        if (!Array.isArray(allMembers)) allMembers = []
+        // 同步设置 familyMembersVisible,避免 loadFamilyMembersVisible() 再调一次
+        this.familyMembersVisible = allMembers
+        const children = allMembers.filter(function(m) { return m.effectiveRole === 'child' || m.source !== 'parent' })
         if (children.length === 0) {
         if (children.length === 0) {
           this.hasChildren = false
           this.hasChildren = false
           this.loading = false
           this.loading = false
@@ -653,8 +657,8 @@ export default {
           console.log('获取沙盘数据失败', e)
           console.log('获取沙盘数据失败', e)
         }
         }
 
 
-        // 加载家庭成员
-        this.loadFamilyMembersVisible()
+        // 家庭成员已在上方 loadData 开头通过 getFamilyMemberList 一次获取,不再重复调用
+        // this.loadFamilyMembersVisible()
 
 
         // 加载维度活动
         // 加载维度活动
         try {
         try {

+ 17 - 15
cfc-frontend/pages/home-pages/parent-index.vue

@@ -716,6 +716,7 @@ export default {
 
 
         // 优先使用仪表盘接口(包含孩子列表 + 统计数据)
         // 优先使用仪表盘接口(包含孩子列表 + 统计数据)
         let dashboardLoaded = false
         let dashboardLoaded = false
+        var _membersVisibleSet = false
         try {
         try {
           const dashRes = await getParentDashboard()
           const dashRes = await getParentDashboard()
           const dashData = dashRes.data || {}
           const dashData = dashRes.data || {}
@@ -736,22 +737,32 @@ export default {
             if (dashData.familyId) {
             if (dashData.familyId) {
               this._familyId = dashData.familyId
               this._familyId = dashData.familyId
             }
             }
+            // 从仪表盘同步设置 familyMembersVisible(含家长本人),避免后续 loadFamilyMembersVisible() 再调 getFamilyMemberList
+            var dashMembers = dashData.members || dashData.familyMembers || filteredChildren
+            this.familyMembersVisible = dashMembers
+            _membersVisibleSet = true
             dashboardLoaded = true
             dashboardLoaded = true
           }
           }
         } catch (e) {
         } catch (e) {
           console.log('获取仪表盘数据失败,降级到单独接口', e)
           console.log('获取仪表盘数据失败,降级到单独接口', e)
         }
         }
 
 
-        // 降级:使用原有单独接口
+        // 降级:使用原有单独接口(仅调用一次 getFamilyMemberList,合并 children 与 familyMembers 派生)
         if (!dashboardLoaded) {
         if (!dashboardLoaded) {
           try {
           try {
             const currentUserId = uni.getStorageSync('userId')
             const currentUserId = uni.getStorageSync('userId')
             const membersRes = await getFamilyMemberList({ visibleOnly: true, includeFamily: true })
             const membersRes = await getFamilyMemberList({ visibleOnly: true, includeFamily: true })
             const data = membersRes.data || {}
             const data = membersRes.data || {}
-            const members = data.members || []
+            var members = data.members || data || []
+            if (!Array.isArray(members)) members = []
             const parents = members.filter(function(p) { return p.source === 'parent' && p.id && p.id !== currentUserId }).map(function(p) { return { ...p, role: 'parent' } })
             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' } })
             const children = members.filter(function(c) { return c.source !== 'parent' && c.id }).map(function(c) { return { ...c, role: 'child' } })
             this.familyMembers = [].concat(parents, children)
             this.familyMembers = [].concat(parents, children)
+            // 从同一响应派生 children,避免再调 getChildren()(= getFamilyMemberList({}))
+            this.children = children
+            // 同步设置 familyMembersVisible,避免 loadFamilyMembersVisible() 重复调用 getFamilyMemberList
+            this.familyMembersVisible = members
+            _membersVisibleSet = true
             // 不自动选孩子:默认显示家长自己,等待用户从 FamilyMemberStrip 选择
             // 不自动选孩子:默认显示家长自己,等待用户从 FamilyMemberStrip 选择
             // 保存 familyId 用于后续请求
             // 保存 familyId 用于后续请求
             if (data.familyId) {
             if (data.familyId) {
@@ -760,17 +771,6 @@ export default {
           } catch (e) {
           } catch (e) {
             console.log('获取家庭成员失败', e)
             console.log('获取家庭成员失败', e)
           }
           }
-
-          if (this.children.length === 0) {
-            try {
-              const childrenRes = await getChildren()
-              const rawChildren = childrenRes.data || []
-              this.children = rawChildren
-              // 不自动选孩子:默认显示家长自己,等待用户从 FamilyMemberStrip 选择
-            } catch (e) {
-              console.log('获取孩子列表失败', e)
-            }
-          }
         }
         }
 
 
         const reviewRes = await getPendingReviewTasks()
         const reviewRes = await getPendingReviewTasks()
@@ -900,8 +900,10 @@ export default {
         // 加载推荐商品
         // 加载推荐商品
         this.loadRecommendedProducts()
         this.loadRecommendedProducts()
 
 
-        // 加载家庭成员可见列表
-        this.loadFamilyMembersVisible()
+        // 加载家庭成员可见列表(降级路径已在上面设置,此处跳过避免重复请求)
+        if (!_membersVisibleSet) {
+          this.loadFamilyMembersVisible()
+        }
         this.loadSelfCheckStatus()
         this.loadSelfCheckStatus()
         // 加载健康预警(P1-2)
         // 加载健康预警(P1-2)
         this.loadAlerts()
         this.loadAlerts()

+ 55 - 51
cfc-frontend/pages/index-home/index.vue

@@ -396,7 +396,7 @@ import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-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 nav from '../../utils/nav.js'
 import config from '@/config.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
@@ -570,6 +570,7 @@ membershipDays: 0,
     }
     }
   },
   },
   onLoad(options) {
   onLoad(options) {
+    this._onLoadFired = true
     this._doInit(options)
     this._doInit(options)
   },
   },
   onShow() {
   onShow() {
@@ -591,8 +592,13 @@ membershipDays: 0,
       }, 5000)
       }, 5000)
     }
     }
 
 
-    // 始终执行初始化,确保登录态变更后及时切换视图
-    this._doInit()
+    // 首次加载:onLoad 已触发 _doInit,此处跳过避免重复调用
+    // 后续 onShow(切 Tab 返回):刷新易变数据(会员状态、今日任务、挑战列表)
+    if (this._onLoadFired) {
+      this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
+    } else {
+      this._refreshOnShow()
+    }
     // 定期调研待办角标(内部自检登录态,未登录静默清空)
     // 定期调研待办角标(内部自检登录态,未登录静默清空)
     this._loadSurveyBadge()
     this._loadSurveyBadge()
   },
   },
@@ -624,15 +630,18 @@ membershipDays: 0,
 
 
     // ===== 登录态数据加载 =====
     // ===== 登录态数据加载 =====
     loadLoggedInData: function() {
     loadLoggedInData: function() {
-      this.loadFamilyMembers()
-      this.loadChildren()
+      this.loadFamilyData()
       this.loadReferralCode()
       this.loadReferralCode()
     },
     },
-    loadChildren: function() {
+    // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
+    loadFamilyData: function() {
       var self = this
       var self = this
-      getChildren().then(function(res) {
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res.code === 200 && res.data) {
         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')
           var savedChildId = uni.getStorageSync('currentChildId')
           if (savedChildId) {
           if (savedChildId) {
             self.currentChildId = savedChildId
             self.currentChildId = savedChildId
@@ -640,15 +649,22 @@ membershipDays: 0,
           self.loadDimensionData()
           self.loadDimensionData()
         }
         }
         self.childrenChecked = true
         self.childrenChecked = true
+        self.membersChecked = true
         self.loadTodayTasks()
         self.loadTodayTasks()
         self.loadFamilyFeed()
         self.loadFamilyFeed()
         self.loadNotices()
         self.loadNotices()
         self.loadNotifications()
         self.loadNotifications()
       }).catch(function(e) {
       }).catch(function(e) {
-        console.log('获取孩子列表失败', e)
+        console.log('获取家庭成员失败', e)
+        self.familyMembersVisible = []
+        self.children = []
         self.childrenChecked = true
         self.childrenChecked = true
+        self.membersChecked = true
       })
       })
     },
     },
+    // loadChildren 已废弃:loadFamilyData 统一调用 getFamilyMemberList 一次,
+    // 从响应中过滤出 effectiveRole==='child' 的成员赋给 this.children。
+    // 删除了原 loadChildren 方法(调用 getChildren 会重复打 /api/family/member/list)。
     loadDimensionData: function() {
     loadDimensionData: function() {
       this.loadSandboxData()
       this.loadSandboxData()
       this.loadEnergyOverview()
       this.loadEnergyOverview()
@@ -774,40 +790,32 @@ membershipDays: 0,
     },
     },
     loadFamilyFeed: function() {
     loadFamilyFeed: function() {
       var self = this
       var self = this
-      var children = self.children || []
+      // 仅对第一个孩子拉取任务历史(避免 N 个孩子 N 次 /api/tasks/history 请求)
+      var children = (self.children || []).filter(function(m) { return m.effectiveRole === 'child' })
       if (children.length === 0) {
       if (children.length === 0) {
         self.familyFeed = []
         self.familyFeed = []
         return
         return
       }
       }
-      var promises = children.map(function(child) {
-        return getTaskHistory(child.id, 1, 3).then(function(res) {
-          var tasks = []
-          if (res.code === 200) {
-            var data = res.data
-            tasks = (data && data.records) ? data.records : (Array.isArray(data) ? data : [])
+      var child = children[0]
+      getTaskHistory(child.id, 1, 3).then(function(res) {
+        var tasks = []
+        if (res.code === 200) {
+          var data = res.data
+          tasks = (data && data.records) ? data.records : (Array.isArray(data) ? data : [])
+        }
+        var feed = tasks.filter(function(t) { return t.status === 'completed' }).map(function(t) {
+          var points = t.pointsEarned || t.points || 0
+          var timeStr = ''
+          if (t.completedAt && typeof t.completedAt === 'string') {
+            timeStr = t.completedAt.substring(11, 16)
           }
           }
-          return tasks.filter(function(t) { return t.status === 'completed' }).map(function(t) {
-            var points = t.pointsEarned || t.points || 0
-            var timeStr = ''
-            if (t.completedAt && typeof t.completedAt === 'string') {
-              timeStr = t.completedAt.substring(11, 16)
-            }
-            return {
-              text: '✅ ' + (child.nickname || '小名') + ' 完成「' + (t.title || '任务') + '」 +' + points + '分',
-              time: timeStr,
-              type: 'task'
-            }
-          })
-        }).catch(function() { return [] })
-      })
-      Promise.all(promises).then(function(results) {
-        var all = []
-        for (var i = 0; i < results.length; i++) {
-          for (var j = 0; j < results[i].length; j++) {
-            all.push(results[i][j])
+          return {
+            text: '✅ ' + (child.nickname || '小名') + ' 完成「' + (t.title || '任务') + '」 +' + points + '分',
+            time: timeStr,
+            type: 'task'
           }
           }
-        }
-        self.familyFeed = all.slice(0, 10)
+        })
+        self.familyFeed = feed.slice(0, 10)
       }).catch(function() {
       }).catch(function() {
         self.familyFeed = []
         self.familyFeed = []
       })
       })
@@ -906,19 +914,7 @@ membershipDays: 0,
         uni.showToast({ title: '操作失败,请重试', icon: 'none' })
         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) {
     parseRelatedDimensions: function(val) {
       if (!val) return []
       if (!val) return []
       if (Array.isArray(val)) return val
       if (Array.isArray(val)) return val
@@ -1045,12 +1041,20 @@ membershipDays: 0,
       }
       }
       // 登录用户:直接在 TabBar 页面内联渲染,保持底签可见
       // 登录用户:直接在 TabBar 页面内联渲染,保持底签可见
       if (!this.loggedInInitDone) {
       if (!this.loggedInInitDone) {
+        // 首次加载:全量初始化(数据 + 会员状态),onShow 不再重复
         this.loggedInInitDone = true
         this.loggedInInitDone = true
         this.loadLoggedInData()
         this.loadLoggedInData()
+        this.checkMemberStatus()
       }
       }
-      this.checkMemberStatus()
       this._watchPageReady()
       this._watchPageReady()
     },
     },
+    _refreshOnShow() {
+      // 切 Tab 返回时刷新易变数据(仅会员状态、今日任务、挑战列表),避免重复加载
+      if (!this.isLoggedIn()) return
+      this.checkMemberStatus()
+      this.loadTodayTasks()
+      this.loadChallenges()
+    },
     _watchPageReady() {
     _watchPageReady() {
       if (this._watchPageReadyDone) return
       if (this._watchPageReadyDone) return
       this._watchPageReadyDone = true
       this._watchPageReadyDone = true

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

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

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

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

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

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

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

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

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

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

+ 13 - 3
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -450,7 +450,8 @@ export default {
       showPickupLocationModal: false,
       showPickupLocationModal: false,
       showTimeSlotModal: false,
       showTimeSlotModal: false,
       showServicePersonModal: false,
       showServicePersonModal: false,
-      showOnlineSlotModal: false
+       showOnlineSlotModal: false,
+       giftItemIds: []
     }
     }
   },
   },
   computed: {
   computed: {
@@ -508,6 +509,8 @@ export default {
   onLoad(options) {
   onLoad(options) {
     var that = this
     var that = this
     var stored = uni.getStorageSync('checkoutItems')
     var stored = uni.getStorageSync('checkoutItems')
+    var giftIds = uni.getStorageSync('checkoutGiftItemIds')
+    this.giftItemIds = (giftIds && giftIds.length > 0) ? giftIds : []
     if (stored && stored.length > 0) {
     if (stored && stored.length > 0) {
       this.items = stored
       this.items = stored
       this.calcAmount()
       this.calcAmount()
@@ -534,10 +537,15 @@ export default {
     this.loadCfBalance()
     this.loadCfBalance()
     this.loadUserPoints()
     this.loadUserPoints()
     this.loadDeliveryConfig()
     this.loadDeliveryConfig()
+    this._onLoadFired = true
   },
   },
   onShow() {
   onShow() {
-    // 从地址选择/编辑页返回后重新加载地址列表
-    this.loadDefaultAddress()
+    // 从地址选择/编辑页返回后重新加载地址列表(首次进入跳过,避免与 onLoad 双发)
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadDefaultAddress()
+    }
   },
   },
   methods: {
   methods: {
     calcAmount() {
     calcAmount() {
@@ -862,6 +870,7 @@ export default {
         method: 'POST',
         method: 'POST',
         data: {
         data: {
           items: orderItems,
           items: orderItems,
+          giftItemIds: that.giftItemIds,
           remark: this.remark,
           remark: this.remark,
           addressId: that.selectedAddress.id,
           addressId: that.selectedAddress.id,
           purchaseInfo: that.purchaseForm,
           purchaseInfo: that.purchaseForm,
@@ -881,6 +890,7 @@ export default {
             var order = res.data.data
             var order = res.data.data
             uni.showToast({ title: '下单成功', icon: 'success' })
             uni.showToast({ title: '下单成功', icon: 'success' })
             uni.removeStorageSync('checkoutItems')
             uni.removeStorageSync('checkoutItems')
+            uni.removeStorageSync('checkoutGiftItemIds')
             var moneyAmount = order.moneyAmount != null ? order.moneyAmount : order.totalAmount
             var moneyAmount = order.moneyAmount != null ? order.moneyAmount : order.totalAmount
             var pointsUsed = order.pointsUsed || 0
             var pointsUsed = order.pointsUsed || 0
             setTimeout(function() {
             setTimeout(function() {

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

@@ -175,11 +175,16 @@ export default {
     this.loadProducts()
     this.loadProducts()
     this.loadRecommended()
     this.loadRecommended()
     this.loadCartCount()
     this.loadCartCount()
+    this._onLoadFired = true
   },
   },
   onShow() {
   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: {
   methods: {
     loadCategories() {
     loadCategories() {

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

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

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

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

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

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

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

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

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

@@ -5,6 +5,47 @@ const BASE_URL = config.API_BASE_URL
 var _redirectingToLogin = false
 var _redirectingToLogin = false
 var _reloginLock = 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() {
 function _clearAuthAndRedirect() {
   if (_redirectingToLogin) return
   if (_redirectingToLogin) return
   _redirectingToLogin = true
   _redirectingToLogin = true
@@ -73,7 +114,9 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
       console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
       console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
       _applyLoginData(res.data)
       _applyLoginData(res.data)
       // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
       // 重放原请求;重试期间保持锁,防止重试仍 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
         _reloginLock = false
         resolve(r)
         resolve(r)
       }, function(e) {
       }, function(e) {
@@ -121,8 +164,18 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
     }
     }
     if (!hasFiltered) body = data
     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({
     uni.request({
       url: fullUrl,
       url: fullUrl,
       method: method,
       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')
 	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 = () => {
 export const getParentDashboard = () => {
   return request('/api/parent/dashboard', 'POST')
   return request('/api/parent/dashboard', 'POST')
@@ -515,8 +576,10 @@ export const reviewTask = (taskId, data) => {
   return request(`/api/tasks/${taskId}/review`, 'POST', 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) => {
 export const deleteTask = (taskId) => {
@@ -924,10 +987,6 @@ export const getAvailableGuides = (packageId) => {
   return request('/api/package/available-guides', 'POST', { 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) => {
 export const getOrderStatus = (orderNo) => {
@@ -1336,18 +1395,11 @@ export const cancelMyJoinRequest = (requestId) => {
 }
 }
 
 
 // ===== 家庭加入请求 API 别名(供组件统一引用)=====
 // ===== 家庭加入请求 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 聊天 =====
 // ===== AI 聊天 =====
 export const aiSendMessage = (data) => {
 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 = () => {
 export const getUserActionStats = () => {
@@ -2027,9 +2077,8 @@ export const getTodayTasksByCategory = (memberId, category) => {
   return request('/api/tasks/today', 'POST', { memberId: memberId, category: 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) =====
 // ===== 维度商品(domain 筛选,使用 productList) =====
 export const getProductsByDomain = (domain, page, size) => {
 export const getProductsByDomain = (domain, page, size) => {
@@ -2618,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 confirmDietIngredients = (data) => request('/api/diet/ingredients/confirm', 'POST', data)
 export const getDietRecommendation = (data) => request('/api/diet/recommendation/today', '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 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 updateDietRecommendation = (data) => request('/api/diet/recommendation/update', 'POST', data)
 export const completeDietRecommendation = (data) => request('/api/diet/recommendation/complete', 'POST', data)
 export const completeDietRecommendation = (data) => request('/api/diet/recommendation/complete', 'POST', data)
 export const recognizeDietFood = (data) => request('/api/diet/record/recognize', 'POST', data)
 export const recognizeDietFood = (data) => request('/api/diet/record/recognize', 'POST', data)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d01181d28ae6aeb77c625aa8db05b00b8256312a
+405ab8913a68e487726e5fbd79ea75488f7f3c01

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,156 @@
 
 
 ---
 ---
 
 
+## v1.0.1290 (2026-09-03)
+
+### 新功能
+- 家庭运营增加优惠券列表和使用记录(后端/family-coupons + /family-coupon-usage,前端弹窗展示)
+
+
+## v1.0.1289 (2026-09-03)
+
+### Bug 修复
+- processGifts 下单时复核赠品商品库存
+- 修复 toggleGift 后多余逗号语法错误
+- class 改用 _selected 状态字段,移除方法调用
+
+### 新功能
+- 管理端商品编辑页新增可选赠品配置区块
+- 小程序商品详情赠品选择区 + checkout 透传 giftItemIds
+- createMultiItem 单商品路径支持赠品(抽取 processGifts 共享方法)
+- handlePaymentSuccess 追加赠品发放逻辑
+- create 流程注入赠品校验与 product_order_gifts 记录
+- 新增管理端赠品配置接口(/api/admin/product-gift/save + /list)
+- 新增 ProductGiftService(配置保存/查询逻辑)
+- 新增三张赠品表实体类与Mapper接口
+- 建三张赠品相关表迁移(product_gift_configs/items/order_gifts)
+
+### 其他
+- feat(product-gift): 商品详情附带当前用户等级赠品配置
+- - 涵盖:数据库迁移、实体/Mapper、ProductGiftService、管理端接口、
+-   下单校验+写记录、支付成功发放、商品详情装配、小程序前端、管理端前端
+- 
+-   - 3张新表:product_gift_configs / product_gift_items / product_order_gifts
+-   - 与现有 product_gift_rules(赠送会员)完全独立
+-   - 赠品类型:商品 或 CF值,按会员等级配置,用户可多选
+-   - 下单时写入订单赠品记录(fulfilled=0),支付成功后发放
+- - 更新 PROJECT-OVERVIEW.md §4.2 电商与供应体系,记录新设计稿
+- 
+
+### 文档
+- 商品可选赠品功能实施计划
+- 商品可选赠品功能设计稿(product-gift-selection-design)
+
+
+## v1.0.1288 (2026-09-03)
+
+### Bug 修复
+- 移除能量沙盘切换开关的 familyUnlocked 门禁条件
+
+### 其他
+- 切换开关仅依赖 hasMultipleMembers(有 2+ 成员时显示),
+- familyUnlocked 仍作为 prop 保留,不影响 GateLock 门禁展示。
+- 
+
+
+## v1.0.1287 (2026-09-03)
+
+### Bug 修复
+- 根治 family/member/list 与 tasks/history 重复调用——index-home loadFamilyFeed 改为只查第一个孩子、parent-index/child-index 合并 getChildren+loadFamilyMembersVisible 为单次 getFamilyMemberList
+
+### 其他
+- - child-index: loadData 中 getChildren() 改为 getFamilyMemberList({visibleOnly:true}) 一次调用,同步设置 familyMembersVisible,跳过后续 loadFamilyMembersVisible()
+- - parent-index: dashboard 成功路径同步设置 familyMembersVisible + _membersVisibleSet,降级路径合并 getFamilyMemberList+getChildren 为单次调用并派生 children
+- 
+
+
+## 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)
 ## v1.0.1281 (2026-09-02)
 
 
 ### 其他
 ### 其他

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1281
+> 当前版本: v1.0.1290
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,156 @@
 
 
 ---
 ---
 
 
+## v1.0.1290 (2026-09-03)
+
+### 新功能
+- 家庭运营增加优惠券列表和使用记录(后端/family-coupons + /family-coupon-usage,前端弹窗展示)
+
+
+## v1.0.1289 (2026-09-03)
+
+### Bug 修复
+- processGifts 下单时复核赠品商品库存
+- 修复 toggleGift 后多余逗号语法错误
+- class 改用 _selected 状态字段,移除方法调用
+
+### 新功能
+- 管理端商品编辑页新增可选赠品配置区块
+- 小程序商品详情赠品选择区 + checkout 透传 giftItemIds
+- createMultiItem 单商品路径支持赠品(抽取 processGifts 共享方法)
+- handlePaymentSuccess 追加赠品发放逻辑
+- create 流程注入赠品校验与 product_order_gifts 记录
+- 新增管理端赠品配置接口(/api/admin/product-gift/save + /list)
+- 新增 ProductGiftService(配置保存/查询逻辑)
+- 新增三张赠品表实体类与Mapper接口
+- 建三张赠品相关表迁移(product_gift_configs/items/order_gifts)
+
+### 其他
+- feat(product-gift): 商品详情附带当前用户等级赠品配置
+- - 涵盖:数据库迁移、实体/Mapper、ProductGiftService、管理端接口、
+-   下单校验+写记录、支付成功发放、商品详情装配、小程序前端、管理端前端
+- 
+-   - 3张新表:product_gift_configs / product_gift_items / product_order_gifts
+-   - 与现有 product_gift_rules(赠送会员)完全独立
+-   - 赠品类型:商品 或 CF值,按会员等级配置,用户可多选
+-   - 下单时写入订单赠品记录(fulfilled=0),支付成功后发放
+- - 更新 PROJECT-OVERVIEW.md §4.2 电商与供应体系,记录新设计稿
+- 
+
+### 文档
+- 商品可选赠品功能实施计划
+- 商品可选赠品功能设计稿(product-gift-selection-design)
+
+
+## v1.0.1288 (2026-09-03)
+
+### Bug 修复
+- 移除能量沙盘切换开关的 familyUnlocked 门禁条件
+
+### 其他
+- 切换开关仅依赖 hasMultipleMembers(有 2+ 成员时显示),
+- familyUnlocked 仍作为 prop 保留,不影响 GateLock 门禁展示。
+- 
+
+
+## v1.0.1287 (2026-09-03)
+
+### Bug 修复
+- 根治 family/member/list 与 tasks/history 重复调用——index-home loadFamilyFeed 改为只查第一个孩子、parent-index/child-index 合并 getChildren+loadFamilyMembersVisible 为单次 getFamilyMemberList
+
+### 其他
+- - child-index: loadData 中 getChildren() 改为 getFamilyMemberList({visibleOnly:true}) 一次调用,同步设置 familyMembersVisible,跳过后续 loadFamilyMembersVisible()
+- - parent-index: dashboard 成功路径同步设置 familyMembersVisible + _membersVisibleSet,降级路径合并 getFamilyMemberList+getChildren 为单次调用并派生 children
+- 
+
+
+## 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)
 ## v1.0.1281 (2026-09-02)
 
 
 ### 其他
 ### 其他

+ 16 - 0
cfc-web/src/api/admin.js

@@ -701,6 +701,22 @@ export function listProductGiftRules(productId) {
   })
   })
 }
 }
 
 
+export function saveProductGift(data) {
+  return request({
+    url: '/api/admin/product-gift/save',
+    method: 'post',
+    data
+  })
+}
+
+export function getProductGiftList(productId) {
+  return request({
+    url: '/api/admin/product-gift/list',
+    method: 'post',
+    data: { productId }
+  })
+}
+
 export function listMembershipLevels() {
 export function listMembershipLevels() {
   return request({
   return request({
     url: '/api/membership/levels',
     url: '/api/membership/levels',

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

@@ -35,3 +35,11 @@ export function getCouponGrantLog(data) {
 export function toggleCouponStatus(id, status) {
 export function toggleCouponStatus(id, status) {
   return request({ url: '/api/admin/coupon/toggle-status', method: 'post', data: { id, status } })
   return request({ url: '/api/admin/coupon/toggle-status', method: 'post', data: { id, status } })
 }
 }
+
+export function getFamilyCoupons(data) {
+  return request({ url: '/api/admin/coupon/family-coupons', method: 'post', data })
+}
+
+export function getFamilyCouponUsage(data) {
+  return request({ url: '/api/admin/coupon/family-coupon-usage', method: 'post', data })
+}

+ 180 - 2
cfc-web/src/views/Families.vue

@@ -55,6 +55,8 @@
               </el-button>
               </el-button>
               <el-dropdown-menu slot="dropdown">
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
+                <el-dropdown-item command="viewCoupons" icon="el-icon-ticket">优惠券列表</el-dropdown-item>
+                <el-dropdown-item command="viewUsage" icon="el-icon-document">使用记录</el-dropdown-item>
                 <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
               </el-dropdown-menu>
@@ -174,12 +176,106 @@
         <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
         <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
       </div>
       </div>
     </el-dialog>
     </el-dialog>
+
+    <!-- 优惠券列表弹窗 -->
+    <el-dialog
+      title="优惠券列表"
+      :visible.sync="couponDialogVisible"
+      width="900px"
+    >
+      <el-form :inline="true" style="margin-bottom: 12px">
+        <el-form-item label="状态">
+          <el-select v-model="couponStatusFilter" placeholder="全部" clearable size="small" style="width:120px">
+            <el-option label="可用" value="AVAILABLE"></el-option>
+            <el-option label="已使用" value="USED"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button size="small" @click="loadCoupons(currentRow.id)">刷新</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="couponListData" border v-loading="couponLoading" style="width:100%" max-height="400">
+        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+        <el-table-column prop="type" label="类型" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.type === 'DISCOUNT' ? '' : 'success'">{{ scope.row.type === 'DISCOUNT' ? '折扣' : '满减' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="面值/折扣" width="100">
+          <template slot-scope="scope">
+            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.status === 'AVAILABLE' ? 'success' : 'info'">{{ scope.row.status === 'AVAILABLE' ? '可用' : '已使用' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="receivedAt" label="领取时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.receivedAt) }}</template>
+        </el-table-column>
+        <el-table-column prop="usedAt" label="使用时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 12px; text-align: right"
+        @size-change="onCouponSizeChange"
+        @current-change="onCouponPageChange"
+        :current-page="couponPagination.current"
+        :page-sizes="[10, 20, 50]"
+        :page-size="couponPagination.size"
+        :total="couponPagination.total"
+        layout="total, sizes, prev, pager, next"
+      ></el-pagination>
+    </el-dialog>
+
+    <!-- 使用记录弹窗 -->
+    <el-dialog
+      title="使用记录"
+      :visible.sync="usageDialogVisible"
+      width="800px"
+    >
+      <el-table :data="usageListData" border v-loading="usageLoading" style="width:100%" max-height="400">
+        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+        <el-table-column prop="type" label="类型" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.type === 'DISCOUNT' ? '' : 'success'">{{ scope.row.type === 'DISCOUNT' ? '折扣' : '满减' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="面值/折扣" width="100">
+          <template slot-scope="scope">
+            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="orderId" label="订单号" width="120">
+          <template slot-scope="scope">{{ scope.row.orderId || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="usedAt" label="使用时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 12px; text-align: right"
+        @size-change="onUsageSizeChange"
+        @current-change="onUsagePageChange"
+        :current-page="usagePagination.current"
+        :page-sizes="[10, 20, 50]"
+        :page-size="usagePagination.size"
+        :total="usagePagination.total"
+        layout="total, sizes, prev, pager, next"
+      ></el-pagination>
+    </el-dialog>
   </div>
   </div>
 </template>
 </template>
 
 
 <script>
 <script>
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
-import { getCouponList, issueFamilyCoupon } from '@/api/coupon'
+import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 
 export default {
 export default {
   name: 'Families',
   name: 'Families',
@@ -224,7 +320,16 @@ export default {
         quantity: 1
         quantity: 1
       },
       },
       couponOptions: [],
       couponOptions: [],
-      couponSubmitting: false
+      couponSubmitting: false,
+      currentRow: null,
+      couponListData: [],
+      couponLoading: false,
+      couponPagination: { current: 1, size: 20, total: 0 },
+      couponStatusFilter: '',
+      usageListData: [],
+      usageLoading: false,
+      usagePagination: { current: 1, size: 20, total: 0 },
+      usageDialogVisible: false
     }
     }
   },
   },
   mounted() {
   mounted() {
@@ -239,6 +344,8 @@ export default {
     handleActionCmd(row, cmd) {
     handleActionCmd(row, cmd) {
       switch (cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
         case 'viewMembers': this.handleViewMembers(row); break;
+        case 'viewCoupons': this.handleViewCoupons(row); break;
+        case 'viewUsage': this.handleViewUsage(row); break;
         case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
         case 'delete': this.handleDelete(row); break;
@@ -386,6 +493,77 @@ export default {
       if (!dateStr) return '-'
       if (!dateStr) return '-'
       const date = new Date(dateStr)
       const date = new Date(dateStr)
       return date.toLocaleString('zh-CN')
       return date.toLocaleString('zh-CN')
+    },
+    formatTime(t) {
+      return t ? t.replace('T', ' ').substring(0, 19) : '-'
+    },
+    handleViewCoupons(row) {
+      this.currentRow = row
+      this.couponPagination = { current: 1, size: 20, total: 0 }
+      this.couponStatusFilter = ''
+      this.couponListData = []
+      this.couponDialogVisible = true
+      this.loadCoupons()
+    },
+    async loadCoupons() {
+      if (!this.currentRow) return
+      this.couponLoading = true
+      try {
+        const res = await getFamilyCoupons({
+          familyId: this.currentRow.id,
+          status: this.couponStatusFilter || undefined,
+          page: this.couponPagination.current,
+          size: this.couponPagination.size
+        })
+        this.couponListData = res.data.records || []
+        this.couponPagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      } finally {
+        this.couponLoading = false
+      }
+    },
+    onCouponPageChange(val) {
+      this.couponPagination.current = val
+      this.loadCoupons()
+    },
+    onCouponSizeChange(val) {
+      this.couponPagination.size = val
+      this.couponPagination.current = 1
+      this.loadCoupons()
+    },
+    handleViewUsage(row) {
+      this.currentRow = row
+      this.usagePagination = { current: 1, size: 20, total: 0 }
+      this.usageListData = []
+      this.usageDialogVisible = true
+      this.loadUsage()
+    },
+    async loadUsage() {
+      if (!this.currentRow) return
+      this.usageLoading = true
+      try {
+        const res = await getFamilyCouponUsage({
+          familyId: this.currentRow.id,
+          page: this.usagePagination.current,
+          size: this.usagePagination.size
+        })
+        this.usageListData = res.data.records || []
+        this.usagePagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载使用记录失败')
+      } finally {
+        this.usageLoading = false
+      }
+    },
+    onUsagePageChange(val) {
+      this.usagePagination.current = val
+      this.loadUsage()
+    },
+    onUsageSizeChange(val) {
+      this.usagePagination.size = val
+      this.usagePagination.current = 1
+      this.loadUsage()
     }
     }
   }
   }
 }
 }

+ 11 - 6
cfc-web/src/views/admin/ArticleManage.vue

@@ -116,6 +116,11 @@
             <span class="sort-header" @click.stop="onSortClick('viewCount', $event)" @dblclick.stop="onSortToggle('viewCount')">阅读<span v-if="sortIndex('viewCount') !== null" class="sort-index" :class="sortClass('viewCount') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('viewCount') }}</span>{{ sortIcon('viewCount') }}</span>
             <span class="sort-header" @click.stop="onSortClick('viewCount', $event)" @dblclick.stop="onSortToggle('viewCount')">阅读<span v-if="sortIndex('viewCount') !== null" class="sort-index" :class="sortClass('viewCount') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('viewCount') }}</span>{{ sortIcon('viewCount') }}</span>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
+        <el-table-column label="总阅读时长" width="110">
+          <template slot-scope="{ row }">
+            {{ formatDuration(row.totalDurationSeconds) }}
+          </template>
+        </el-table-column>
 
 
         <el-table-column label="发布时间" width="160">
         <el-table-column label="发布时间" width="160">
           <template slot-scope="{ row }">
           <template slot-scope="{ row }">
@@ -240,19 +245,19 @@
             {{ row.nickname || '-' }}
             {{ row.nickname || '-' }}
           </template>
           </template>
         </el-table-column>
         </el-table-column>
-        <el-table-column prop="duration_seconds" label="累计时长" width="120">
+        <el-table-column prop="total_duration_seconds" label="累计时长" width="110">
           <template slot-scope="{ row }">
           <template slot-scope="{ row }">
-            {{ formatDuration(row.duration_seconds) }}
+            {{ formatDuration(row.total_duration_seconds) }}
           </template>
           </template>
         </el-table-column>
         </el-table-column>
-        <el-table-column prop="read_date" label="最后阅读日期" width="130">
+        <el-table-column prop="read_count" label="阅读次数" width="90">
           <template slot-scope="{ row }">
           <template slot-scope="{ row }">
-            {{ row.read_date || '-' }}
+            {{ row.read_count || 0 }}次
           </template>
           </template>
         </el-table-column>
         </el-table-column>
-        <el-table-column prop="read_at" label="最后阅读时间" width="170">
+        <el-table-column prop="last_read_at" label="最后阅读时间" width="170">
           <template slot-scope="{ row }">
           <template slot-scope="{ row }">
-            {{ formatTime(row.read_at) }}
+            {{ formatTime(row.last_read_at) }}
           </template>
           </template>
         </el-table-column>
         </el-table-column>
       </el-table>
       </el-table>

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

@@ -353,12 +353,14 @@ export default {
           getMembershipStats(),
           getMembershipStats(),
           this._request('/api/admin/membership/overview')
           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[0].value = s.freeCount || 0
           this.statCards[1].value = s.familyCount || 0
           this.statCards[1].value = s.familyCount || 0
           this.statCards[2].value = s.providerCount || 0
           this.statCards[2].value = s.providerCount || 0
         }
         }
+        // _request 是裸 axios,需通过 res.data.code 解包
         if (overviewRes.data && overviewRes.data.code === 200) {
         if (overviewRes.data && overviewRes.data.code === 200) {
           const o = overviewRes.data.data || {}
           const o = overviewRes.data.data || {}
           this.statCards[3].value = o.newFamilyThisMonth || 0
           this.statCards[3].value = o.newFamilyThisMonth || 0
@@ -373,9 +375,10 @@ export default {
     async loadLevels() {
     async loadLevels() {
       this.levelsLoading = true
       this.levelsLoading = true
       try {
       try {
+        // listMembershipLevels 走 request 拦截器,返回已解包的 {code, message, data},data 即等级数组
         const res = await listMembershipLevels()
         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' }) : []
           this.levels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' }) : []
         }
         }
       } catch (e) {
       } catch (e) {
@@ -397,7 +400,6 @@ export default {
           const records = (res.data.data && res.data.data.records) || []
           const records = (res.data.data && res.data.data.records) || []
           this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
           this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
           this.recordsTotal = res.data.data.total || 0
           this.recordsTotal = res.data.data.total || 0
-          await this.resolveUserNames(this.upgradeRecords, 'userId')
         }
         }
       } catch (e) {
       } catch (e) {
         this.$message.error('加载升级记录失败')
         this.$message.error('加载升级记录失败')
@@ -448,12 +450,12 @@ export default {
       this.editSubmitting = true
       this.editSubmitting = true
       try {
       try {
         const res = await updateMembershipLevel(this.editForm)
         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.editVisible = false
           this.loadLevels()
           this.loadLevels()
         } else {
         } 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) {
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('更新失败')
         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() {
     async handleMaintainSearch() {
       const kw = (this.maintainSearch || '').trim()
       const kw = (this.maintainSearch || '').trim()
       if (!kw) {
       if (!kw) {
@@ -532,13 +509,13 @@ export default {
         if (refStr === '0') payload.referrerId = ''
         if (refStr === '0') payload.referrerId = ''
         else if (refStr !== '') payload.referrerId = refStr
         else if (refStr !== '') payload.referrerId = refStr
         const res = await updateMembershipUser(payload)
         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.maintainVisible = false
           this.handleMaintainSearch()
           this.handleMaintainSearch()
           this.refreshAll()
           this.refreshAll()
         } else {
         } 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) {
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('更新失败')
         this.$message && this.$message.error && this.$message.error('更新失败')

+ 197 - 61
cfc-web/src/views/admin/ProductEdit.vue

@@ -299,6 +299,26 @@
           </el-select>
           </el-select>
         </el-form-item>
         </el-form-item>
 
 
+        <el-form-item label="可选赠品">
+          <div style="width: 100%">
+            <div v-for="lv in membershipLevels" :key="lv.levelCode" class="gift-config-level">
+              <div class="gift-config-level-title">{{ lv.levelName }} 赠品</div>
+              <div v-for="(item, idx) in getGiftItems(lv.levelCode)" :key="getGiftItemKey(item, idx)" class="gift-config-item">
+                <el-select v-model="item.giftType" size="mini" style="width: 110px; margin-right: 8px">
+                  <el-option label="赠品商品" value="product" />
+                  <el-option label="CF值" value="cf" />
+                </el-select>
+                <el-select v-if="item.giftType === 'product'" v-model="item.giftProductId" size="mini" filterable placeholder="选择赠品商品" style="width: 260px; margin-right: 8px">
+                  <el-option v-for="p in onShelfProducts" :key="p.id" :label="p.name" :value="p.id" />
+                </el-select>
+                <el-input v-if="item.giftType === 'cf'" v-model.number="item.cfValue" size="mini" placeholder="CF值数量" type="number" style="width: 140px; margin-right: 8px" />
+                <el-button size="mini" type="text" @click="removeGiftItem(lv.levelCode, idx)">删除</el-button>
+              </div>
+              <el-button size="mini" type="text" @click="addGiftItem(lv.levelCode)">+ 添加赠品</el-button>
+            </div>
+          </div>
+        </el-form-item>
+
         <el-form-item>
         <el-form-item>
           <el-button type="primary" :loading="saving" @click="handleSave">
           <el-button type="primary" :loading="saving" @click="handleSave">
 {{ isEdit ? '保存修改' : '创建商品' }}
 {{ isEdit ? '保存修改' : '创建商品' }}
@@ -311,7 +331,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides } from '@/api/admin'
+import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides, saveProductGift, getProductGiftList } from '@/api/admin'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
@@ -369,6 +389,9 @@ export default {
       giftRuleEnabled: false,
       giftRuleEnabled: false,
       giftRuleLevel: '',
       giftRuleLevel: '',
       membershipLevels: [],
       membershipLevels: [],
+      // 可选赠品配置:{ [levelCode]: [{giftType,giftProductId,cfValue,sortOrder}] }
+      giftConfigsByLevel: {},
+      onShelfProducts: [],
       rules: {
       rules: {
         name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
         name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
         productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
         productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
@@ -423,65 +446,6 @@ export default {
           }
           }
         }
         }
       },
       },
-      toolbarConfig: {},
-      editorInstance: null
-    }
-  },
-  computed: {
-    isEdit() {
-      return !!this.$route.query.id
-    },
-    isSupplierAdmin() {
-      const rolesStr = localStorage.getItem('roles')
-      const roles = rolesStr ? JSON.parse(rolesStr) : []
-      return roles.includes('supplier') || roles.includes('supplier_admin')
-    },
-    uploadActionUrl() {
-      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 }
-    },
-    selectedSystem() {
-      if (!this.form.distributionSystemId || !this.supplySystems.length) return null
-      return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
-    },
-    filteredGuides() {
-      if (!this.approvedGuides.length) return this.approvedGuides
-      if (!this.guideSearchQuery) return this.approvedGuides
-      var q = this.guideSearchQuery.toLowerCase()
-      return this.approvedGuides.filter(function(g) {
-        return (g.realName || '').toLowerCase().indexOf(q) !== -1
-          || (g.nickname || '').toLowerCase().indexOf(q) !== -1
-          || (g.title || '').toLowerCase().indexOf(q) !== -1
-          || (g.specialty || '').toLowerCase().indexOf(q) !== -1
-      })
-    }
-  },
-  created() {
-    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()
-    this.loadPredefinedFields()
-    if (this.isEdit) {
-      this.loadProduct(this.$route.query.id)
-    }
-  },
-  methods: {
-    handleEditorCreated(editor) {
-      this.editorInstance = editor
-    },
     addBundleItem() {
     addBundleItem() {
       this.bundleItems.push({
       this.bundleItems.push({
         childProductId: null,
         childProductId: null,
@@ -547,7 +511,67 @@ export default {
         }
         }
       } catch (e) { /* ignore */ }
       } catch (e) { /* ignore */ }
     },
     },
-
+      toolbarConfig: {},
+      editorInstance: null
+    }
+  },
+  computed: {
+    isEdit() {
+      return !!this.$route.query.id
+    },
+    isSupplierAdmin() {
+      const rolesStr = localStorage.getItem('roles')
+      const roles = rolesStr ? JSON.parse(rolesStr) : []
+      return roles.includes('supplier') || roles.includes('supplier_admin')
+    },
+  uploadActionUrl() {
+    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 }
+    },
+    selectedSystem() {
+      if (!this.form.distributionSystemId || !this.supplySystems.length) return null
+      return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
+    },
+    filteredGuides() {
+      if (!this.approvedGuides.length) return this.approvedGuides
+      if (!this.guideSearchQuery) return this.approvedGuides
+      var q = this.guideSearchQuery.toLowerCase()
+      return this.approvedGuides.filter(function(g) {
+        return (g.realName || '').toLowerCase().indexOf(q) !== -1
+          || (g.nickname || '').toLowerCase().indexOf(q) !== -1
+          || (g.title || '').toLowerCase().indexOf(q) !== -1
+          || (g.specialty || '').toLowerCase().indexOf(q) !== -1
+      })
+    }
+  },
+  created() {
+    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()
+    this.loadPredefinedFields()
+    this.ensureMembershipLevels()
+    this.loadOnShelfProducts()
+    if (this.isEdit) {
+      this.loadProduct(this.$route.query.id)
+    }
+  },
+  methods: {
+    handleEditorCreated(editor) {
+      this.editorInstance = editor
+    },
     async loadSupplySystems() {
     async loadSupplySystems() {
       if (this.isSupplierAdmin) {
       if (this.isSupplierAdmin) {
         try {
         try {
@@ -621,6 +645,7 @@ export default {
           this.loadPurchaseFields(p.id)
           this.loadPurchaseFields(p.id)
 // 赠送会员规则回显
 // 赠送会员规则回显
           this.loadGiftRule(p.id)
           this.loadGiftRule(p.id)
+          this.loadGiftConfigs(p.id)
           // Load growth archive config
           // Load growth archive config
           this.loadGrowthArchive(p.id)
           this.loadGrowthArchive(p.id)
           // Load delivery config (service persons / online slots)
           // Load delivery config (service persons / online slots)
@@ -700,6 +725,94 @@ async loadGiftRule(productId) {
         console.warn('加载赠送会员规则失败', e)
         console.warn('加载赠送会员规则失败', e)
       }
       }
     },
     },
+    getGiftItems(levelCode) {
+      return this.giftConfigsByLevel[levelCode] || []
+    },
+    getGiftItemKey(item, idx) {
+      return (item && item.giftType ? item.giftType : 'item') + '-' + idx
+    },
+    addGiftItem(levelCode) {
+      var arr = this.giftConfigsByLevel[levelCode]
+      if (!arr) {
+        this.$set(this.giftConfigsByLevel, levelCode, [])
+        arr = this.giftConfigsByLevel[levelCode]
+      }
+      arr.push({ giftType: 'product', giftProductId: null, cfValue: 0, sortOrder: arr.length })
+    },
+    removeGiftItem(levelCode, idx) {
+      var arr = this.giftConfigsByLevel[levelCode]
+      if (arr) arr.splice(idx, 1)
+    },
+    async ensureMembershipLevels() {
+      if (this.membershipLevels.length === 0) {
+        try {
+          const lvRes = await listMembershipLevels()
+          if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
+            this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
+          }
+        } catch (e) {
+          console.warn('加载会员等级失败', e)
+        }
+      }
+    },
+    async loadOnShelfProducts() {
+      try {
+        const res = await getProductList({ status: 'on_shelf', page: 1, size: 200 })
+        if (res.code === 200) {
+          const data = res.data || {}
+          this.onShelfProducts = data.records || (Array.isArray(data) ? data : [])
+        }
+      } catch (e) {
+        console.warn('加载上架商品失败', e)
+      }
+    },
+    async loadGiftConfigs(productId) {
+      try {
+        await this.ensureMembershipLevels()
+        await this.loadOnShelfProducts()
+        // 初始化每等级空数组
+        var that = this
+        this.membershipLevels.forEach(function(lv) {
+          that.$set(that.giftConfigsByLevel, lv.levelCode, [])
+        })
+        const res = await getProductGiftList(productId)
+        if (res.code === 200 && Array.isArray(res.data)) {
+          res.data.forEach(function(cfg) {
+            if (cfg && cfg.levelCode) {
+              that.$set(that.giftConfigsByLevel, cfg.levelCode, (cfg.items || []).map(function(it) {
+                return {
+                  giftType: it.giftType,
+                  giftProductId: it.giftProductId,
+                  cfValue: it.cfValue || 0,
+                  sortOrder: it.sortOrder || 0
+                }
+              }))
+            }
+          })
+        }
+      } catch (e) {
+        console.warn('加载赠品配置失败', e)
+      }
+    },
+    async saveGiftConfigs(productId) {
+      try {
+        for (var i = 0; i < this.membershipLevels.length; i++) {
+          var lv = this.membershipLevels[i]
+          var items = (this.giftConfigsByLevel[lv.levelCode] || []).map(function(it, idx) {
+            return {
+              giftType: it.giftType,
+              giftProductId: it.giftType === 'product' ? it.giftProductId : null,
+              cfValue: it.giftType === 'cf' ? (it.cfValue || 0) : 0,
+              sortOrder: idx
+            }
+          })
+          var status = items.length > 0 ? 1 : 0
+          await saveProductGift({ productId: productId, levelCode: lv.levelCode, status: status, items: items })
+        }
+      } catch (e) {
+        console.warn('保存赠品配置失败', e)
+      }
+    },
     async loadGrowthArchive(productId) {
     async loadGrowthArchive(productId) {
       try {
       try {
         const res = await getGrowthArchiveProduct(productId)
         const res = await getGrowthArchiveProduct(productId)
@@ -932,6 +1045,14 @@ async loadGiftRule(productId) {
                 console.warn('保存赠送会员规则失败', e)
                 console.warn('保存赠送会员规则失败', e)
               }
               }
             }
             }
+            // 保存可选赠品配置
+            if (productId) {
+              try {
+                await this.saveGiftConfigs(productId)
+              } catch (e) {
+                console.warn('保存赠品配置失败', e)
+              }
+            }
             // Save growth archive config (only for existing products)
             // Save growth archive config (only for existing products)
             if (this.form.id) {
             if (this.form.id) {
               try {
               try {
@@ -1122,6 +1243,21 @@ async loadGiftRule(productId) {
   color: #ccc;
   color: #ccc;
   cursor: move;
   cursor: move;
 }
 }
+.gift-config-level {
+  margin-bottom: 12px;
+  padding: 8px 0;
+  border-bottom: 1px dashed #eee;
+}
+.gift-config-level-title {
+  font-size: 13px;
+  color: #606266;
+  margin-bottom: 6px;
+}
+.gift-config-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
 .service-person-card {
 .service-person-card {
   border: 1px solid #e8e8e8;
   border: 1px solid #e8e8e8;
   border-radius: 4px;
   border-radius: 4px;

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

@@ -197,6 +197,7 @@
 | 供应商订单修复 | Phase 4 | 🟡 计划中 | `plans/2026-06-20-danshop-order-fix.md` |
 | 供应商订单修复 | Phase 4 | 🟡 计划中 | `plans/2026-06-20-danshop-order-fix.md` |
 | SKU 价格单位统一分 + 规格选择响应式修复 | Phase 4 | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | `plans/2026-08-15-sku-price-cents-and-spec-selector.md` |
 | SKU 价格单位统一分 + 规格选择响应式修复 | Phase 4 | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | `plans/2026-08-15-sku-price-cents-and-spec-selector.md` |
 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) | Phase 4 | 🟡 设计稿 | `specs/2026-08-05-inventory-management-design.md` |
 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) | Phase 4 | 🟡 设计稿 | `specs/2026-08-05-inventory-management-design.md` |
+| 商品可选赠品(按会员等级配置赠品池:赠品商品/CF值,购买时可多选) | Phase 4 | 🟡 设计稿 | `specs/2026-09-02-product-gift-selection-design.md` |
 
 
 ---
 ---
 
 

+ 1354 - 0
docs/superpowers/plans/2026-09-02-product-gift-selection.md

@@ -0,0 +1,1354 @@
+# 商品可选赠品功能 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现商品可选赠品功能:运营为商品配置不同会员等级的赠品池(含赠品商品 或 CF值),用户购买时按自身等级从赠品池中选0个或多个,支付成功后发放对应赠品。
+
+**Architecture:** 新增三张表(`product_gift_configs` / `product_gift_items` / `product_order_gifts`),新增 `ProductGiftService` 承载赠品配置与发放逻辑;在现有下单(`ProductOrderService.create`)和支付成功(`handlePaymentSuccess`)两个节点注入赠品校验与发放;商品详情接口附带赠品配置;管理端和小程序前端分别实现配置与选择 UI。
+
+**Tech Stack:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / Vue 2 + uni-app(小程序)/ Vue 2 + Element UI(管理端)
+
+**Spec:** `docs/superpowers/specs/2026-09-02-product-gift-selection-design.md`
+
+## Global Constraints
+
+- 统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- DI 使用 `@Resource`,字段名必须与类型默认 Bean Name 一致
+- 响应统一 `Result<T>`(code/message/data),`Result.error(String)` 返回 code=500
+- 数据库迁移在 `DatabaseInitializer.runMigrations()` 中执行,幂等(try-catch 包裹);同步更新 `schema.sql`
+- 小程序禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid、禁止 `:key` 表达式(用方法调用代替)
+- 所有迁移编号递增,当前最大编号见 `DatabaseInitializer.runMigrations()` 末尾搜索 `// 迁移`
+
+---
+
+## File Map
+
+| 操作 | 文件 |
+|------|------|
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftConfigMapper.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftItemMapper.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderGiftMapper.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java` |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java` |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java` |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java` |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java` |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java` |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` |
+| 修改 | `cfc-backend/src/main/resources/schema.sql` |
+| 修改 | `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` |
+| 修改 | `cfc-web/src/views/admin/ProductEdit.vue` |
+| 修改 | `cfc-web/src/api/admin.js` |
+
+---
+
+### Task 1: 数据库迁移(三张新表)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- Modify: `cfc-backend/src/main/resources/schema.sql`
+
+**Interfaces:**
+- Consumes: existing `ensureColumn` method pattern (line ~4099 in DatabaseInitializer)
+- Produces: three new tables available for use by subsequent tasks
+
+- [ ] **Step 1: 查找最新迁移编号**
+
+在 `DatabaseInitializer.java` 中搜索最后一个 `// 迁移` 注释:
+```bash
+grep -n "// 迁移" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java | tail -5
+```
+记录最大编号 N(预计是 `迁移278` 或更高)。
+
+- [ ] **Step 2: 在 DatabaseInitializer 末尾添加三个建表迁移**
+
+在 `runMigrations()` 方法末尾(`}` 之前)追加:
+```java
+// 迁移N+1: 创建 product_gift_configs 表(商品赠品配置,商品×等级)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_configs (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "product_id BIGINT NOT NULL COMMENT '关联products表', " +
+            "level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)', " +
+            "status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_product_level (product_id, level_code), " +
+            "INDEX idx_product_id (product_id), " +
+            "INDEX idx_level_code (level_code)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)'");
+    log.info("已创建product_gift_configs表");
+} catch (Exception e) {
+    // 表已存在,忽略
+}
+
+// 迁移N+2: 创建 product_gift_items 表(商品赠品项)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_items (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id', " +
+            "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
+            "gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)', " +
+            "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
+            "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
+            "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)', " +
+            "sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_config_id (config_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项'");
+    log.info("已创建product_gift_items表");
+} catch (Exception e) {
+    // 表已存在,忽略
+}
+
+// 迁移N+3: 创建 product_order_gifts 表(订单赠品记录)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_order_gifts (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "order_id BIGINT NOT NULL COMMENT '关联product_orders.id', " +
+            "order_no VARCHAR(64) NOT NULL COMMENT '订单号快照', " +
+            "gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值', " +
+            "gift_product_id BIGINT COMMENT '赠品商品ID', " +
+            "gift_product_name VARCHAR(200) COMMENT '赠品商品名快照', " +
+            "gift_product_image VARCHAR(500) COMMENT '赠品商品图快照', " +
+            "cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)', " +
+            "quantity INT DEFAULT 1 COMMENT '数量(默认1)', " +
+            "fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_order_id (order_id), " +
+            "INDEX idx_order_no (order_no)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录'");
+    log.info("已创建product_order_gifts表");
+} catch (Exception e) {
+    // 表已存在,忽略
+}
+```
+
+- [ ] **Step 3: 同步更新 schema.sql**
+
+在 `schema.sql` 中,找到 `product_gift_rules` 建表语句(约第4301行),在其**之后**追加以下三张表的建表语句(保持与迁移代码完全一致):
+```sql
+-- 商品赠品配置(商品×等级)
+CREATE TABLE IF NOT EXISTS product_gift_configs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '关联products表',
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)',
+    status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_product_level (product_id, level_code),
+    INDEX idx_product_id (product_id),
+    INDEX idx_level_code (level_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)';
+
+-- 商品赠品项
+CREATE TABLE IF NOT EXISTS product_gift_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)',
+    sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_config_id (config_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项';
+
+-- 订单赠品记录
+CREATE TABLE IF NOT EXISTS product_order_gifts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_id BIGINT NOT NULL COMMENT '关联product_orders.id',
+    order_no VARCHAR(64) NOT NULL COMMENT '订单号快照',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)',
+    quantity INT DEFAULT 1 COMMENT '数量(默认1)',
+    fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_order_id (order_id),
+    INDEX idx_order_no (order_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录';
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS,无编译错误。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git add cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(product-gift): 建三张赠品相关表迁移(product_gift_configs/items/order_gifts)"
+```
+
+---
+
+### Task 2: 实体类与 Mapper 接口(三个实体 + 三个 Mapper)
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftConfigMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftItemMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderGiftMapper.java`
+
+**Interfaces:**
+- Consumes: table names and column definitions from Task 1
+- Produces: three entity classes and three Mapper interfaces available for service layer injection
+
+- [ ] **Step 1: 创建 ProductGiftConfig.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_gift_configs")
+public class ProductGiftConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    private String levelCode;
+
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 2: 创建 ProductGiftItem.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_gift_items")
+public class ProductGiftItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long configId;
+
+    private String giftType;
+
+    private Long giftProductId;
+
+    private String giftProductName;
+
+    private String giftProductImage;
+
+    private Integer cfValue;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 3: 创建 ProductOrderGift.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_order_gifts")
+public class ProductOrderGift implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long orderId;
+
+    private String orderNo;
+
+    private String giftType;
+
+    private Long giftProductId;
+
+    private String giftProductName;
+
+    private String giftProductImage;
+
+    private Integer cfValue;
+
+    private Integer quantity;
+
+    private Integer fulfilled;
+
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 4: 创建三个 Mapper 接口(每个都与 ProductGiftRuleMapper 同构)**
+
+`ProductGiftConfigMapper.java`:
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductGiftConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProductGiftConfigMapper extends BaseMapper<ProductGiftConfig> {
+}
+```
+
+`ProductGiftItemMapper.java`:
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductGiftItem;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProductGiftItemMapper extends BaseMapper<ProductGiftItem> {
+}
+```
+
+`ProductOrderGiftMapper.java`:
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductOrderGift;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProductOrderGiftMapper extends BaseMapper<ProductOrderGift> {
+}
+```
+
+- [ ] **Step 5: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftConfigMapper.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftItemMapper.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderGiftMapper.java
+git commit -m "feat(product-gift): 新增三张赠品表实体类与Mapper接口"
+```
+
+---
+
+### Task 3: ProductGiftService(配置 CRUD + 查询)
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java`(在末尾新增 `giftConfigs` 字段及内嵌类)
+
+**Interfaces:**
+- Consumes: `ProductGiftConfigMapper`, `ProductGiftItemMapper`, `MembershipLevelMapper`, `ProductMapper`
+- Produces: `saveGiftConfig(productId, levelCode, status, items) → Result<Void>`
+  `listConfigsByProduct(productId) → List<GiftConfigVO>`
+  `getActiveItemById(itemId) → ProductGiftItem or null`
+  `getAvailableItems(productId, levelCode) → List<GiftConfigVO>`(按等级过滤且只返回启用配置)
+
+- [ ] **Step 1: 创建 ProductGiftItemDTO.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class ProductGiftItemDTO {
+    private Long id;           // null=新增,有值=更新
+    private String giftType;   // "product" or "cf"
+    private Long giftProductId;
+    private Integer cfValue;
+    private Integer sortOrder;
+}
+```
+
+- [ ] **Step 2: 创建 GiftConfigVO.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class GiftConfigVO {
+    private Long id;
+    private String levelCode;
+    private String levelName;
+    private Integer status;
+    private List<GiftItemVO> items;
+
+    @Data
+    public static class GiftItemVO {
+        private Long id;
+        private String giftType;
+        private Long giftProductId;
+        private String giftProductName;
+        private String giftProductImage;
+        private Integer cfValue;
+        private Integer sortOrder;
+    }
+}
+```
+
+- [ ] **Step 3: 创建 ProductGiftService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.ProductGiftItemDTO;
+import com.etotem.cfc.entity.MemberLevel;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductGiftConfig;
+import com.etotem.cfc.entity.ProductGiftItem;
+import com.etotem.cfc.mapper.MemberLevelMapper;
+import com.etotem.cfc.mapper.ProductGiftConfigMapper;
+import com.etotem.cfc.mapper.ProductGiftItemMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class ProductGiftService {
+
+    @Resource
+    private ProductGiftConfigMapper configMapper;
+    @Resource
+    private ProductGiftItemMapper itemMapper;
+    @Resource
+    private ProductMapper productMapper;
+    @Resource
+    private MemberLevelMapper levelMapper;
+
+    /** 保存赠品配置(幂等:先删后插) */
+    @Transactional
+    public Result<Void> saveGiftConfig(Long productId, String levelCode, Integer status, List<ProductGiftItemDTO> items) {
+        if (productId == null || levelCode == null || status == null) {
+            return Result.error("参数不完整");
+        }
+        // 校验等级存在且非 FREE
+        MemberLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MemberLevel>()
+                .eq(MemberLevel::getLevelCode, levelCode));
+        if (level == null || "FREE".equals(level.getLevelCode())) {
+            return Result.error("无效的赠送等级");
+        }
+        // 校验赠品商品(giftType=product)存在且启用
+        if (items != null) {
+            for (ProductGiftItemDTO it : items) {
+                if ("product".equals(it.getGiftType()) && it.getGiftProductId() != null) {
+                    Product giftProduct = productMapper.selectById(it.getGiftProductId());
+                    if (giftProduct == null || !"on_shelf".equals(giftProduct.getStatus())) {
+                        return Result.error("赠品商品不存在或未上架");
+                    }
+                }
+            }
+        }
+        // 先删后插(整个 config + 其下所有 items)
+        configMapper.delete(new LambdaQueryWrapper<ProductGiftConfig>()
+                .eq(ProductGiftConfig::getProductId, productId)
+                .eq(ProductGiftConfig::getLevelCode, levelCode));
+        if (status == 1) {
+            ProductGiftConfig config = new ProductGiftConfig();
+            config.setProductId(productId);
+            config.setLevelCode(levelCode);
+            config.setStatus(1);
+            config.setCreatedAt(new Date());
+            config.setUpdatedAt(new Date());
+            configMapper.insert(config);
+            if (items != null) {
+                for (ProductGiftItemDTO it : items) {
+                    ProductGiftItem item = new ProductGiftItem();
+                    item.setConfigId(config.getId());
+                    item.setGiftType(it.getGiftType());
+                    item.setGiftProductId(it.getGiftProductId());
+                    item.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+                    item.setSortOrder(it.getSortOrder() != null ? it.getSortOrder() : 0);
+                    // 快照商品名称和图
+                    if (it.getGiftProductId() != null) {
+                        Product gp = productMapper.selectById(it.getGiftProductId());
+                        if (gp != null) {
+                            item.setGiftProductName(gp.getName());
+                            item.setGiftProductImage(gp.getCoverImage());
+                        }
+                    }
+                    item.setCreatedAt(new Date());
+                    itemMapper.insert(item);
+                }
+            }
+        }
+        return Result.success(null);
+    }
+
+    /** 查询商品全部等级赠品配置(管理端用) */
+    public List<GiftConfigVO> listConfigsByProduct(Long productId) {
+        List<ProductGiftConfig> configs = configMapper.selectList(
+                new LambdaQueryWrapper<ProductGiftConfig>()
+                        .eq(ProductGiftConfig::getProductId, productId)
+                        .orderByDesc(ProductGiftConfig::getStatus));
+        return toVOList(configs);
+    }
+
+    /** 查询某商品某等级下启用的赠品项(含库存充足校验,供商品详情展示) */
+    public List<GiftConfigVO> getActiveConfigsByProductAndLevel(Long productId, String levelCode) {
+        ProductGiftConfig config = configMapper.selectOne(new LambdaQueryWrapper<ProductGiftConfig>()
+                .eq(ProductGiftConfig::getProductId, productId)
+                .eq(ProductGiftConfig::getLevelCode, levelCode)
+                .eq(ProductGiftConfig::getStatus, 1)
+                .last("LIMIT 1"));
+        if (config == null) {
+            return Collections.emptyList();
+        }
+        List<ProductGiftItem> items = itemMapper.selectList(
+                new LambdaQueryWrapper<ProductGiftItem>()
+                        .eq(ProductGiftItem::getConfigId, config.getId())
+                        .orderByAsc(ProductGiftItem::getSortOrder));
+        GiftConfigVO vo = new GiftConfigVO();
+        vo.setId(config.getId());
+        vo.setLevelCode(config.getLevelCode());
+        MemberLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MemberLevel>()
+                .eq(MemberLevel::getLevelCode, levelCode));
+        vo.setLevelName(level != null ? level.getLevelName() : levelCode);
+        vo.setStatus(config.getStatus());
+        List<GiftConfigVO.GiftItemVO> itemVOs = new ArrayList<>();
+        for (ProductGiftItem it : items) {
+            GiftConfigVO.GiftItemVO itemVO = new GiftConfigVO.GiftItemVO();
+            itemVO.setId(it.getId());
+            itemVO.setGiftType(it.getGiftType());
+            itemVO.setGiftProductId(it.getGiftProductId());
+            itemVO.setGiftProductName(it.getGiftProductName());
+            itemVO.setGiftProductImage(it.getGiftProductImage());
+            itemVO.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+            itemVO.setSortOrder(it.getSortOrder());
+            // 库存充足校验:实物赠品必须库存>0
+            if ("product".equals(it.getGiftType()) && it.getGiftProductId() != null) {
+                Product gp = productMapper.selectById(it.getGiftProductId());
+                if (gp == null || gp.getStock() == null || gp.getStock() <= 0) {
+                    continue; // 库存不足则不展示
+                }
+            }
+            itemVOs.add(itemVO);
+        }
+        vo.setItems(itemVOs);
+        return Collections.singletonList(vo);
+    }
+
+    /** 根据 ID 查询赠品项(含 config 归属校验) */
+    public ProductGiftItem getActiveItemById(Long itemId) {
+        return itemMapper.selectById(itemId);
+    }
+
+    private List<GiftConfigVO> toVOList(List<ProductGiftConfig> configs) {
+        List<GiftConfigVO> result = new ArrayList<>();
+        for (ProductGiftConfig cfg : configs) {
+            GiftConfigVO vo = new GiftConfigVO();
+            vo.setId(cfg.getId());
+            vo.setLevelCode(cfg.getLevelCode());
+            MemberLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MemberLevel>()
+                    .eq(MemberLevel::getLevelCode, cfg.getLevelCode()));
+            vo.setLevelName(level != null ? level.getLevelName() : cfg.getLevelCode());
+            vo.setStatus(cfg.getStatus());
+            List<ProductGiftItem> items = itemMapper.selectList(
+                    new LambdaQueryWrapper<ProductGiftItem>()
+                            .eq(ProductGiftItem::getConfigId, cfg.getId())
+                            .orderByAsc(ProductGiftItem::getSortOrder));
+            List<GiftConfigVO.GiftItemVO> itemVOs = new ArrayList<>();
+            for (ProductGiftItem it : items) {
+                GiftConfigVO.GiftItemVO itemVO = new GiftConfigVO.GiftItemVO();
+                itemVO.setId(it.getId());
+                itemVO.setGiftType(it.getGiftType());
+                itemVO.setGiftProductId(it.getGiftProductId());
+                itemVO.setGiftProductName(it.getGiftProductName());
+                itemVO.setGiftProductImage(it.getGiftProductImage());
+                itemVO.setCfValue(it.getCfValue() != null ? it.getCfValue() : 0);
+                itemVO.setSortOrder(it.getSortOrder());
+                itemVOs.add(itemVO);
+            }
+            vo.setItems(itemVOs);
+            result.add(vo);
+        }
+        return result;
+    }
+}
+```
+
+注意:项目中会员等级实体名为 `MembershipLevel`(`entity/MembershipLevel.java`),不是 `MemberLevel`。请将上述代码中所有 `MemberLevel` 替换为 `MembershipLevel`,`MemberLevelMapper` 替换为 `MembershipLevelMapper`,字段 `levelCode` / `levelName` 对应 `getLevelCode()` / `getLevelName()`。
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS。如有编译错误,根据错误信息调整字段名和 import。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java
+git commit -m "feat(product-gift): 新增 ProductGiftService(配置保存/查询逻辑)"
+```
+
+---
+
+### Task 4: Admin Controller(赠品配置管理接口)
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java`
+
+**Interfaces:**
+- Consumes: `ProductGiftService.saveGiftConfig`, `ProductGiftService.listConfigsByProduct`
+- Produces: `POST /api/admin/product-gift/save`, `POST /api/admin/product-gift/list`
+
+- [ ] **Step 1: 创建 AdminProductGiftController.java**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.dto.ProductGiftItemDTO;
+import com.etotem.cfc.service.ProductGiftService;
+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.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/product-gift")
+public class AdminProductGiftController {
+
+    @Resource
+    private ProductGiftService giftService;
+
+    /** 保存某商品某等级的赠品配置(幂等:先删后插) */
+    @PostMapping("/save")
+    public Result<Void> save(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        String levelCode = params.get("levelCode").toString();
+        Integer status = Integer.parseInt(params.get("status").toString());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> itemsMap = (List<Map<String, Object>>) params.get("items");
+        List<ProductGiftItemDTO> items = null;
+        if (itemsMap != null) {
+            items = new java.util.ArrayList<>();
+            for (Map<String, Object> m : itemsMap) {
+                ProductGiftItemDTO dto = new ProductGiftItemDTO();
+                Object id = m.get("id");
+                dto.setId(id != null ? Long.parseLong(id.toString()) : null);
+                dto.setGiftType((String) m.get("giftType"));
+                Object gpId = m.get("giftProductId");
+                dto.setGiftProductId(gpId != null ? Long.parseLong(gpId.toString()) : null);
+                Object cf = m.get("cfValue");
+                dto.setCfValue(cf != null ? Integer.parseInt(cf.toString()) : 0);
+                Object sort = m.get("sortOrder");
+                dto.setSortOrder(sort != null ? Integer.parseInt(sort.toString()) : 0);
+                items.add(dto);
+            }
+        }
+        return giftService.saveGiftConfig(productId, levelCode, status, items);
+    }
+
+    /** 查询某商品的全部等级赠品配置(管理端回显) */
+    @PostMapping("/list")
+    public Result<List<GiftConfigVO>> list(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        return Result.success(giftService.listConfigsByProduct(productId));
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS。
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java
+git commit -m "feat(product-gift): 新增管理端赠品配置接口(/api/admin/product-gift/save + /list)"
+```
+
+---
+
+### Task 5: ProductOrderService 修改(create 校验赠品 + 写入订单赠品记录)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java`(已由 Task 2 创建,无需额外修改)
+
+**Interfaces:**
+- Consumes: `ProductGiftService.getActiveItemById`, `ProductGiftService.getActiveConfigsByProductAndLevel`
+- Consumes: `MembershipService.getMemberLevel(userId)` — 返回家庭创建者等级字符串
+- Consumes: `ProductOrderMapper.insert(order)` result (order.getId())
+- Produces: `product_order_gifts` 表中记录 `order_id`, `order_no`, `gift_type`, `gift_product_id`, `gift_product_name`, `gift_product_image`, `cf_value`, `fulfilled=0`
+
+- [ ] **Step 1: CreateProductOrderDTO 新增字段**
+
+在 `CreateProductOrderDTO.java` 末尾(`purchaseInfo` 字段之后)追加:
+```java
+    private List<Long> giftItemIds; // 选中的赠品项ID列表(来自 product_gift_items.id)
+
+    public List<Long> getGiftItemIds() { return giftItemIds; }
+    public void setGiftItemIds(List<Long> giftItemIds) { this.giftItemIds = giftItemIds; }
+```
+
+- [ ] **Step 2: ProductOrderService 注入依赖**
+
+在 `ProductOrderService.java` 的 `@Resource` 字段区域,追加:
+```java
+    @Resource
+    private ProductGiftService productGiftService;
+```
+
+- [ ] **Step 3: 在 create 方法末尾(insert 之后)追加赠品逻辑**
+
+在 `orderMapper.insert(order)` 之后(约第250行)、`return Result.success(...)` 之前,插入:
+```java
+        // ===== 赠品校验与记录 =====
+        if (dto.getGiftItemIds() != null && !dto.getGiftItemIds().isEmpty()) {
+            // 获取家庭创建者等级
+            Long familyId = order.getFamilyId();
+            String userLevel = null;
+            if (familyId != null) {
+                // 复用 MembershipService 中已有的 getMemberLevel 或 getCurrentLevel 逻辑
+                // 根据 MembershipService.getCurrentLevel(familyId) 返回的 MembershipLevelDTO.getLevelCode()
+                // 这里调用 membershipService.getCurrentLevel(familyId) 的 levelCode
+                userLevel = membershipService.getCurrentLevel(familyId) != null
+                        ? membershipService.getCurrentLevel(familyId).getLevelCode()
+                        : null;
+            }
+            if (userLevel != null && !"FREE".equals(userLevel)) {
+                // 查询该商品该等级下启用的赠品项(含库存校验)
+                List<GiftConfigVO> activeConfigs = productGiftService.getActiveConfigsByProductAndLevel(
+                        product.getId(), userLevel);
+                if (!activeConfigs.isEmpty()) {
+                    Set<Long> allowedItemIds = activeConfigs.stream()
+                            .flatMap(c -> c.getItems().stream())
+                            .map(GiftConfigVO.GiftItemVO::getId)
+                            .collect(Collectors.toSet());
+                    for (Long itemId : dto.getGiftItemIds()) {
+                        if (!allowedItemIds.contains(itemId)) {
+                            return Result.error("赠品项不存在或超出权限");
+                        }
+                        ProductGiftItem giftItem = productGiftService.getActiveItemById(itemId);
+                        if (giftItem == null) {
+                            return Result.error("赠品项不存在");
+                        }
+                        ProductOrderGift orderGift = new ProductOrderGift();
+                        orderGift.setOrderId(order.getId());
+                        orderGift.setOrderNo(order.getOrderNo());
+                        orderGift.setGiftType(giftItem.getGiftType());
+                        orderGift.setGiftProductId(giftItem.getGiftProductId());
+                        orderGift.setGiftProductName(giftItem.getGiftProductName());
+                        orderGift.setGiftProductImage(giftItem.getGiftProductImage());
+                        orderGift.setCfValue(giftItem.getCfValue() != null ? giftItem.getCfValue() : 0);
+                        orderGift.setQuantity(1);
+                        orderGift.setFulfilled(0);
+                        orderGift.setCreatedAt(new Date());
+                        productOrderGiftMapper.insert(orderGift);
+                    }
+                } else {
+                    return Result.error("您所在等级暂无该商品赠品,请联系客服");
+                }
+            } else {
+                return Result.error("您暂无会员等级,无法享受赠品");
+            }
+        }
+```
+
+注意:需要在文件顶部 import 中追加:
+```java
+import com.etotem.cfc.dto.GiftConfigVO;
+import com.etotem.cfc.entity.ProductGiftItem;
+import com.etotem.cfc.entity.ProductOrderGift;
+import com.etotem.cfc.mapper.ProductOrderGiftMapper;
+import com.etotem.cfc.service.ProductGiftService;
+import com.etotem.cfc.service.MembershipService;
+import java.util.Set;
+```
+
+并在 `@Resource` 区域追加:
+```java
+    @Resource
+    private ProductOrderGiftMapper productOrderGiftMapper;
+    @Resource
+    private MembershipService membershipService;
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+根据编译错误修正字段名/方法名(`MembershipService.getCurrentLevel` 的返回类型需确认,如返回 `MembershipLevelDTO` 则字段名可能是 `getLevelCode()`,如返回 `MembershipLevel` 则直接调用 `getLevelCode()`)。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git commit -m "feat(product-gift): create 流程注入赠品校验与 product_order_gifts 记录"
+```
+
+---
+
+### Task 6: ProductOrderService.handlePaymentSuccess 追加赠品发放
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`
+
+**Interfaces:**
+- Consumes: `productOrderGiftMapper.selectList(fulfilled=0)`, `platformPointsService.earn()`, `productService.decreaseStock()`, `inventoryService.recordOutbound()`
+- Produces: `product_order_gifts.fulfilled=1` after successful fulfillment
+
+- [ ] **Step 1: 定位 handlePaymentSuccess 中现有赠品会员逻辑位置**
+
+在 `ProductOrderService.java` 中搜索:
+```java
+ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId
+```
+该行约在第706行。在其**之后**(约第718行 `log.info("商品订单...")` 之后)插入赠品发放逻辑。
+
+- [ ] **Step 2: 插入赠品发放代码**
+
+```java
+        // ===== 发放订单赠品(CF值 + 实物库存扣减) =====
+        try {
+            List<ProductOrderGift> pendingGifts = productOrderGiftMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrderGift>()
+                            .eq(ProductOrderGift::getOrderId, order.getId())
+                            .eq(ProductOrderGift::getFulfilled, 0));
+            for (ProductOrderGift gift : pendingGifts) {
+                if ("cf".equals(gift.getGiftType()) && gift.getCfValue() != null && gift.getCfValue() > 0) {
+                    platformPointsService.earn(order.getBuyerId(), gift.getCfValue(),
+                            "product_gift", order.getId(),
+                            "购买商品赠品: " + (gift.getGiftProductName() != null ? gift.getGiftProductName() : "CF值"));
+                } else if ("product".equals(gift.getGiftType()) && gift.getGiftProductId() != null) {
+                    boolean stockOk = productService.decreaseStock(gift.getGiftProductId(), gift.getQuantity() != null ? gift.getQuantity() : 1);
+                    if (!stockOk) {
+                        log.error("赠品库存扣减失败: orderNo={}, giftProductId={}", orderNo, gift.getGiftProductId());
+                    } else {
+                        inventoryService.recordOutbound(gift.getGiftProductId(), null,
+                                gift.getQuantity() != null ? gift.getQuantity() : 1,
+                                "gift", order.getId(), null, "商品赠品:" + orderNo);
+                    }
+                }
+                gift.setFulfilled(1);
+                gift.setUpdatedAt(new Date());
+                productOrderGiftMapper.updateById(gift);
+            }
+        } catch (Exception e) {
+            log.error("订单{}赠品发放异常: {}", orderNo, e.getMessage());
+        }
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git commit -m "feat(product-gift): handlePaymentSuccess 追加赠品发放逻辑"
+```
+
+---
+
+### Task 7: ProductService.detail 附带赠品配置
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java`
+
+**Interfaces:**
+- Consumes: `ProductGiftService.getActiveConfigsByProductAndLevel(productId, userLevel)`
+- Produces: `ProductDTO.giftConfigs` 字段(当前用户等级对应配置列表)
+
+- [ ] **Step 1: ProductDTO 新增 giftConfigs 字段**
+
+在 `ProductDTO.java` 末尾 `giftLevelName` 字段之后追加:
+```java
+    // 商品可选赠品配置(当前用户等级对应,空数组=无赠品)
+    private List<GiftConfigVO> giftConfigs;
+
+    // 内嵌 GiftConfigVO(与 dto/GiftConfigVO 同构,避免额外 import)
+    @Data
+    public static class GiftConfigVO {
+        private Long id;
+        private String levelCode;
+        private String levelName;
+        private Integer status;
+        private List<GiftItemVO> items;
+
+        @Data
+        public static class GiftItemVO {
+            private Long id;
+            private String giftType;
+            private Long giftProductId;
+            private String giftProductName;
+            private String giftProductImage;
+            private Integer cfValue;
+            private Integer sortOrder;
+        }
+    }
+```
+
+注意:`@Data` 注解来自 `lombok.Data`,`ProductDTO` 已有 `import lombok.Data`,无需新增 import。
+
+- [ ] **Step 2: ProductService.detail 组装赠品配置**
+
+在 `ProductService.java` 的 `detail` 方法中,现有赠品会员逻辑(约第163行 `ProductGiftRule giftRule = ...` 块)**之后**插入:
+```java
+        // 可选赠品配置(当前用户等级)
+        try {
+            Long currentUserId = ...; // 从 JWT 上下文获取,参考现有 detail 方法签名
+            // detail(Long id) 无 userId 参数,需改为 detail(Long id, Long userId)
+            // 参考现有调用方式:ProductController.detail(@PathVariable 或 @RequestParam)
+            // 实际调用链:ProductController.detail(@PathVariable Long id) → 调用 productMapper.selectById 等
+            // 改为从 @RequestAttribute("userId") 获取当前用户
+        } catch (Exception e) {
+            log.debug("获取赠品配置失败: {}", e.getMessage());
+        }
+```
+
+**重要:`ProductService.detail(Long id)` 目前无 userId 参数。** 需要:
+1. 修改 `ProductService.detail` 方法签名:`public Result<ProductDTO> detail(Long id, Long userId)`
+2. 修改 `ProductController.detail` 调用:从 `@RequestAttribute("userId") Long userId` 获取
+3. 在 detail 方法中组装赠品配置:
+
+```java
+        if (userId != null) {
+            // 获取用户家庭创建者等级
+            Long familyId = userMapper.selectById(userId).getFamilyId();
+            String userLevel = null;
+            if (familyId != null) {
+                MembershipLevelDTO currentLevel = membershipService.getCurrentLevel(familyId);
+                if (currentLevel != null) {
+                    userLevel = currentLevel.getLevelCode();
+                }
+            }
+            if (userLevel != null && !"FREE".equals(userLevel)) {
+                List<GiftConfigVO> activeConfigs = productGiftService.getActiveConfigsByProductAndLevel(id, userLevel);
+                dto.setGiftConfigs(activeConfigs);
+            }
+        }
+```
+
+需要先确认 `MembershipService.getCurrentLevel` 返回类型。查看现有代码:
+```bash
+grep -n "getCurrentLevel\|MembershipLevelDTO" cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java | head -10
+```
+若返回 `MembershipLevelDTO`,则字段为 `getLevelCode()`;若返回 `MembershipLevel`,则直接 `getLevelCode()`。
+
+- [ ] **Step 3: 修改 ProductController.detail 传入 userId**
+
+查看 `ProductController.java` 的 detail 方法,将:
+```java
+public Result<ProductDTO> detail(@PathVariable Long id)
+```
+改为:
+```java
+public Result<ProductDTO> detail(@RequestParam("id") Long id,
+                                  @RequestAttribute(value = "userId", required = false) Long userId)
+```
+并在方法体中传递 `userId` 给 `productService.detail(id, userId)`。
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
+git commit -m "feat(product-gift): 商品详情附带当前用户等级赠品配置"
+```
+
+---
+
+### Task 8: 小程序前端 — 商品详情页展示赠品选择
+
+**Files:**
+- Modify: `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`
+- Modify: `cfc-frontend/utils/api.js`(或对应 API 封装文件)
+
+**Interfaces:**
+- Consumes: `POST /api/product/detail`(现有接口,已返回 `giftConfigs`)
+- Produces: 商品详情页展示"购买即送"区块,支持多选赠品,提交订单时透传 `giftItemIds`
+
+- [ ] **Step 1: 确认 API 封装位置**
+
+搜索现有商品详情 API 调用:
+```bash
+grep -rn "product/detail\|product-detail\|api.product" cfc-frontend/pages/discover-detail/ | head -5
+```
+找到对应的 API 封装文件(通常在 `utils/api.js` 或页面内联)。
+
+- [ ] **Step 2: 在商品详情页添加赠品区块**
+
+在 `product-detail.vue` 中,价格区下方(`<view class="price">` 块之后)插入:
+```vue
+        <!-- 赠品选择区 -->
+        <view class="gift-section" v-if="product.giftConfigs && product.giftConfigs.length > 0">
+            <view class="gift-title">购买即送</view>
+            <view class="gift-items">
+                <view 
+                    class="gift-item" 
+                    :class="{ selected: selectedGiftIds.indexOf(item.id) > -1 }"
+                    v-for="config in product.giftConfigs" 
+                    v-for="item in config.items"
+                    :key="getItemKey(item)"
+                    @click="toggleGift(item)"
+                >
+                    <image 
+                        v-if="item.giftType === 'product' && item.giftProductImage" 
+                        :src="item.giftProductImage" 
+                        class="gift-image"
+                        mode="aspectFill"
+                    />
+                    <view v-else class="gift-cf-icon">💎</view>
+                    <text class="gift-name">{{ item.giftType === 'product' ? item.giftProductName : item.cfValue + ' CF值' }}</text>
+                    <view class="gift-check" v-if="selectedGiftIds.indexOf(item.id) > -1">✓</view>
+                </view>
+            </view>
+            <view class="gift-footer" v-if="selectedGiftIds.length > 0">
+                已选 {{ selectedGiftIds.length }} 件赠品
+            </view>
+        </view>
+```
+
+注意:小程序禁止 `:key` 表达式,需改用方法调用:
+```vue
+:key="getItemKey(item)"
+```
+并在 `methods` 中添加:
+```js
+getItemKey: function(item) {
+    return item.id;
+}
+```
+
+- [ ] **Step 3: 添加 data 和 methods**
+
+在 `data()` 中追加:
+```js
+selectedGiftIds: []
+```
+
+在 `methods` 中追加:
+```js
+toggleGift: function(item) {
+    var ids = this.selectedGiftIds;
+    var idx = ids.indexOf(item.id);
+    if (idx > -1) {
+        ids.splice(idx, 1);
+    } else {
+        ids.push(item.id);
+    }
+},
+```
+
+- [ ] **Step 4: 下单时透传 giftItemIds**
+
+找到提交订单的函数(通常在"立即购买"或"加入购物车"按钮的点击事件中),在构建下单参数时追加:
+```js
+var params = {
+    productId: this.productId,
+    quantity: this.quantity,
+    // ... 其他字段
+    giftItemIds: this.selectedGiftIds.length > 0 ? this.selectedGiftIds : undefined
+};
+```
+
+- [ ] **Step 5: 编译验证**
+
+```bash
+cd cfc-frontend && npm run build:mp-weixin
+```
+预期:构建成功,无语法错误。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
+git commit -m "feat(product-gift): 商品详情页展示赠品选择区,下单透传 giftItemIds"
+```
+
+---
+
+### Task 9: Web 管理端 — 商品编辑页新增赠品配置区块
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/ProductEdit.vue`
+- Modify: `cfc-web/src/api/admin.js`
+
+**Interfaces:**
+- Consumes: `POST /api/admin/product-gift/save`,`POST /api/admin/product-gift/list`,`GET /api/membership/levels`(或复用现有接口)
+- Produces: 商品编辑页"赠品配置"区块,支持按等级配置赠品项
+
+- [ ] **Step 1: 在 admin.js 中新增 API 封装**
+
+在 `cfc-web/src/api/admin.js` 末尾追加:
+```js
+// ===== 商品赠品配置 =====
+export const saveProductGift = (data) => request('/api/admin/product-gift/save', 'POST', data)
+export const getProductGiftList = (data) => request('/api/admin/product-gift/list', 'POST', data)
+```
+
+- [ ] **Step 2: ProductEdit.vue 新增赠品配置区块**
+
+在 `ProductEdit.vue` 中,找到商品基础信息区块末尾,追加:
+```vue
+        <!-- 赠品配置区块 -->
+        <el-divider content-position="left">赠品配置</el-divider>
+        <el-form-item label="赠品">
+            <el-table :data="giftConfigs" border size="mini">
+                <el-table-column label="会员等级" width="120">
+                    <template slot-scope="scope">
+                        <span>{{ scope.row.levelName }}</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="赠品项">
+                    <template slot-scope="scope">
+                        <div v-for="item in scope.row.items" :key="item.id" style="margin-bottom:4px">
+                            <span v-if="item.giftType === 'product'">{{ item.giftProductName }}</span>
+                            <span v-else>{{ item.cfValue }} CF值</span>
+                        </div>
+                        <span v-if="!scope.row.items || scope.row.items.length === 0">未配置</span>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-button size="mini" @click="showGiftDialog = true" style="margin-top:8px">
+                配置赠品
+            </el-button>
+        </el-form-item>
+
+        <!-- 赠品配置弹窗 -->
+        <el-dialog title="配置赠品" :visible.sync="showGiftDialog" width="700px">
+            <el-tabs v-model="activeGiftTab">
+                <el-tab-pane 
+                    v-for="level in membershipLevels" 
+                    :key="level.levelCode" 
+                    :label="level.levelName" 
+                    :name="level.levelCode"
+                >
+                    <div v-for="(item, index) in getGiftItems(level.levelCode)" :key="item.id || index" style="margin-bottom:8px; display:flex; align-items:center">
+                        <el-select v-model="item.giftType" size="small" style="width:100px; margin-right:8px">
+                            <el-option label="赠品商品" value="product" />
+                            <el-option label="CF值" value="cf" />
+                        </el-select>
+                        <el-select v-if="item.giftType === 'product'" v-model="item.giftProductId" size="small" placeholder="选择商品" style="flex:1; margin-right:8px">
+                            <el-option v-for="p in onShelfProducts" :key="p.id" :label="p.name" :value="p.id" />
+                        </el-select>
+                        <el-input v-if="item.giftType === 'cf'" v-model.number="item.cfValue" size="small" placeholder="CF值数量" style="width:120px; margin-right:8px" type="number" />
+                        <el-button size="small" type="text" @click="removeGiftItem(level.levelCode, index)">删除</el-button>
+                    </div>
+                    <el-button size="small" @click="addGiftItem(level.levelCode)">+ 添加赠品</el-button>
+                </el-tab-pane>
+            </el-tabs>
+            <div slot="footer">
+                <el-button size="small" @click="showGiftDialog = false">取消</el-button>
+                <el-button size="small" type="primary" @click="saveGiftConfig">保存</el-button>
+            </div>
+        </el-dialog>
+```
+
+- [ ] **Step 3: 添加 data 字段和 methods**
+
+在 `data()` 中追加:
+```js
+showGiftDialog: false,
+activeGiftTab: '',
+membershipLevels: [],    // 从 /api/membership/levels 获取,过滤 FREE
+giftConfigs: [],         // 已配置的赠品(加载商品详情时获取)
+giftItemsMap: {},        // { [levelCode]: [{ giftType, giftProductId, cfValue, sortOrder }] }
+onShelfProducts: [],     // 已上架商品列表(用于赠品商品选择)
+```
+
+在 `methods` 中追加:
+```js
+async loadGiftConfigs() {
+    if (!this.productId) return
+    const res = await getProductGiftList({ productId: this.productId })
+    if (res.code === 200) {
+        this.giftConfigs = res.data || []
+        this.giftItemsMap = {}
+        for (const cfg of this.giftConfigs) {
+            this.giftItemsMap[cfg.levelCode] = cfg.items || []
+        }
+    }
+},
+async loadMembershipLevels() {
+    // 复用现有会员等级接口,过滤 FREE
+    const res = await getMembershipLevels()
+    if (res.code === 200) {
+        this.membershipLevels = (res.data || []).filter(l => l.levelCode !== 'FREE')
+        if (this.membershipLevels.length > 0 && !this.activeGiftTab) {
+            this.activeGiftTab = this.membershipLevels[0].levelCode
+        }
+    }
+},
+async loadOnShelfProducts() {
+    const res = await getProductList({ status: 'on_shelf', size: 200 })
+    if (res.code === 200) {
+        this.onShelfProducts = res.data.records || res.data || []
+    }
+},
+getGiftItems(levelCode) {
+    return this.giftItemsMap[levelCode] || []
+},
+addGiftItem(levelCode) {
+    if (!this.giftItemsMap[levelCode]) {
+        this.giftItemsMap[levelCode] = []
+    }
+    this.giftItemsMap[levelCode].push({ giftType: 'product', giftProductId: null, cfValue: 0, sortOrder: 0 })
+},
+removeGiftItem(levelCode, index) {
+    this.giftItemsMap[levelCode].splice(index, 1)
+},
+async saveGiftConfig() {
+    if (!this.productId) return
+    for (const level of this.membershipLevels) {
+        const items = this.giftItemsMap[level.levelCode] || []
+        const itemsDTO = items.map((it, idx) => ({
+            giftType: it.giftType,
+            giftProductId: it.giftType === 'product' ? it.giftProductId : null,
+            cfValue: it.giftType === 'cf' ? it.cfValue : 0,
+            sortOrder: idx
+        }))
+        const res = await saveProductGift({
+            productId: this.productId,
+            levelCode: level.levelCode,
+            status: 1,
+            items: itemsDTO
+        })
+        if (res.code !== 200) {
+            this.$message.error('保存' + level.levelName + '赠品失败: ' + res.message)
+            return
+        }
+    }
+    this.$message.success('赠品配置已保存')
+    this.showGiftDialog = false
+    await this.loadGiftConfigs()
+},
+```
+
+在 `mounted` 或商品加载回调中调用:
+```js
+this.loadMembershipLevels()
+this.loadOnShelfProducts()
+// 当 productId 变化时(编辑已有商品):
+this.loadGiftConfigs()
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-web && npm run build
+```
+预期:构建成功,无语法错误。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-web/src/views/admin/ProductEdit.vue
+git add cfc-web/src/api/admin.js
+git commit -m "feat(product-gift): 管理端商品编辑页新增赠品配置区块"
+```
+
+---
+
+## Self-Review Checklist
+
+| 检查项 | 结论 |
+|--------|------|
+| 三张新表 DDL 完整 | ✅ Task 1 已覆盖 |
+| 实体类字段与 DDL 对齐 | ✅ Task 2 已覆盖 |
+| 赠品配置保存幂等(先删后插) | ✅ Task 3 `saveGiftConfig` 已实现 |
+| 管理端 save + list 接口 | ✅ Task 4 已覆盖 |
+| 下单时赠品校验(防越权) | ✅ Task 5 已覆盖 |
+| 支付成功后 CF 发放 + 实物库存扣减 | ✅ Task 6 已覆盖 |
+| 商品详情返回赠品配置 | ✅ Task 7 已覆盖 |
+| 小程序赠品选择 UI | ✅ Task 8 已覆盖 |
+| 管理端赠品配置 UI | ✅ Task 9 已覆盖 |
+| 不修改现有 `product_gift_rules` | ✅ 独立新表,不触碰旧逻辑 |
+| 赠品商品不受等级限制 | ✅ 仅校验商品 `status=on_shelf`,不校验 `memberEligible` |
+| CF 赠品发给购买者本人 | ✅ `platformPointsService.earn(buyerId, ...)` |

+ 383 - 0
docs/superpowers/specs/2026-09-02-product-gift-selection-design.md

@@ -0,0 +1,383 @@
+# 商品可选赠品功能设计
+
+- **日期**: 2026-09-02
+- **状态**: 设计稿
+- **关联**: 已有 `product_gift_rules` 表(赠送会员功能,本功能不复用);本功能新增 `product_gift_configs` / `product_gift_items` / `product_order_gifts` 三张表
+- **业务目标**: 运营指定某商品,配置不同会员等级下可选的赠品集合(含其他商品 或 CF值);用户购买时按自身等级从赠品池中勾选一个或多个(每项限选1次);支付成功后发放对应赠品
+
+---
+
+## 1. 需求背景
+
+### 已有功能(不复用)
+
+`product_gift_rules` 实现的是"购买→自动赠送会员等级"功能(无用户选择、无CF值、无赠品商品)。本功能是完全不同的业务场景,表结构和接口独立。
+
+### 本功能
+
+| 决策点 | 结论 |
+|--------|------|
+| 等级粒度 | 商品 × 多个等级,各配一套赠品(同一商品可为 FAMILY/PREMIUM 分别配置不同赠品池) |
+| 选择规则 | 用户可多选,每个赠品项最多选1次 |
+| 等级判定 | 以家庭创建者(creatorId)的有效会员等级为准 |
+| 赠品商品限制 | **无**(赠品商品只要启用即可选,不要求购买者等级匹配赠品商品的 `memberEligible`) |
+| CF 值赠品 | 类型 `cf` 的赠品,支付成功时调用 `PlatformPointsService.earn(buyerId, cfValue, ...)` 发放到购买者本人账户 |
+| 实物赠品 | 扣减赠品商品库存,写入 `product_order_gifts` 记录,随主商品统一发货;不产生CF值变化 |
+| 退款 | 本期不回收 CF 赠品,实物赠品库存不回滚(与现有逻辑一致) |
+| 原价购买 | 不限,积分/优惠券购买都可享受赠品 |
+
+---
+
+## 2. 数据模型
+
+### 2.1 新表定义
+
+**`product_gift_configs`** — 商品 × 会员等级 赠品配置(一商品可配多个等级)
+
+```sql
+CREATE TABLE IF NOT EXISTS product_gift_configs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '关联products表',
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级编码(FAMILY/PREMIUM等)',
+    status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_product_level (product_id, level_code),
+    INDEX idx_product_id (product_id),
+    INDEX idx_level_code (level_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置(商品×等级)';
+```
+
+**`product_gift_items`** — 单个配置下的可选赠品项
+
+```sql
+CREATE TABLE IF NOT EXISTS product_gift_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    config_id BIGINT NOT NULL COMMENT '关联product_gift_configs.id',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID(gift_type=product时必填)',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时必填,单位分)',
+    sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_config_id (config_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品项';
+```
+
+**`product_order_gifts`** — 订单选中的赠品记录(**下单时写入**,支付成功后发放)
+
+```sql
+CREATE TABLE IF NOT EXISTS product_order_gifts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_id BIGINT NOT NULL COMMENT '关联product_orders.id',
+    order_no VARCHAR(64) NOT NULL COMMENT '订单号快照',
+    gift_type VARCHAR(20) NOT NULL COMMENT 'product=赠品商品 cf=CF值',
+    gift_product_id BIGINT COMMENT '赠品商品ID',
+    gift_product_name VARCHAR(200) COMMENT '赠品商品名快照',
+    gift_product_image VARCHAR(500) COMMENT '赠品商品图快照',
+    cf_value INT DEFAULT 0 COMMENT 'CF值数量(gift_type=cf时)',
+    quantity INT DEFAULT 1 COMMENT '数量(默认1,可选扩展)',
+    fulfilled TINYINT(1) DEFAULT 0 COMMENT '0=已选未发放 1=已发放(支付成功置1)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_order_id (order_id),
+    INDEX idx_order_no (order_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单赠品记录';
+```
+
+### 2.2 DTO 变更
+
+**`CreateProductOrderDTO`** 新增字段:
+```java
+private List<Long> giftItemIds; // 选中的赠品项ID列表(来自 product_gift_items.id)
+```
+
+**`ProductDTO`** 新增字段:
+```java
+// 赠品配置(当前用户等级对应的配置,如无匹配则为空数组)
+private List<GiftConfigVO> giftConfigs;
+```
+
+新增内部 VO:
+```java
+@Data
+public static class GiftConfigVO {
+    private String levelCode;
+    private String levelName;
+    private Integer status;     // 1启用 0停用
+    private List<GiftItemVO> items;
+}
+
+@Data
+public static class GiftItemVO {
+    private Long id;
+    private String giftType;    // product / cf
+    private Long giftProductId;
+    private String giftProductName;
+    private String giftProductImage;
+    private Integer cfValue;
+    private Integer sortOrder;
+}
+```
+
+### 2.3 索引与约束说明
+
+- `product_gift_configs.uk_product_level`:同一商品同一等级只允许一条配置
+- `product_gift_items` 无唯一约束(一配置下可含多个赠品项)
+- `product_order_gifts` 允许重复(一个订单同一赠品可多次出现,由业务逻辑保证不重复选)
+
+---
+
+## 3. 后端实现
+
+### 3.1 新增文件
+
+| 文件 | 说明 |
+|------|------|
+| `entity/ProductGiftConfig.java` | 配置实体 |
+| `entity/ProductGiftItem.java` | 赠品项实体 |
+| `entity/ProductOrderGift.java` | 订单赠品记录实体 |
+| `mapper/ProductGiftConfigMapper.java` | Mapper |
+| `mapper/ProductGiftItemMapper.java` | Mapper |
+| `mapper/ProductOrderGiftMapper.java` | Mapper |
+| `service/ProductGiftService.java` | 赠品配置管理 + 赠品发放 |
+| `dto/ProductDTO.GiftConfigVO.java` | 内嵌 VO(已在 §2.2 定义) |
+| `dto/ProductDTO.GiftItemVO.java` | 内嵌 VO |
+| 修改 `dto/CreateProductOrderDTO.java` | 新增 `giftItemIds` 字段 |
+| 修改 `dto/ProductDTO.java` | 新增 `giftConfigs` 字段 |
+
+### 3.2 接口设计
+
+**管理端**(继承现有 `AdminProductGiftRuleController`,或新增独立 Controller):
+
+| 端点 | 说明 |
+|------|------|
+| `POST /api/admin/product-gift/save` | 保存某商品某等级的赠品配置(含赠品项列表,幂等:先删后插) |
+| `POST /api/admin/product-gift/list` | 查询某商品的全部等级赠品配置 |
+| `POST /api/admin/product-gift/delete` | 删除某商品某等级的赠品配置(status=0 或直接删除,按业务选择) |
+
+请求体(save):
+```json
+{
+  "productId": 123,
+  "levelCode": "FAMILY",
+  "status": 1,
+  "items": [
+    {"id": 1, "giftType": "product", "giftProductId": 456, "sortOrder": 0},
+    {"id": 2, "giftType": "cf", "cfValue": 1000, "sortOrder": 1}
+  ]
+}
+```
+- `id` 为 null 表示新增,有值表示更新现有项
+- 赠品商品(`giftType=product`)必须 `status=enabled`(启用状态)
+- 不校验赠品商品的 `memberEligible`(赠品不受等级限制)
+
+**商品详情**(复用现有 `/api/product/detail`,在 `ProductService.detail()` 中扩展):
+
+详情组装时查询 `product_gift_configs`(status=1),按当前用户等级过滤:
+- 若用户等级有匹配的配置 → `giftConfigs` 仅包含该等级配置
+- 若用户等级无匹配 → `giftConfigs` 为空
+- 每个配置项展开 `giftItems`
+
+### 3.3 下单流程变更
+
+`ProductOrderService.create(dto, buyerId)` 新增赠品校验:
+
+```
+1. 查询 user 的家庭 creatorId
+2. 调用 MembershipService.getMemberLevel(creatorId) 获取家庭等级
+3. 查询 product_gift_configs(productId 匹配 && levelCode 匹配 && status=1)
+4. 若存在有效配置(不强制选择,用户可选0个或多个赠品项,每项限1次):
+   - 若 dto.giftItemIds 非空:
+     - 校验每个 giftItemId 属于该配置下的 item(防越权)
+     - 校验赠品商品库存充足(giftType=product 时)
+   - 若 dto.giftItemIds 为空:无需赠品,跳过
+5. 校验通过后,**立即写入 `product_order_gifts`**(fulfilled=0),记录用户选择的赠品项快照;
+   若未选赠品,则 product_order_gifts 无记录。
+```
+
+**关键约束**:giftItemId 必须属于当前用户等级对应的配置(防其他等级越权)。
+
+### 3.4 支付成功流程(handlePaymentSuccess)
+
+在现有赠送会员逻辑之后追加赠品发放(第 706-720 行附近):
+
+```
+支付成功
+  → 查询 product_order_gifts(order_id = paidOrder.id AND fulfilled=0)
+  → 遍历每条记录:
+      if gift_type = 'cf':
+          platformPointsService.earn(buyerId, cfValue, 
+              "product_gift", orderId, "购买商品赠品: " + giftProductName)
+      if gift_type = 'product':
+          decreaseStock(giftProductId, quantity)
+          inventoryService.recordOutbound(giftProductId, null, quantity,
+              "gift", orderId, null, "商品赠品:" + orderNo)
+      → 置 fulfilled=1
+  → 异常捕获:log.error,不抛错(与现有赠送会员失败处理一致)
+```
+
+**为什么在 create 时写入、在支付成功时发放:**
+- create 时写入:确保订单持久化记录了用户选择,即便用户未支付,历史仍可追溯
+- 支付成功时发放:与主商品库存扣减时机一致,取消/退款不影响已发放赠品
+
+### 3.5 ProductGiftService 核心方法
+
+```java
+// 保存赠品配置(幂等:先删后插 items;config 行更新或插入)
+@Transactional
+public Result<Void> saveGiftConfig(Long productId, String levelCode, Integer status, List<GiftItemDTO> items)
+
+// 查询商品全部赠品配置(管理端用,返回所有等级)
+public List<GiftConfigVO> listConfigsByProduct(Long productId)
+
+// 查询某商品某等级的可用赠品项(含库存校验,供商品详情页展示)
+public List<GiftItemVO> getAvailableItems(Long productId, String levelCode)
+
+// 下单时写入订单赠品记录(create 流程调用,非事务内)
+public void recordGiftSelection(Long orderId, String orderNo, List<Long> giftItemIds)
+
+// 支付成功时发放赠品
+public void fulfillGifts(Long orderId, String orderNo, Long buyerId)
+```
+
+---
+
+## 4. 前端实现
+
+### 4.1 小程序 — 商品详情页
+
+`pages/discover-detail/product-detail/product-detail.vue`
+
+- 若 `product.giftConfigs` 非空,在价格区下方展示"购买即送"区块
+- 按家庭等级显示对应赠品配置
+- 赠品项以卡片形式展示(商品类:缩略图+名称;CF类:CF图标+数量)
+- 支持多选,底部显示已选数量
+- 提交订单时带 `giftItemIds` 字段
+
+### 4.2 小程序 — 下单页
+
+`CreateProductOrderDTO` 接收 `giftItemIds`,下单时透传。
+
+### 4.3 Web 管理端 — 商品编辑
+
+`cfc-web/src/views/admin/ProductEdit.vue`
+
+- 新增"赠品配置"区块
+- 等级 Tab:遍历 `membership_levels`(过滤 FREE),显示各等级配置
+- 每个等级下:赠品项列表(商品选择器 + CF值输入框),可增删排序
+- 保存时调 `POST /api/admin/product-gift/save`
+
+### 4.4 Web 管理端 — 赠品配置独立管理(可选后续)
+
+暂不需要独立管理页,赠品配置随商品编辑一并管理。后续如配置复杂可扩展独立页面。
+
+---
+
+## 5. 边界情况
+
+| 场景 | 行为 |
+|------|------|
+| 家庭等级为 FREE | 不展示赠品选择区(无对应配置) |
+| 赠品配置停用(status=0) | 不展示 |
+| 赠品商品库存不足 | 下单校验失败,提示"赠品商品库存不足" |
+| 赠品商品已下架 | 商品详情页不展示该赠品项 |
+| 用户选中的赠品项不属于其等级配置 | 下单校验失败,拒绝(防越权) |
+| 赠品发放异常(如库存扣减失败) | 记日志,订单支付状态不变,已发放的赠品不可逆(与退款不回收赠品一致) |
+| 取消/退款 | 不回收 CF 赠品,不回滚实物赠品库存;product_order_gifts.fulfilled 已置1则保持不变 |
+| 未选赠品(用户主动不选) | 下单正常完成,product_order_gifts 无记录 |
+| 一商品多等级配置 | 用户按自身等级只看对应配置 |
+
+---
+
+## 6. 迁移(DatabaseInitializer)
+
+```java
+// 迁移N: 创建 product_gift_configs 表(商品可选赠品配置)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_configs (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "product_id BIGINT NOT NULL, " +
+            "level_code VARCHAR(50) NOT NULL, " +
+            "status TINYINT(1) DEFAULT 1, " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_product_level (product_id, level_code), " +
+            "INDEX idx_product_id (product_id), " +
+            "INDEX idx_level_code (level_code)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠品配置'");
+    log.info("已创建product_gift_configs表");
+} catch (Exception e) {
+    // 表已存在,忽略
+}
+
+// 迁移N+1: 创建 product_gift_items 表(商品赠品项)
+// 迁移N+2: 创建 product_order_gifts 表(订单赠品记录)
+```
+
+同步更新 `schema.sql`。
+
+---
+
+## 7. 验证
+
+- 后端:`mvn clean compile` 通过
+- 小程序:`npm run build:mp-weixin` 通过
+- 管理端:`npm run build` 通过
+- 人工验证:
+  - 配置 FAMILY 等级赠品 →  FAMILY 用户下单可见并可选
+  - PREMIUM 用户下单可见 PREMIUM 等级赠品(与 FAMILY 不同)
+  - 选择 CF 赠品 → 支付后购买者 CF 余额增加
+  - 选择商品赠品 → 支付后赠品库存扣减,`product_order_gifts` 有记录
+  - 赠品商品库存不足 → 下单失败
+
+---
+
+## 8. 涉及文件清单
+
+### 后端(cfc-backend)
+
+| 操作 | 文件 |
+|------|------|
+| 新增 | `entity/ProductGiftConfig.java` |
+| 新增 | `entity/ProductGiftItem.java` |
+| 新增 | `entity/ProductOrderGift.java` |
+| 新增 | `mapper/ProductGiftConfigMapper.java` |
+| 新增 | `mapper/ProductGiftItemMapper.java` |
+| 新增 | `mapper/ProductOrderGiftMapper.java` |
+| 新增 | `service/ProductGiftService.java` |
+| 修改 | `dto/CreateProductOrderDTO.java`(新增 `giftItemIds`) |
+| 修改 | `dto/ProductDTO.java`(新增 `giftConfigs` 及内嵌 VO) |
+| 修改 | `service/ProductService.java`(详情组装赠品配置) |
+| 修改 | `service/ProductOrderService.java`(create 校验赠品 + handlePaymentSuccess 发放赠品) |
+| 修改 | `controller/admin/AdminProductGiftRuleController.java` 或新增 `AdminProductGiftController.java` |
+| 修改 | `config/DatabaseInitializer.java`(三张建表迁移) |
+| 修改 | `resources/schema.sql`(三张建表 SQL) |
+
+### 小程序(cfc-frontend)
+
+| 操作 | 文件 |
+|------|------|
+| 修改 | `pages/discover-detail/product-detail/product-detail.vue`(展示赠品选择区) |
+| 修改 | 下单相关页面/组件(提交 `giftItemIds`) |
+| 修改 | `utils/api.js`(新增赠品配置查询接口封装) |
+
+### 管理端(cfc-web)
+
+| 操作 | 文件 |
+|------|------|
+| 修改 | `src/views/admin/ProductEdit.vue`(赠品配置区块) |
+| 修改 | `src/api/admin.js`(新增赠品管理接口封装) |
+
+---
+
+## 9. 与现有赠送会员功能的区分
+
+| 维度 | 赠送会员(已有) | 可选赠品(本次) |
+|------|----------------|----------------|
+| 表 | `product_gift_rules` | `product_gift_configs` + `product_gift_items` |
+| 触发 | 支付成功自动赠送 | 用户主动选择后支付成功发放 |
+| 内容 | 会员等级 | 商品 或 CF值 |
+| 选择 | 无选择,系统自动 | 用户从赠品池中多选 |
+| 会员限制 | 商品→会员等级绑定 | 配置→会员等级绑定,赠品商品无等级限制 |
+| CF值 | 不涉及 | 赠品项可为 CF 值类型 |