2026-08-03-product-gift-membership.md 25 KB

实施计划:商品原价购买赠送会员

  • 日期: 2026-08-03
  • 依据: docs/superpowers/specs/2026-08-03-product-gift-membership-design.md(已确认方案 B)
  • 状态: 待实施
  • 验证基线: 后端 /bwydata/maven/bin/mvn clean compile;后端打包 /bwydata/maven/bin/mvn clean package -DskipTests;小程序 /sc-data/cfc/cfc-frontend 下 npm run build:mp-weixin;管理端 /sc-data/cfc/cfc-web 下 npm run build

0. 总览

运营给指定商品配置"原价购买送 XX 级别会员"。支付成功后,若订单为原价(无积分/无券/无折扣)且商品存在启用中的赠送规则,则给家庭创建者赠送该级别会员(已有同级或更高级有效会员则跳过)。

  • 规则表 product_gift_rules 与 assessment_products 同构(一商品一启用规则,status 可停用)。
  • 触发收口点:ProductOrderService.handlePaymentSuccess(现有"测评额度发放"逻辑之后追加,失败仅记日志不阻塞支付)。
  • 赠送核心:MembershipService.grantMembershipByGift,仿 processPaymentCallback,跳过佣金。

1. 关键参考代码(实施前必读)

参考 路径 用途
建表迁移模式 cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 迁移58 assessment_products 块(~4902 行) 新增迁移100 的模板
扩展实体模式 cfc-backend/src/main/java/com/etotem/cfc/entity/AssessmentProduct.java ProductGiftRule 实体模板
扩展服务模式 cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentProductService.java saveOrUpdate = 先删后插
管理端控制器模式 cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPurchaseFieldController.java 管理接口模板
赠送核心参考 cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java processPaymentCallback(334-427 行)、upgradeToFamily(428-458 行)、getMemberLevel(475-490 行) grantMembershipByGift 模板
触发注入点 cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java handlePaymentSuccess(287-367 行,"测评额度"块 ~355-367 行之后) 追加赠送逻辑
详情 DTO cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java 加 giftLevelCode/giftLevelName
详情组装 cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java detail(Long id)(89-98 行) 附带赠送规则
小程序详情页 cfc-frontend/pages/discover-detail/product-detail/product-detail.vue(17-19 行价格区) 加赠送徽标
管理端表单 cfc-web/src/views/admin/ProductEdit.vue handleSave(502-570 行)、api 引入(212 行) 加赠送区块
管理端 API 封装 cfc-web/src/api/admin.js saveProductPurchaseFields(596-601 行) 加规则 API

项目约定(必须遵守):

  • 依赖注入一律 @Resource(JDK javax.annotation),不用构造器注入。
  • 实体继承 Serializable,@TableName + @TableId(type = IdType.AUTO) + @Data。
  • 迁移编号递增:本计划新增为 迁移100(当前最大为迁移99)。
  • 测试目录存在 ~166 个预先存在失败(BeanCreation/NoClassDefFound,与本次改动无关)。项目部署验证以 mvn clean package -DskipTests 为准;单步验证用 mvn clean compile。不要尝试修复预存测试失败,也不要把本次功能写进现有失败测试套件。
  • 提交信息风格 feat: 商品赠送会员-xxx;若 auto-bump 机器人已扫入改动则直接推送即可。

任务 1:数据库迁移 + schema.sql 同步

1.1 修改 cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

在 runMigrations() 中最后一个迁移块(迁移99)之后、方法闭合 } 之前追加:

        // 迁移100: 创建 product_gift_rules 表(商品赠送会员规则)
        try {
            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_gift_rules (" +
                "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)" +
                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品赠送会员规则'");
            log.info("创建product_gift_rules表成功");
        } catch (Exception e) {
            log.warn("创建product_gift_rules表失败: {}", e.getMessage());
        }

1.2 同步 cfc-backend/src/main/resources/schema.sql

在 assessment_products 建表语句之后追加同样的 CREATE TABLE IF NOT EXISTS product_gift_rules (...) 语句。

验证: /bwydata/maven/bin/mvn clean compile(在 /sc-data/cfc/cfc-backend)退出码 0。 提交: feat: 商品赠送会员-建表迁移


任务 2:实体 + Mapper

2.1 新建 cfc-backend/src/main/java/com/etotem/cfc/entity/ProductGiftRule.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_rules")
public class ProductGiftRule implements Serializable {

    @TableId(type = IdType.AUTO)
    private Long id;

    private Long productId;

    private String levelCode;   // 赠送会员等级编码(FAMILY/PREMIUM等)

    private Integer status;     // 1=启用 0=停用

    private Date createdAt;

    private Date updatedAt;
}

2.2 新建 cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductGiftRuleMapper.java

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ProductGiftRule;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ProductGiftRuleMapper extends BaseMapper<ProductGiftRule> {
}

验证: mvn clean compile 退出码 0。 提交: feat: 商品赠送会员-实体与Mapper


任务 3:ProductGiftRuleService

新建 cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftRuleService.java:

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.MembershipLevel;
import com.etotem.cfc.entity.ProductGiftRule;
import com.etotem.cfc.mapper.MembershipLevelMapper;
import com.etotem.cfc.mapper.ProductGiftRuleMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.Date;
import java.util.List;

@Service
public class ProductGiftRuleService {

    @Resource
    private ProductGiftRuleMapper mapper;

    @Resource
    private MembershipLevelMapper levelMapper;

    /** 查询商品启用的赠送规则(一商品最多一条启用) */
    public ProductGiftRule getActiveByProductId(Long productId) {
        return mapper.selectOne(new LambdaQueryWrapper<ProductGiftRule>()
                .eq(ProductGiftRule::getProductId, productId)
                .eq(ProductGiftRule::getStatus, 1)
                .last("LIMIT 1"));
    }

    /** 查询商品全部规则(管理端回显) */
    public List<ProductGiftRule> listByProduct(Long productId) {
        return mapper.selectList(new LambdaQueryWrapper<ProductGiftRule>()
                .eq(ProductGiftRule::getProductId, productId));
    }

    /** 等级编码 → 等级名(不存在则原样返回) */
    public String getLevelName(String levelCode) {
        if (levelCode == null) {
            return null;
        }
        MembershipLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
                .eq(MembershipLevel::getLevelCode, levelCode));
        return level != null ? level.getLevelName() : levelCode;
    }

    /**
     * 保存赠送规则:先删后插(一商品一启用规则)。
     * status=1 且 levelCode 有效 → 启用;否则视为停用(清空规则)。
     */
    @Transactional
    public Result<Void> saveRule(Long productId, Integer status, String levelCode) {
        if (productId == null || status == null) {
            return Result.error("参数不完整");
        }
        boolean enable = (status == 1);
        if (enable) {
            if (levelCode == null || levelCode.isEmpty()) {
                return Result.error("请选择赠送等级");
            }
            MembershipLevel level = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
                    .eq(MembershipLevel::getLevelCode, levelCode));
            if (level == null || "FREE".equals(level.getLevelCode())) {
                return Result.error("无效的赠送等级");
            }
        }
        mapper.delete(new LambdaQueryWrapper<ProductGiftRule>()
                .eq(ProductGiftRule::getProductId, productId));
        if (enable) {
            ProductGiftRule rule = new ProductGiftRule();
            rule.setProductId(productId);
            rule.setLevelCode(levelCode);
            rule.setStatus(1);
            rule.setCreatedAt(new Date());
            rule.setUpdatedAt(new Date());
            mapper.insert(rule);
        }
        return Result.success(null);
    }
}

验证: mvn clean compile 退出码 0(确认 MembershipLevelMapper 存在,路径 com.etotem.cfc.mapper)。 提交: feat: 商品赠送会员-规则服务


任务 4:管理端规则接口

新建 cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductGiftRuleController.java:

package com.etotem.cfc.controller.admin;

import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.ProductGiftRule;
import com.etotem.cfc.service.ProductGiftRuleService;
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-rule")
public class AdminProductGiftRuleController {

    @Resource
    private ProductGiftRuleService giftRuleService;

    @PostMapping("/save")
    public Result<Void> save(@RequestBody Map<String, Object> params) {
        Long productId = Long.parseLong(params.get("productId").toString());
        Integer status = Integer.parseInt(params.get("status").toString());
        Object levelCodeObj = params.get("levelCode");
        String levelCode = levelCodeObj != null ? levelCodeObj.toString() : null;
        return giftRuleService.saveRule(productId, status, levelCode);
    }

    @PostMapping("/list")
    public Result<List<ProductGiftRule>> list(@RequestBody Map<String, Object> params) {
        Long productId = Long.parseLong(params.get("productId").toString());
        return Result.success(giftRuleService.listByProduct(productId));
    }
}

验证: mvn clean compile 退出码 0。 提交: feat: 商品赠送会员-管理端接口


任务 5:grantMembershipByGift

5.1 接口声明:cfc-backend/src/main/java/com/etotem/cfc/service/MembershipServiceInterface.java

在 /** 应用会员折扣... */ Integer applyMemberDiscount(...) 之后追加:

    /**
     * 商品赠送会员:原价购买赠送指定级别会员给家庭创建者。
     * 返回 true=已赠送/升级,false=跳过(已有同级/更高级、或参数异常)。
     */
    boolean grantMembershipByGift(Long familyId, Long userId, String levelCode,
                                  String sourceOrderNo, String transactionId);

5.2 实现:cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

在 applyMemberDiscount 方法之后追加:

    /**
     * 商品赠送会员:赠送对象=家庭创建者;已有同级/更高级有效会员则跳过。
     * 有效期=365天;写入 family_memberships + 升级记录(upgrade_type='gift');跳过佣金。
     */
    @Override
    public boolean grantMembershipByGift(Long familyId, Long userId, String levelCode,
                                         String sourceOrderNo, String transactionId) {
        if (familyId == null || userId == null || levelCode == null || levelCode.isEmpty()) {
            return false;
        }
        // 等级编码校验:需存在于 membership_levels 且非 FREE
        MembershipLevel giftLevel = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
                .eq(MembershipLevel::getLevelCode, levelCode));
        if (giftLevel == null || "FREE".equals(giftLevel.getLevelCode())) {
            return false;
        }
        // 目标用户=家庭创建者
        Family family = familyMapper.selectById(familyId);
        if (family == null || family.getCreatorId() == null) {
            return false;
        }
        Long adminUserId = family.getCreatorId();
        // 防御:购买者必须属于该家庭(赠送仅对家庭创建者生效)
        User buyer = userMapper.selectById(userId);
        if (buyer == null || !familyId.equals(buyer.getFamilyId())) {
            return false;
        }
        User adminUser = userMapper.selectById(adminUserId);
        if (adminUser == null) {
            return false;
        }
        // 前置校验:当前有效等级低于赠送等级才送(无会员/过期=FREE 视为最低)
        String currentLevel = getMemberLevel(adminUserId);
        Long currentLevelId = null;
        if (!"FREE".equals(currentLevel) && currentLevel != null) {
            MembershipLevel current = levelMapper.selectOne(new LambdaQueryWrapper<MembershipLevel>()
                    .eq(MembershipLevel::getLevelCode, currentLevel));
            currentLevelId = current != null ? current.getId() : null;
        }
        if (currentLevelId != null && currentLevelId >= giftLevel.getId()) {
            return false; // 已有同级或更高级有效会员,跳过
        }
        // 有效期:按级别年费周期(默认365天)
        Date expireTime = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000);
        // 写订阅记录
        FamilyMembership membership = new FamilyMembership();
        membership.setFamilyId(familyId);
        membership.setLevelCode(levelCode);
        membership.setStartDate(new Date());
        membership.setEndDate(expireTime);
        membership.setPaymentMethod("gift");
        membership.setPaymentStatus("paid");
        membership.setOrderNo(sourceOrderNo);
        membership.setTransactionId(transactionId);
        membership.setAmount(0);
        membershipMapper.insert(membership);
        // 升级用户
        String fromLevel = adminUser.getMemberLevel() != null ? adminUser.getMemberLevel() : "FREE";
        adminUser.setMemberLevel(levelCode);
        adminUser.setMemberExpireTime(expireTime);
        adminUser.setUpdatedAt(new Date());
        userMapper.updateById(adminUser);
        // 升级记录(upgradeType='gift')
        MemberUpgradeRecord record = new MemberUpgradeRecord();
        record.setUserId(adminUserId);
        record.setFromLevel(fromLevel);
        record.setToLevel(levelCode);
        record.setUpgradeType("gift");
        record.setExpireTime(expireTime);
        record.setCreatedAt(new Date());
        memberUpgradeRecordMapper.insert(record);
        return true;
    }

注意:

  • MembershipLevel、Family、FamilyMembership、User、MemberUpgradeRecord 均已在本文件 import(processPaymentCallback 已在用),无需新增 import;LambdaQueryWrapper 已引入。
  • 若 MembershipService 未实现 MembershipServiceInterface 的全部新方法会编译失败——本任务两步必须一起完成。

验证: mvn clean compile 退出码 0。 提交: feat: 商品赠送会员-赠送核心逻辑


任务 6:支付成功触发赠送

修改 cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java:

6.1 新增注入(在 private AssessmentProductService assessmentProductService; 之后)

    @Resource
    private ProductGiftRuleService productGiftRuleService;

    @Resource
    private MembershipService membershipService;

新增 import:com.etotem.cfc.entity.ProductGiftRule(若用局部变量类型)。

6.2 在 handlePaymentSuccess 中"测评商品发放额度"块之后、return Result.success("支付成功"); 之前追加

        // 商品赠送会员:原价购买且配置赠送规则时,支付成功后自动赠送会员
        try {
            if (paidProduct != null) {
                ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(paidProduct.getId());
                if (giftRule != null) {
                    // 原价判定:无积分抵扣、无优惠券、无折扣
                    boolean fullPrice = (order.getPointsUsed() == null || order.getPointsUsed() == 0)
                            && order.getCouponId() == null
                            && (order.getDiscountAmount() == null || order.getDiscountAmount() == 0);
                    if (fullPrice && order.getFamilyId() != null) {
                        boolean granted = membershipService.grantMembershipByGift(
                                order.getFamilyId(), order.getBuyerId(), giftRule.getLevelCode(),
                                order.getOrderNo(), order.getTransactionId());
                        log.info("商品订单{}赠送会员{}结果={}", orderNo, giftRule.getLevelCode(), granted);
                    }
                }
            }
        } catch (Exception e) {
            log.error("商品赠送会员失败: orderNo={}", orderNo, e);
            // 不阻塞支付流程
        }

验证: mvn clean compile 退出码 0。 提交: feat: 商品赠送会员-支付成功触发赠送


任务 7:商品详情附带赠送规则

7.1 cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

在 private String guideScope; 之后追加:

    // 商品赠送会员扩展信息
    private String giftLevelCode;
    private String giftLevelName;

7.2 cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

  • 新增注入(private UserMapper userMapper; 之后):

    @Resource
    private ProductGiftRuleService productGiftRuleService;
    
  • detail(Long id) 改为在 return Result.success(ProductDTO.from(product)); 前附加规则:

        ProductDTO dto = ProductDTO.from(product);
        ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(id);
        if (giftRule != null) {
            dto.setGiftLevelCode(giftRule.getLevelCode());
            dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
        }
        return Result.success(dto);
    
  • 新增 import:com.etotem.cfc.entity.ProductGiftRule。

验证: mvn clean compile 退出码 0。 提交: feat: 商品赠送会员-详情附带规则


任务 8:小程序详情页赠送徽标

修改 /sc-data/cfc/cfc-frontend/pages/discover-detail/product-detail/product-detail.vue:

8.1 在价格区(17-19 行 <view class="price-row"> 块之后)追加

        <view v-if="product.giftLevelName" class="gift-tag">购买即送 {{ product.giftLevelName }} 会员</view>

8.2 在 <style scoped> 中追加样式

.gift-tag {
  display: inline-block;
  margin-top: 12rpx;
  padding: 6rpx 16rpx;
  font-size: 22rpx;
  color: #b8860b;
  background: #fff7e6;
  border: 1rpx solid #f0d9a8;
  border-radius: 8rpx;
}

(颜色/字号可按页面现有视觉微调,但必须保持徽标位于价格下方。)

验证: 在 /sc-data/cfc/cfc-frontend 执行 npm run build:mp-weixin 退出码 0。 提交: feat: 商品赠送会员-小程序详情徽标


任务 9:管理端 API 封装

修改 /sc-data/cfc/cfc-web/src/api/admin.js,在 saveProductPurchaseFields(596-601 行)之后追加:

export function saveProductGiftRule(productId, status, levelCode) {
  return request({
    url: '/api/admin/product-gift-rule/save',
    method: 'post',
    data: { productId, status, levelCode }
  })
}

export function listProductGiftRules(productId) {
  return request({
    url: '/api/admin/product-gift-rule/list',
    method: 'post',
    data: { productId }
  })
}

export function listMembershipLevels() {
  return request({
    url: '/api/membership/levels',
    method: 'post'
  })
}

验证: 无需单独构建(依赖 admin.js 语法,任务 10 一并构建验证)。 提交: feat: 商品赠送会员-管理端API封装


任务 10:管理端 ProductEdit.vue 赠送会员区块

修改 /sc-data/cfc/cfc-web/src/views/admin/ProductEdit.vue:

10.1 引入 API(212 行 import ... from '@/api/admin' 中追加)

import { ..., saveProductGiftRule, listProductGiftRules, listMembershipLevels } from '@/api/admin'

10.2 data 中新增状态(form 附近)

        giftRuleEnabled: false,
        giftRuleLevel: '',
        membershipLevels: [],   // 可选赠送等级(过滤 FREE)

10.3 模板:在购买字段/配送等区块(表单末尾附近)追加 el-form-item

          <el-form-item label="赠送会员">
            <el-switch v-model="giftRuleEnabled" active-text="启用" inactive-text="停用" />
            <el-select v-if="giftRuleEnabled" v-model="giftRuleLevel" placeholder="选择赠送等级" style="margin-left: 12px; width: 200px">
              <el-option v-for="lv in membershipLevels" :key="lv.levelCode" :label="lv.levelName" :value="lv.levelCode" />
            </el-select>
          </el-form-item>

10.4 加载回显:在编辑态数据加载成功处(this.form = {...} 之后)追加

        // 赠送会员规则回显
        try {
          const lvRes = await listMembershipLevels()
          if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
            this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
          }
          const ruleRes = await listProductGiftRules(this.form.id)
          if (ruleRes.code === 200 && Array.isArray(ruleRes.data) && ruleRes.data.length > 0) {
            const active = ruleRes.data.find(r => r.status === 1)
            if (active) {
              this.giftRuleEnabled = true
              this.giftRuleLevel = active.levelCode
            }
          }
        } catch (e) {
          console.warn('加载赠送会员规则失败', e)
        }

(若该编辑态加载是同步流程而非 async 方法,则将上述包成独立 async 方法并在加载后调用。)

10.5 保存:在 handleSave 中 saveProductPurchaseFields 之后追加

            // Save gift membership rule
            if (productId) {
              try {
                await saveProductGiftRule(productId, this.giftRuleEnabled ? 1 : 0,
                  this.giftRuleEnabled ? this.giftRuleLevel : '')
              } catch (e) {
                console.warn('保存赠送会员规则失败', e)
              }
            }

验证: 在 /sc-data/cfc/cfc-web 执行 npm run build 退出码 0。 提交: feat: 商品赠送会员-管理端配置区块


任务 11:全量验证与部署

  1. 后端:在 /sc-data/cfc/cfc-backend 执行 /bwydata/maven/bin/mvn clean package -DskipTests,确认 BUILD SUCCESS。
  2. 小程序:在 /sc-data/cfc/cfc-frontend 执行 npm run build:mp-weixin,退出码 0。
  3. 管理端:在 /sc-data/cfc/cfc-web 执行 npm run build,退出码 0。
  4. 检查 git status,确认无遗漏文件;推送(若 auto-bump 未自动推送)。

提交: 如有未提交改动 → feat: 商品赠送会员-全量验证


任务 12:手工冒烟验证(可选,需要部署后)

部署到测试环境后验证:

  1. 管理端商品编辑页:启用赠送会员 → 选择 PREMIUM → 保存 → 重新进入回显正确;停用 → 保存 → 回显为空。
  2. 小程序详情页:配置了规则的商品显示"购买即送 XX 会员"徽标;未配置的不显示。
  3. 原价购买支付成功后,家庭创建者 member_level 升级、member_expire_time=+365 天、family_memberships 与 member_upgrade_records(type='gift')有记录。
  4. 用积分/优惠券购买:不赠送。
  5. 家庭已有同级/更高级有效会员时购买:不降级、不赠送。

范围外(本期不做)

  • 退款回收会员
  • 多规则同时启用(当前一商品一启用规则)
  • 赠送记录独立展示页(可查 family_memberships / member_upgrade_records)
  • 会员时长累计/顺延(直接按 365 天重设 expireTime,与现有 upgradeToFamily 行为一致)