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

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

liaoxg 2 месяцев назад
Родитель
Сommit
294ab806bf
40 измененных файлов с 3253 добавлено и 56 удалено
  1. 59 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  3. 129 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  4. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftController.java
  5. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
  6. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  7. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/GiftConfigVO.java
  8. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  9. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductGiftItemDTO.java
  10. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  11. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftConfig.java
  12. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftItem.java
  13. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrderGift.java
  14. 15 20
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftConfigMapper.java
  16. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftItemMapper.java
  17. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderGiftMapper.java
  18. 29 4
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  19. 26 6
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  20. 184 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftService.java
  21. 115 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  22. 25 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  23. 44 0
      cfc-backend/src/main/resources/schema.sql
  24. 6 6
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
  25. 2 2
      cfc-frontend/components/wuxing-sandbox.vue
  26. 140 0
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  27. 6 1
      cfc-frontend/pages/shop/checkout/checkout.vue
  28. 1 1
      cfc-web/.last_build_commit
  29. 2 2
      cfc-web/package-lock.json
  30. 1 1
      cfc-web/package.json
  31. 63 0
      cfc-web/public/CHANGELOG-v1.0.md
  32. 64 1
      cfc-web/public/CHANGELOG.md
  33. 16 0
      cfc-web/src/api/admin.js
  34. 8 0
      cfc-web/src/api/coupon.js
  35. 180 2
      cfc-web/src/views/Families.vue
  36. 11 6
      cfc-web/src/views/admin/ArticleManage.vue
  37. 138 1
      cfc-web/src/views/admin/ProductEdit.vue
  38. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  39. 1354 0
      docs/superpowers/plans/2026-09-02-product-gift-selection.md
  40. 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) {
             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) {
+            // 表已存在,忽略
+        }
     }
 }

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

@@ -1592,6 +1592,14 @@ public class HealthReportController {
 
         try {
             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);
         } catch (Exception e) {
             log.error("审核确认失败", e);
@@ -2215,6 +2223,16 @@ public class HealthReportController {
             Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
             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) {
                 healthReportService.setProxyUploader(reportId, userId);

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

@@ -1,20 +1,27 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 
@@ -34,6 +41,9 @@ public class AdminCouponController {
     @Resource
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
 
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
@@ -153,4 +163,123 @@ public class AdminCouponController {
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         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));
+    }
+}

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

@@ -47,10 +47,11 @@ public class ProductController {
     }
 
     @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");
         Long id = ParamUtils.getLong(params.get("id"));
-        return productService.detail(id);
+        return productService.detail(id, userId);
     }
 
     @PostMapping("/create")

+ 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 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 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 giftLevelName;
 
+    // 商品可选赠品配置(当前用户等级对应,空=无赠品)
+    private List<GiftConfigVO> giftConfigs;
+
     // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
     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;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -48,6 +49,9 @@ public class Article implements Serializable {
     private Integer isFeatured;
     private Date publishedAt;
     private Integer viewCount;
+    /** 总阅读时长(秒,瞬态字段,由 article_reading_records 聚合) */
+    @TableField(exist = false)
+    private Integer totalDurationSeconds;
     private Long createdBy;
     /** 作者类型: admin/parent/child */
     private String authorType;

+ 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);
 
     /**
-     * 按文章 ID 分页查询阅读记录,JOIN family_members 获取昵称,按最后阅读时间倒序
+     * 按文章 ID 分页查询阅读记录(每人聚合一行),JOIN family_members 获取昵称
      */
     @Select("SELECT " +
-            "  r.id, " +
-            "  r.user_id, " +
-            "  r.child_id, " +
-            "  r.duration_seconds, " +
-            "  r.read_at, " +
-            "  r.updated_at, " +
             "  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 " +
             "LEFT JOIN family_members m ON r.child_id = m.id " +
             "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}")
-    @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 = "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("offset") int offset,
             @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> {
+}

+ 29 - 4
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 javax.annotation.Resource;
 import java.util.*;
+import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -356,10 +357,10 @@ public class ArticleService {
     }
 
     /**
-     * 按文章 ID 分页查询阅读记录,返回用户昵称、时长、最后阅读时间
+     * 按文章 ID 分页查询阅读记录(每人聚合一行),返回昵称、累计时长、阅读次数、最后阅读时间
      */
     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) {
             Page<Map<String, Object>> empty = new Page<>(page, size);
             empty.setTotal(0);
@@ -367,7 +368,7 @@ public class ArticleService {
             return empty;
         }
         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);
         result.setTotal(total);
         result.setRecords(records);
@@ -416,7 +417,31 @@ public class ArticleService {
         }
         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

+ 26 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -5,9 +5,11 @@ import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.ReportFoodSuitability;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
 import com.etotem.cfc.mapper.FoodRecommendIdxLogMapper;
+import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -27,6 +29,9 @@ public class FoodRecommendService {
     @Resource
     private FoodRecommendIdxLogMapper foodRecommendIdxLogMapper;
 
+    @Resource
+    private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -46,14 +51,29 @@ public class FoodRecommendService {
             return;
         }
 
-        // 从 foods 表读取最新报告关联的食材推荐(正常确认流程写入 foods 表)
+        // 优先从 foods 表读取(正常确认流程写入 foods 表);
+        // 若 foods 表无数据(审核路径只写 report_food_suitability),回退到 report_food_suitability
         List<Food> reportFoods = foodService.listByReportId(report.getId());
-
         if (reportFoods.isEmpty()) {
-            foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
-                    .eq(FoodRecommendIndex::getUserId, userId));
-            log.info("报告{}无食材推荐数据,已清除用户{}的推荐指数", report.getId(), userId);
-            return;
+            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(

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

+ 115 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -21,6 +21,7 @@ import com.etotem.cfc.entity.ProductServicePerson;
 import com.etotem.cfc.entity.ProductServicePersonSlot;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
+import com.etotem.cfc.service.PlatformPointsService;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
@@ -47,6 +48,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 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.UnlockGateService;
 import com.etotem.cfc.service.UserService;
@@ -54,6 +57,10 @@ import com.alibaba.fastjson.JSON;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 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
 public class ProductOrderService {
@@ -117,6 +124,9 @@ public class ProductOrderService {
     @Resource
     private FamilyPlatformPointsService familyPlatformPointsService;
 
+    @Resource
+    private PlatformPointsService platformPointsService;
+
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
 
@@ -147,6 +157,12 @@ public class ProductOrderService {
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
+    @Resource
+    private ProductOrderGiftMapper productOrderGiftMapper;
+
+    @Resource
+    private ProductGiftService productGiftService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
             return Result.error("请先登录");
@@ -236,6 +252,11 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         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));
     }
 
@@ -506,9 +527,76 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         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));
     }
 
+    /**
+     * 赠品校验与记录:校验 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) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -696,6 +784,33 @@ public class ProductOrderService {
             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 关卡)
         try {
             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.extension.plugins.pagination.Page;
 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.ProductListQueryDTO;
 import com.etotem.cfc.entity.AssessmentProduct;
@@ -45,6 +47,9 @@ public class ProductService {
     @Resource
     private ProductGiftRuleService productGiftRuleService;
 
+    @Resource
+    private ProductGiftService productGiftService;
+
     @Resource
     private AssessmentProductService assessmentProductService;
 
@@ -128,7 +133,7 @@ wrapper.in(Product::getId, productIds)
         return Result.success(data);
     }
 
-    public Result<ProductDTO> detail(Long id) {
+    public Result<ProductDTO> detail(Long id, Long userId) {
         Product product = productMapper.selectById(id);
         if (product == null) {
             return Result.error("商品不存在");
@@ -165,6 +170,25 @@ wrapper.in(Product::getId, productIds)
             dto.setGiftLevelCode(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 附带自取地点/服务者/线上时间段)
         Map<String, Object> deliveryConfig = new HashMap<>();
         productDeliveryConfigService.enrichProductDetail(id, product.getDeliveryMethod(), deliveryConfig);

+ 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)
 ) 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 (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     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.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());
         assertNotNull(result.getData());
@@ -160,9 +160,9 @@ public class ProductControllerTest {
         Map<String, Object> params = new HashMap<>();
         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());
     }
@@ -175,9 +175,9 @@ public class ProductControllerTest {
         ProductDTO mockProduct = createProductDTO(10L, "待发布任务模板", 9800, "draft");
         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("draft", result.getData().getStatus());

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

@@ -9,8 +9,8 @@
     </view>
   </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')">
       <text class="toggle-icon">👤</text>
       <text class="toggle-text">个人</text>

+ 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>
         <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">
           <text class="meta-item" v-if="product.productType">{{ typeLabel }}</text>
           <text class="meta-item" v-if="product.domain">{{ domainLabel }}</text>
@@ -280,6 +300,20 @@ export default {
     },
     buyButtonText: function() {
       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) {
@@ -338,6 +372,16 @@ export default {
         if (res.code === 200 && res.data) {
           that.product = res.data
           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.product.name,
             '/pages/discover-detail/product-detail/product-detail?id=' + that.product.id
@@ -536,6 +580,12 @@ export default {
       var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
       return map[method] || '快递配送'
     },
+    getGiftItemKey: function(item) {
+      return item.id
+    },
+    toggleGift: function(item) {
+      this.$set(item, '_selected', !item._selected)
+    },
 
     onBuy: function() {
       var userId = uni.getStorageSync('userId')
@@ -561,6 +611,20 @@ export default {
         productData.specDesc = this.buildSpecDesc()
       }
       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
       if (deliveryMethod > 1 && this.product.deliveryConfig) {
         productData.deliveryMethod = deliveryMethod
@@ -1063,4 +1127,80 @@ export default {
 .btn-share::after {
   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>

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

@@ -450,7 +450,8 @@ export default {
       showPickupLocationModal: false,
       showTimeSlotModal: false,
       showServicePersonModal: false,
-      showOnlineSlotModal: false
+       showOnlineSlotModal: false,
+       giftItemIds: []
     }
   },
   computed: {
@@ -508,6 +509,8 @@ export default {
   onLoad(options) {
     var that = this
     var stored = uni.getStorageSync('checkoutItems')
+    var giftIds = uni.getStorageSync('checkoutGiftItemIds')
+    this.giftItemIds = (giftIds && giftIds.length > 0) ? giftIds : []
     if (stored && stored.length > 0) {
       this.items = stored
       this.calcAmount()
@@ -867,6 +870,7 @@ export default {
         method: 'POST',
         data: {
           items: orderItems,
+          giftItemIds: that.giftItemIds,
           remark: this.remark,
           addressId: that.selectedAddress.id,
           purchaseInfo: that.purchaseForm,
@@ -886,6 +890,7 @@ export default {
             var order = res.data.data
             uni.showToast({ title: '下单成功', icon: 'success' })
             uni.removeStorageSync('checkoutItems')
+            uni.removeStorageSync('checkoutGiftItemIds')
             var moneyAmount = order.moneyAmount != null ? order.moneyAmount : order.totalAmount
             var pointsUsed = order.pointsUsed || 0
             setTimeout(function() {

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-4570f9400ece6c2134394194e657ba09976d6ef7
+405ab8913a68e487726e5fbd79ea75488f7f3c01

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,69 @@
 
 ---
 
+## 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 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1286
+> 当前版本: v1.0.1290
 
 ## 历史版本
 
@@ -8,6 +8,69 @@
 
 ---
 
+## 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 修复

+ 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() {
   return request({
     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) {
   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-dropdown-menu slot="dropdown">
                 <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="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
@@ -174,12 +176,106 @@
         <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
       </div>
     </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>
 </template>
 
 <script>
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
-import { getCouponList, issueFamilyCoupon } from '@/api/coupon'
+import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
   name: 'Families',
@@ -224,7 +320,16 @@ export default {
         quantity: 1
       },
       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() {
@@ -239,6 +344,8 @@ export default {
     handleActionCmd(row, cmd) {
       switch (cmd) {
         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 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
@@ -386,6 +493,77 @@ export default {
       if (!dateStr) return '-'
       const date = new Date(dateStr)
       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>
           </template>
         </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">
           <template slot-scope="{ row }">
@@ -240,19 +245,19 @@
             {{ row.nickname || '-' }}
           </template>
         </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 }">
-            {{ formatDuration(row.duration_seconds) }}
+            {{ formatDuration(row.total_duration_seconds) }}
           </template>
         </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 }">
-            {{ row.read_date || '-' }}
+            {{ row.read_count || 0 }}次
           </template>
         </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 }">
-            {{ formatTime(row.read_at) }}
+            {{ formatTime(row.last_read_at) }}
           </template>
         </el-table-column>
       </el-table>

+ 138 - 1
cfc-web/src/views/admin/ProductEdit.vue

@@ -299,6 +299,26 @@
           </el-select>
         </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-button type="primary" :loading="saving" @click="handleSave">
 {{ isEdit ? '保存修改' : '创建商品' }}
@@ -311,7 +331,7 @@
 </template>
 
 <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 DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
@@ -369,6 +389,9 @@ export default {
       giftRuleEnabled: false,
       giftRuleLevel: '',
       membershipLevels: [],
+      // 可选赠品配置:{ [levelCode]: [{giftType,giftProductId,cfValue,sortOrder}] }
+      giftConfigsByLevel: {},
+      onShelfProducts: [],
       rules: {
         name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
         productType: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
@@ -539,6 +562,8 @@ export default {
   mounted() {
     this.loadSupplySystems()
     this.loadPredefinedFields()
+    this.ensureMembershipLevels()
+    this.loadOnShelfProducts()
     if (this.isEdit) {
       this.loadProduct(this.$route.query.id)
     }
@@ -620,6 +645,7 @@ export default {
           this.loadPurchaseFields(p.id)
 // 赠送会员规则回显
           this.loadGiftRule(p.id)
+          this.loadGiftConfigs(p.id)
           // Load growth archive config
           this.loadGrowthArchive(p.id)
           // Load delivery config (service persons / online slots)
@@ -699,6 +725,94 @@ async loadGiftRule(productId) {
         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) {
       try {
         const res = await getGrowthArchiveProduct(productId)
@@ -931,6 +1045,14 @@ async loadGiftRule(productId) {
                 console.warn('保存赠送会员规则失败', e)
               }
             }
+            // 保存可选赠品配置
+            if (productId) {
+              try {
+                await this.saveGiftConfigs(productId)
+              } catch (e) {
+                console.warn('保存赠品配置失败', e)
+              }
+            }
             // Save growth archive config (only for existing products)
             if (this.form.id) {
               try {
@@ -1121,6 +1243,21 @@ async loadGiftRule(productId) {
   color: #ccc;
   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 {
   border: 1px solid #e8e8e8;
   border-radius: 4px;

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

@@ -197,6 +197,7 @@
 | 供应商订单修复 | 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` |
 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) | 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 值类型 |