2026-09-02-product-gift-selection.md 54 KB

商品可选赠品功能 实施计划

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 中搜索最后一个 // 迁移 注释:

grep -n "// 迁移" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java | tail -5

记录最大编号 N(预计是 迁移278 或更高)。

  • Step 2: 在 DatabaseInitializer 末尾添加三个建表迁移

runMigrations() 方法末尾(} 之前)追加:

// 迁移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行),在其之后追加以下三张表的建表语句(保持与迁移代码完全一致):

-- 商品赠品配置(商品×等级)
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: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS,无编译错误。

  • [ ] Step 5: 提交

    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

    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

    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

    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:

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:

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:

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: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS。

  • [ ] Step 6: 提交

    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

    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

    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

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

注意:项目中会员等级实体名为 MembershipLevelentity/MembershipLevel.java),不是 MemberLevel。请将上述代码中所有 MemberLevel 替换为 MembershipLevelMemberLevelMapper 替换为 MembershipLevelMapper,字段 levelCode / levelName 对应 getLevelCode() / getLevelName()

  • [ ] Step 4: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS。如有编译错误,根据错误信息调整字段名和 import。

  • [ ] Step 5: 提交

    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

    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: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS。

  • [ ] Step 3: 提交

    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 字段之后)追加:

    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 字段区域,追加:

    @Resource
    private ProductGiftService productGiftService;
  • Step 3: 在 create 方法末尾(insert 之后)追加赠品逻辑

orderMapper.insert(order) 之后(约第250行)、return Result.success(...) 之前,插入:

        // ===== 赠品校验与记录 =====
        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 中追加:

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 区域追加:

    @Resource
    private ProductOrderGiftMapper productOrderGiftMapper;
    @Resource
    private MembershipService membershipService;
  • [ ] Step 4: 编译验证

    cd cfc-backend && mvn clean compile
    

根据编译错误修正字段名/方法名(MembershipService.getCurrentLevel 的返回类型需确认,如返回 MembershipLevelDTO 则字段名可能是 getLevelCode(),如返回 MembershipLevel 则直接调用 getLevelCode())。

  • [ ] Step 5: 提交

    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 中搜索:

ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId

该行约在第706行。在其之后(约第718行 log.info("商品订单...") 之后)插入赠品发放逻辑。

  • [ ] Step 2: 插入赠品发放代码

        // ===== 发放订单赠品(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: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS。

  • [ ] Step 4: 提交

    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 字段之后追加:

    // 商品可选赠品配置(当前用户等级对应,空数组=无赠品)
    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.DataProductDTO 已有 import lombok.Data,无需新增 import。

  • Step 2: ProductService.detail 组装赠品配置

ProductService.javadetail 方法中,现有赠品会员逻辑(约第163行 ProductGiftRule giftRule = ... 块)之后插入:

        // 可选赠品配置(当前用户等级)
        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 方法中组装赠品配置:

        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 返回类型。查看现有代码:

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 方法,将:

public Result<ProductDTO> detail(@PathVariable Long id)

改为:

public Result<ProductDTO> detail(@RequestParam("id") Long id,
                                  @RequestAttribute(value = "userId", required = false) Long userId)

并在方法体中传递 userIdproductService.detail(id, userId)

  • [ ] Step 4: 编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS。

  • [ ] Step 5: 提交

    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 调用:

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"> 块之后)插入:

        <!-- 赠品选择区 -->
        <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 表达式,需改用方法调用:

:key="getItemKey(item)"

并在 methods 中添加:

getItemKey: function(item) {
    return item.id;
}
  • Step 3: 添加 data 和 methods

data() 中追加:

selectedGiftIds: []

methods 中追加:

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

找到提交订单的函数(通常在"立即购买"或"加入购物车"按钮的点击事件中),在构建下单参数时追加:

var params = {
    productId: this.productId,
    quantity: this.quantity,
    // ... 其他字段
    giftItemIds: this.selectedGiftIds.length > 0 ? this.selectedGiftIds : undefined
};
  • [ ] Step 5: 编译验证

    cd cfc-frontend && npm run build:mp-weixin
    

预期:构建成功,无语法错误。

  • [ ] Step 6: 提交

    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/savePOST /api/admin/product-gift/listGET /api/membership/levels(或复用现有接口)
  • Produces: 商品编辑页"赠品配置"区块,支持按等级配置赠品项

  • [ ] Step 1: 在 admin.js 中新增 API 封装

cfc-web/src/api/admin.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 中,找到商品基础信息区块末尾,追加:

        <!-- 赠品配置区块 -->
        <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() 中追加:

showGiftDialog: false,
activeGiftTab: '',
membershipLevels: [],    // 从 /api/membership/levels 获取,过滤 FREE
giftConfigs: [],         // 已配置的赠品(加载商品详情时获取)
giftItemsMap: {},        // { [levelCode]: [{ giftType, giftProductId, cfValue, sortOrder }] }
onShelfProducts: [],     // 已上架商品列表(用于赠品商品选择)

methods 中追加:

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 或商品加载回调中调用:

this.loadMembershipLevels()
this.loadOnShelfProducts()
// 当 productId 变化时(编辑已有商品):
this.loadGiftConfigs()
  • [ ] Step 4: 编译验证

    cd cfc-web && npm run build
    

预期:构建成功,无语法错误。

  • [ ] Step 5: 提交

    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, ...)