2026-08-05-membership-coupon-system-redesign.md 51 KB

会员体系重构:统一定价 + 优惠券发放/积分兑换 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 商品对所有用户统一定价,等级差异改为优惠券数量(加入送 + 周期性补发 + 家庭人口券 + 积分兑换),优惠券支持满减/无门槛/折扣三种类型并绑定商品。

架构: 后端 cfc-backend(Spring Boot 2.7.18 + MyBatis-Plus)扩展 coupon 表与新建 coupon_grant_log 流水表;CouponService 承担三类型核销与幂等发券;三个触发点(会员开通 JOIN、定时任务 PERIODIC、新增成员 POPULATION)+ 积分兑换(EXCHANGE)统一走 grant();商品侧停用会员价(活动侧保留)。前端:cfc-web 管理端扩展优惠券配置与发券记录页,cfc-frontend 小程序新增兑换入口。

技术栈: Java 8、Spring Boot 2.7.18、MyBatis-Plus、Lombok、JUnit/Mockito(后端);Vue 2 + Element UI(管理端);uni-app Vue 2(小程序)。

规格: docs/superpowers/specs/2026-08-05-membership-coupon-system-redesign.md


文件结构

后端 cfc-backend(src/main/java/com/etotem/cfc/)

文件 操作 职责
config/DatabaseInitializer.java 修改 迁移164:coupon 补列 + 创建 coupon_grant_log
resources/schema.sql 修改 coupon CREATE TABLE 补列 + coupon_grant_log 建表
entity/Coupon.java 修改 加 7 个字段(复用 type,不新增 coupon_type)
entity/CouponGrantLog.java 创建 发券流水实体
mapper/CouponGrantLogMapper.java 创建 发券流水 Mapper
service/CouponService.java 修改 apply() 三类型核销;grant()/grantJoinCoupons()/grantPopulationCoupons()/listPeriodicTemplates()/listExchangeable()/listByProduct()
service/MembershipService.java 修改 JOIN 发券接入(activateMembership + grantMembershipByGift);listActiveMemberUserIds()
task/CouponGrantTask.java 创建 PERIODIC 定时任务(每日 5:00)
service/FamilyMemberService.java 修改 addMember() 末尾 POPULATION 发券
service/PointsExchangeService.java 修改 exchangeCoupon() 积分兑换优惠券
controller/CouponController.java 修改 新端点 /exchange、/exchangeable、/product、/join-rules
controller/admin/AdminCouponController.java 修改 新端点 /grant-log
dto/ProductDTO.java 修改 统一价(不再 resolveMemberPrice)
service/ProductRecommendationService.java 修改 memberPrice 返回 price

测试 cfc-backend/src/test/java/com/etotem/cfc/

文件 操作 职责
service/CouponServiceTest.java 创建 apply() 三类型 + grant() 幂等
integration/FamilyMemberManagementFlowTest.java 修改 addMember 发券断言

管理端 cfc-web

文件 操作 职责
src/views/admin/CouponManagement.vue 修改 表单扩展(类型/折扣率/绑定商品/积分价/发放规则)
src/views/admin/CouponGrantLog.vue 创建 发券记录查询页
src/router/index.js 修改 注册发券记录路由
src/views/admin/ProductManage.vue 修改 移除会员价输入
src/views/admin/ProductEdit.vue 修改 移除会员价输入

小程序 cfc-frontend

文件 操作 职责
utils/api.js 修改 新增优惠券兑换 API 函数
pages/profile-extra/coupons.vue 修改 加「可兑换」tab(兑换中心入口)
pages/shop/detail/detail.vue 修改 商品详情加「积分兑换优惠券」区块
pages/family/add-member.vue 修改 添加成员成功提示发券
pages/membership/upgrade.vue 修改 会员权益展示 JOIN 券规则

任务 1:数据库迁移

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  • 修改:cfc-backend/src/main/resources/schema.sql

  • [ ] 步骤 1:在 DatabaseInitializer 的 runMigrations() 末尾追加迁移 164

先定位文件末尾(搜索 // 迁移163 确认最新编号)。追加:

// 迁移164: coupon表扩展会员券字段 + 创建coupon_grant_log表(会员体系重构: 统一价+优惠券发放/积分兑换)
try {
    ensureColumn("coupon", "discount_rate", "INT COMMENT '折扣券折扣率(千分比,9000=9折)'");
    ensureColumn("coupon", "product_id", "BIGINT COMMENT '绑定商品ID,NULL=全场通用'");
    ensureColumn("coupon", "points_price", "INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换'");
    ensureColumn("coupon", "grant_type", "VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放'");
    ensureColumn("coupon", "grant_level_code", "VARCHAR(32) COMMENT '发放规则对应会员等级'");
    ensureColumn("coupon", "grant_period", "VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY'");
    ensureColumn("coupon", "grant_quantity", "INT DEFAULT 1 COMMENT '每次发放数量'");
    log.info("已为coupon表添加会员券字段");
} catch (Exception e) {
    log.warn("添加coupon表会员券字段失败: {}", e.getMessage());
}

try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS coupon_grant_log (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "user_id BIGINT NOT NULL COMMENT '收券用户ID', " +
            "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
            "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE', " +
            "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
            "quantity INT DEFAULT 1 COMMENT '发放数量', " +
            "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "UNIQUE KEY uk_grant (user_id, coupon_id, grant_type, period), " +
            "INDEX idx_coupon (coupon_id), " +
            "INDEX idx_user (user_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表'");
    log.info("已创建coupon_grant_log表");
} catch (Exception e) {
    log.warn("创建coupon_grant_log表失败: {}", e.getMessage());
}

注意:先读文件确认 ensureColumn 辅助方法的确切签名(private void ensureColumn(String table, String column, String definition)),如不符则用现有 try { jdbcTemplate.execute("ALTER TABLE ... ADD COLUMN ...") } catch { } 标准模式。

  • 步骤 2:同步 schema.sql

在 coupon 的 CREATE TABLE(约 1910 行)中加入 7 列:

    discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
    product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
    points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
    grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
    grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
    grant_period VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY',
    grant_quantity INT DEFAULT 1 COMMENT '每次发放数量',

在文件末尾追加 coupon_grant_log 建表语句(与迁移 164 中一致)。

  • 步骤 3:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(backend): 迁移164 coupon表扩展会员券字段+新建coupon_grant_log发券流水表"
    

任务 2:Entity 与 Mapper

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/CouponGrantLog.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/CouponGrantLogMapper.java

  • [ ] 步骤 1:Coupon.java 加字段

在 private Integer minSpend; 后追加(注意 type 字段复用,不新增):

    /** 折扣券折扣率(千分比, 9000=9折) */
    private Integer discountRate;

    /** 绑定商品ID, NULL=全场通用 */
    private Long productId;

    /** 积分兑换价, 0=不可积分兑换 */
    private Integer pointsPrice;

    /** 发放规则: JOIN/PERIODIC/POPULATION, NULL=不自动发放 */
    private String grantType;

    /** 发放规则对应会员等级(FREE/FAMILY/PREMIUM) */
    private String grantLevelCode;

    /** PERIODIC周期: MONTHLY/QUARTERLY */
    private String grantPeriod;

    /** 每次发放数量 */
    private Integer grantQuantity;
  • [ ] 步骤 2:创建 CouponGrantLog.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("coupon_grant_log")
    public class CouponGrantLog implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private Long userId;
    
    private Long couponId;
    
    /** JOIN/PERIODIC/POPULATION/EXCHANGE */
    private String grantType;
    
    /** 周期标识(YYYY-MM或YYYY-Qn), PERIODIC防重用 */
    private String period;
    
    private Integer quantity;
    
    /** 触发来源(订单号/成员ID等) */
    private String source;
    
    private Date createdAt;
    }
    
  • [ ] 步骤 3:创建 CouponGrantLogMapper.java

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.CouponGrantLog;
    
    public interface CouponGrantLogMapper extends BaseMapper<CouponGrantLog> {
    }
    
  • [ ] 步骤 4:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java cfc-backend/src/main/java/com/etotem/cfc/entity/CouponGrantLog.java cfc-backend/src/main/java/com/etotem/cfc/mapper/CouponGrantLogMapper.java
    git commit -m "feat(backend): Coupon实体扩展会员券字段+新增CouponGrantLog实体与Mapper"
    

任务 3:CouponService 核心逻辑(三类型核销 + 幂等发券 + 查询)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java
  • 创建:cfc-backend/src/test/java/com/etotem/cfc/service/CouponServiceTest.java

  • [ ] 步骤 1:注入 CouponGrantLogMapper

    @Resource
    private CouponGrantLogMapper couponGrantLogMapper;
    
  • [ ] 步骤 2:编写失败的单元测试 CouponServiceTest.java

    package com.etotem.cfc.service;
    
    import com.etotem.cfc.entity.Coupon;
    import com.etotem.cfc.entity.CouponGrantLog;
    import com.etotem.cfc.entity.UserCoupon;
    import com.etotem.cfc.mapper.CouponGrantLogMapper;
    import com.etotem.cfc.mapper.CouponMapper;
    import com.etotem.cfc.mapper.UserCouponMapper;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.junit.MockitoJUnitRunner;
    
    import java.util.Date;
    
    import static org.junit.Assert.assertEquals;
    import static org.junit.Assert.assertNull;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @RunWith(MockitoJUnitRunner.class)
    public class CouponServiceTest {
    
    @Mock
    private CouponMapper couponMapper;
    
    @Mock
    private UserCouponMapper userCouponMapper;
    
    @Mock
    private CouponGrantLogMapper couponGrantLogMapper;
    
    @InjectMocks
    private CouponService couponService;
    
    private UserCoupon userCoupon(Long id, Long userId, Long couponId) {
        UserCoupon uc = new UserCoupon();
        uc.setId(id);
        uc.setUserId(userId);
        uc.setCouponId(couponId);
        uc.setStatus("AVAILABLE");
        return uc;
    }
    
    private Coupon coupon(String type, Integer value, Integer minSpend, Integer discountRate) {
        Coupon c = new Coupon();
        c.setType(type);
        c.setValue(value);
        c.setMinSpend(minSpend);
        c.setDiscountRate(discountRate);
        c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L));
        c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L));
        c.setApplicableTo("ALL");
        return c;
    }
    
    @Test
    public void applyFixedEnforcesMinSpend() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
        // 订单金额1000分 < 起用门槛2000分 -> 不可用
        assertNull(couponService.apply(100L, 1L, "ALL", 1000));
    }
    
    @Test
    public void applyFixedWorksAboveMinSpend() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
        assertEquals(Integer.valueOf(1000), couponService.apply(100L, 1L, "ALL", 3000));
    }
    
    @Test
    public void applyFixedCappedByOrderAmount() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 500, 0, null));
        // 满减券抵扣不能超过订单金额
        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
    }
    
    @Test
    public void applyCashIgnoresMinSpend() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("CASH", 500, 1000, null));
        // 无门槛券(CASH)忽略min_spend:订单300 < 配置门槛1000 仍可用,抵扣300(不超订单)
        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
    }
    
    @Test
    public void applyDiscountComputesRate() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
        // 订单3000分 9折(9000) -> 抵扣 3000*(10000-9000)/10000=300,实付2700
        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 3000));
    }
    
    @Test
    public void applyDiscountSmallOrder() {
        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
        // 小订单100分 9折 -> 抵扣10,实付90
        assertEquals(Integer.valueOf(10), couponService.apply(100L, 1L, "ALL", 100));
    }
    
    @Test
    public void grantSkipsExistingPeriod() {
        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 0, null));
        when(couponGrantLogMapper.selectCount(any())).thenReturn(1L);
        boolean granted = couponService.grant(100L, 1L, "PERIODIC", "2026-08", null);
        org.junit.Assert.assertFalse(granted);
        verify(userCouponMapper, never()).insert(any());
    }
    
    @Test
    public void grantIssuesQuantityAndLogs() {
        Coupon c = coupon("FIXED", 1000, 0, null);
        c.setGrantQuantity(4);
        when(couponMapper.selectById(1L)).thenReturn(c);
        when(couponGrantLogMapper.selectCount(any())).thenReturn(0L);
        boolean granted = couponService.grant(100L, 1L, "JOIN", null, "ORD123");
        org.junit.Assert.assertTrue(granted);
        verify(userCouponMapper, times(4)).insert(any());
        verify(couponGrantLogMapper, times(1)).insert(any(CouponGrantLog.class));
    }
    }
    
  • [ ] 步骤 3:运行测试确认失败

运行:mvn test -Dtest=CouponServiceTest -DfailIfNoTests=false(workdir: /app/cfc/cfc-backend) 预期:编译失败(CouponService 尚无 grant 方法)或测试失败

  • 步骤 4:实现 CouponService 核心方法

修改 apply():

    public Integer apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) {
        UserCoupon uc = userCouponMapper.selectById(userCouponId);
        if (uc == null || !uc.getUserId().equals(userId) || !"AVAILABLE".equals(uc.getStatus())) {
            return null;
        }
        Coupon coupon = couponMapper.selectById(uc.getCouponId());
        if (coupon == null) {
            return null;
        }
        Date now = new Date();
        if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
            return null;
        }
        if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
            return null;
        }
        if (!"ALL".equals(coupon.getApplicableTo()) && !coupon.getApplicableTo().equals(orderType)) {
            return null;
        }
        // 无门槛券(CASH)忽略起用门槛;满减/折扣券校验
        if (!"CASH".equals(coupon.getType()) && coupon.getMinSpend() != null && orderAmount < coupon.getMinSpend()) {
            return null;
        }
        // 返回抵扣金额(调用方以 originalAmount - discount 计算最终价,见 PackagePaymentService/MembershipService)
        if ("DISCOUNT".equals(coupon.getType())) {
            int rate = coupon.getDiscountRate() != null ? coupon.getDiscountRate() : 9000;
            int discount = orderAmount * (10000 - rate) / 10000;
            return Math.max(discount, 0);
        }
        return Math.min(coupon.getValue(), orderAmount);
    }

新增发券与查询方法(放在 issueToUser 之后):

    /**
     * 发放优惠券并落流水(幂等)。
     * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
     */
    @Transactional
    public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
        Coupon coupon = couponMapper.selectById(couponId);
        if (coupon == null) {
            return false;
        }
        if (period != null) {
            Long exists = couponGrantLogMapper.selectCount(
                    new LambdaQueryWrapper<CouponGrantLog>()
                            .eq(CouponGrantLog::getUserId, userId)
                            .eq(CouponGrantLog::getCouponId, couponId)
                            .eq(CouponGrantLog::getGrantType, grantType)
                            .eq(CouponGrantLog::getPeriod, period));
            if (exists != null && exists > 0) {
                return false;
            }
        }
        int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
                ? coupon.getGrantQuantity() : 1;
        for (int i = 0; i < quantity; i++) {
            issueToUser(couponId, userId);
        }
        CouponGrantLog log = new CouponGrantLog();
        log.setUserId(userId);
        log.setCouponId(couponId);
        log.setGrantType(grantType);
        log.setPeriod(period);
        log.setQuantity(quantity);
        log.setSource(source);
        couponGrantLogMapper.insert(log);
        return true;
    }

    /** JOIN: 开通/续费指定等级会员时发放 */
    @Transactional
    public void grantJoinCoupons(Long userId, String levelCode, String source) {
        List<Coupon> coupons = couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>()
                        .eq(Coupon::getGrantType, "JOIN")
                        .eq(Coupon::getGrantLevelCode, levelCode));
        for (Coupon c : coupons) {
            grant(userId, c.getId(), "JOIN", null, source);
        }
    }

    /** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
    @Transactional
    public void grantPopulationCoupons(Long userId, Long memberId) {
        List<Coupon> coupons = couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>()
                        .eq(Coupon::getGrantType, "POPULATION"));
        for (Coupon c : coupons) {
            grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
        }
    }

    /** PERIODIC: 返回所有周期性补发券模板 */
    public List<Coupon> listPeriodicTemplates() {
        return couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "PERIODIC"));
    }

    /** 积分兑换中心:所有可兑换券(points_price>0 且在有效期内) */
    public List<Coupon> listExchangeable() {
        Date now = new Date();
        return couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>()
                        .gt(Coupon::getPointsPrice, 0)
                        .le(Coupon::getValidFrom, now)
                        .ge(Coupon::getValidUntil, now)
                        .orderByAsc(Coupon::getPointsPrice));
    }

    /** 商品详情:绑定该商品的兑换券 */
    public List<Coupon> listByProduct(Long productId) {
        Date now = new Date();
        return couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>()
                        .eq(Coupon::getProductId, productId)
                        .gt(Coupon::getPointsPrice, 0)
                        .le(Coupon::getValidFrom, now)
                        .ge(Coupon::getValidUntil, now)
                        .orderByAsc(Coupon::getPointsPrice));
    }

    /** JOIN 权益展示:所有加入赠券模板 */
    public List<Coupon> listJoinRules() {
        return couponMapper.selectList(
                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "JOIN"));
    }
  • 步骤 5:运行测试确认通过

运行:mvn test -Dtest=CouponServiceTest -DfailIfNoTests=false(workdir: /app/cfc/cfc-backend) 预期:7 个测试全部 PASS

  • 步骤 6:编译 + 全量回归

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 7:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java cfc-backend/src/test/java/com/etotem/cfc/service/CouponServiceTest.java
    git commit -m "feat(backend): CouponService支持三类型核销+幂等发券grant方法与查询"
    

任务 4:JOIN 发券(会员开通/续费/赠送)

文件:

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

  • [ ] 步骤 1:activateMembership() 接入 JOIN 发券

定位 activateMembership(PaymentOrder order, String transactionId, String payMethod),在 memberUpgradeRecordMapper.insert(upgradeRecord); 之后、// 结算佣金(L1+L2) 之前插入:

            // 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
            try {
                couponService.grantJoinCoupons(adminUserId, order.getLevelCode(), order.getOrderNo());
            } catch (Exception e) {
                log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
            }

注意:couponService 已在 MembershipService 中注入(第 50 行),无需新增注入。

  • 步骤 2:grantMembershipByGift() 接入 JOIN 发券

定位 grantMembershipByGift,在 memberUpgradeRecordMapper.insert(record); 之后、return true; 之前插入:

        // 发放JOIN会员券(赠送会员同样享有加入赠券)
        try {
            couponService.grantJoinCoupons(adminUserId, levelCode, sourceOrderNo);
        } catch (Exception e) {
            log.error("发放JOIN会员券异常(赠送): userId={}, levelCode={}", adminUserId, levelCode, e);
        }
  • 步骤 3:确认试用不发券

检查 activateTrialMembership —— 不添加任何发券调用(试用不发 JOIN 券,防零成本刷券)。

  • 步骤 4:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
    git commit -m "feat(backend): 会员开通/续费/赠送时发放JOIN会员券(试用除外)"
    

任务 5:PERIODIC 周期性补发(定时任务)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java

  • [ ] 步骤 1:MembershipService 新增 listActiveMemberUserIds()

在 getMemberLevel 方法后新增:

    /**
     * 查询指定会员等级当前有效的家庭创建者用户ID列表(PERIODIC发券用)
     */
    public List<Long> listActiveMemberUserIds(String levelCode) {
        Date now = new Date();
        List<FamilyMembership> memberships = membershipMapper.selectList(
                new LambdaQueryWrapper<FamilyMembership>()
                        .eq(FamilyMembership::getLevelCode, levelCode)
                        .eq(FamilyMembership::getPaymentStatus, "paid")
                        .gt(FamilyMembership::getEndDate, now));
        List<Long> userIds = new ArrayList<>();
        for (FamilyMembership m : memberships) {
            Family family = familyMapper.selectById(m.getFamilyId());
            if (family != null && family.getCreatorId() != null) {
                userIds.add(family.getCreatorId());
            }
        }
        return userIds.stream().distinct().collect(Collectors.toList());
    }
  • [ ] 步骤 2:创建 CouponGrantTask.java

    package com.etotem.cfc.task;
    
    import com.etotem.cfc.entity.Coupon;
    import com.etotem.cfc.service.CouponService;
    import com.etotem.cfc.service.MembershipService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.Resource;
    import java.time.LocalDate;
    import java.time.format.DateTimeFormatter;
    import java.util.List;
    
    /**
    * 会员优惠券周期性补发任务
    * - 每天 5:00 执行,给有效会员按 grant_period 补发 PERIODIC 券
    */
    @Slf4j
    @Component
    public class CouponGrantTask {
    
    @Resource
    private CouponService couponService;
    
    @Resource
    private MembershipService membershipService;
    
    @Scheduled(cron = "0 0 5 * * ?")
    public void grantPeriodicCoupons() {
        log.info("开始执行周期性会员券补发任务");
        try {
            List<Coupon> templates = couponService.listPeriodicTemplates();
            if (templates.isEmpty()) {
                log.info("无PERIODIC券模板,跳过");
                return;
            }
            LocalDate now = LocalDate.now();
            String monthKey = now.format(DateTimeFormatter.ofPattern("yyyy-MM"));
            int quarter = (now.getMonthValue() - 1) / 3 + 1;
            String quarterKey = now.getYear() + "-Q" + quarter;
    
            for (Coupon c : templates) {
                String periodKey = "QUARTERLY".equals(c.getGrantPeriod()) ? quarterKey : monthKey;
                List<Long> userIds = membershipService.listActiveMemberUserIds(c.getGrantLevelCode());
                int granted = 0;
                for (Long uid : userIds) {
                    if (couponService.grant(uid, c.getId(), "PERIODIC", periodKey, null)) {
                        granted++;
                    }
                }
                log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放用户数={}", 
                        c.getId(), c.getGrantLevelCode(), periodKey, granted);
            }
            log.info("周期性会员券补发任务执行完成");
        } catch (Exception e) {
            log.error("周期性会员券补发任务执行失败", e);
        }
    }
    }
    
  • [ ] 步骤 3:确认 @Scheduled 已启用

检查 application.yml 或配置类中 @EnableScheduling 已存在(MembershipScheduledTasks 已在运行,说明已启用;无需改动)。

  • 步骤 4:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java
    git commit -m "feat(backend): 新增CouponGrantTask周期性补发PERIODIC会员券"
    

任务 6:POPULATION 家庭人口券(新增成员触发)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  • 修改:cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java

  • [ ] 步骤 1:注入 CouponService

在 FamilyMemberService 的 @Resource 区域添加:

    @Resource
    private CouponService couponService;
  • 步骤 2:addMember() 末尾接入 POPULATION 发券

定位 addMember(Long userId, AddFamilyMemberDTO dto) 方法末尾(familyRelationshipService.createRelationsForNewMember(...) 之后、return toFamilyMemberVO(member); 之前)插入:

        // 发放家庭人口券(POPULATION):有账号发到成员自己,无账号发到家庭创建者
        try {
            Long targetUserId = member.getUserId();
            if (targetUserId == null) {
                targetUserId = user.getFamilyId() != null
                        ? familyMapper.selectById(user.getFamilyId()).getCreatorId()
                        : userId;
            }
            couponService.grantPopulationCoupons(targetUserId, member.getId());
        } catch (Exception e) {
            log.error("发放家庭人口券异常: memberId={}", member.getId(), e);
        }

注意:确认 familyMapper 已在 FamilyMemberService 注入;如未注入,补充 @Resource private FamilyMapper familyMapper;。

  • 步骤 3:扩展集成测试

先读 FamilyMemberManagementFlowTest.java 了解现有断言模式(@SpringBootTest + 真实 DB 或 H2)。在现有添加成员流程测试后追加断言(若测试基建不支持则跳过此步骤并记录):

        // 断言:新增成员后 POPULATION 券发放到成员/创建者账户
        // 伪代码——按现有测试基建实现:
        // 1. 查询 coupon_grant_log where source = 'member:' + 新成员ID
        // 2. 断言存在且 grant_type = 'POPULATION'
        // 3. 查询 user_coupon 确认发放数量 = grant_quantity

若测试基建无法运行(无 DB 连接),改为在 mvn clean compile 通过后于步骤 4 说明跳过原因。

  • 步骤 4:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java
    git commit -m "feat(backend): 新增家庭成员时发放POPULATION家庭人口券"
    

任务 7:积分兑换优惠券(后端)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java

  • [ ] 步骤 1:PointsExchangeService 注入依赖 + 实现 exchangeCoupon()

在 PointsExchangeService 的 @Resource 区域添加:

    @Resource
    private CouponMapper couponMapper;

    @Resource
    private CouponService couponService;

    @Resource
    private CouponGrantLogMapper couponGrantLogMapper;

在 exchangeProduct 方法后新增:

    /**
     * 积分兑换优惠券:校验 -> 扣积分 -> 发券 -> 落兑换记录
     */
    @Transactional
    public PointsExchangeRecord exchangeCoupon(Long userId, Long childId, Long couponId) {
        Coupon coupon = couponMapper.selectById(couponId);
        if (coupon == null) {
            throw new RuntimeException("优惠券不存在");
        }
        if (coupon.getPointsPrice() == null || coupon.getPointsPrice() <= 0) {
            throw new RuntimeException("该优惠券不支持积分兑换");
        }
        Date now = new Date();
        if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
            throw new RuntimeException("优惠券尚未开始");
        }
        if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
            throw new RuntimeException("优惠券已过期");
        }

        // 目标家庭成员(复用现有解析逻辑)
        Long targetFamilyMemberId = childId;
        if (targetFamilyMemberId == null) {
            FamilyMember child = familyMemberMapper.selectOne(
                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId).last("LIMIT 1")
            );
            if (child != null) {
                targetFamilyMemberId = child.getId();
            }
        }
        if (targetFamilyMemberId == null) {
            throw new RuntimeException("未找到关联的家庭成员信息");
        }

        // 全局日限次(复用 exchange_daily_limit)
        int dailyLimit = 5;
        try {
            String limitStr = sysConfigService.getValue("exchange_daily_limit");
            if (limitStr != null && !limitStr.isEmpty()) {
                dailyLimit = Integer.parseInt(limitStr);
            }
        } catch (Exception e) {
            dailyLimit = 5;
        }
        Calendar cal = Calendar.getInstance();
        cal.set(Calendar.HOUR_OF_DAY, 0);
        cal.set(Calendar.MINUTE, 0);
        cal.set(Calendar.SECOND, 0);
        cal.set(Calendar.MILLISECOND, 0);
        Date todayStart = cal.getTime();
        long todayCount = pointsExchangeRecordMapper.selectCount(
            new LambdaQueryWrapper<PointsExchangeRecord>()
                .eq(PointsExchangeRecord::getUserId, userId)
                .ge(PointsExchangeRecord::getCreatedAt, todayStart)
        );
        if (todayCount >= dailyLimit) {
            throw new RuntimeException("今日兑换次数已达上限");
        }

        // 单券模板日限次(coupon_exchange_daily_limit 默认1)
        int couponDailyLimit = 1;
        try {
            String cdlStr = sysConfigService.getValue("coupon_exchange_daily_limit");
            if (cdlStr != null && !cdlStr.isEmpty()) {
                couponDailyLimit = Integer.parseInt(cdlStr);
            }
        } catch (Exception e) {
            couponDailyLimit = 1;
        }
        long couponTodayCount = couponGrantLogMapper.selectCount(
            new LambdaQueryWrapper<CouponGrantLog>()
                .eq(CouponGrantLog::getUserId, userId)
                .eq(CouponGrantLog::getCouponId, couponId)
                .eq(CouponGrantLog::getGrantType, "EXCHANGE")
                .ge(CouponGrantLog::getCreatedAt, todayStart)
        );
        if (couponTodayCount >= couponDailyLimit) {
            throw new RuntimeException("该优惠券今日兑换次数已达上限");
        }

        // 最低积分校验
        int minPoints = 100;
        try {
            String minStr = sysConfigService.getValue("exchange_points_min");
            if (minStr != null && !minStr.isEmpty()) {
                minPoints = Integer.parseInt(minStr);
            }
        } catch (Exception e) {
            minPoints = 100;
        }
        if (coupon.getPointsPrice() < minPoints) {
            throw new RuntimeException("兑换积分不得低于最低限制");
        }

        // 扣积分(system_points)
        int deductResult = pointsService.deductSystemPoints(targetFamilyMemberId, coupon.getPointsPrice(),
            "积分兑换优惠券: " + coupon.getName());
        if (deductResult < 0) {
            throw new RuntimeException("积分不足");
        }

        // 发券 + 流水(EXCHANGE,period=null)
        couponService.grant(userId, couponId, "EXCHANGE", null, null);

        // 兑换记录
        String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
        PointsExchangeRecord record = new PointsExchangeRecord();
        record.setUserId(userId);
        record.setFamilyMemberId(targetFamilyMemberId);
        record.setProductId(couponId);
        record.setProductName(coupon.getName());
        record.setPointsCost(coupon.getPointsPrice());
        record.setQuantity(1);
        record.setStatus("completed");
        record.setRedeemCode(redeemCode);
        record.setCreatedAt(new Date());
        record.setUpdatedAt(new Date());
        pointsExchangeRecordMapper.insert(record);
        return record;
    }
  • 步骤 2:CouponController 新增 4 个端点

注入 PointsExchangeService 并新增端点(现有端点 /list、/claim、/apply 不动):

    @Resource
    private PointsExchangeService pointsExchangeService;

    /** 积分兑换优惠券 */
    @PostMapping("/exchange")
    public Result<Map<String, Object>> exchange(@RequestAttribute("userId") Long userId,
                                                @RequestBody Map<String, Object> params) {
        Long couponId = Long.valueOf(params.get("couponId").toString());
        Object memberId = params.get("memberId");
        Long childId = memberId != null ? Long.valueOf(memberId.toString()) : null;
        PointsExchangeRecord record = pointsExchangeService.exchangeCoupon(userId, childId, couponId);
        return Result.success(java.util.Collections.singletonMap("recordId", record.getId()));
    }

    /** 积分兑换中心:可兑换券列表 */
    @PostMapping("/exchangeable")
    public Result<List<Coupon>> exchangeable() {
        return Result.success(couponService.listExchangeable());
    }

    /** 商品详情:绑定该商品的兑换券 */
    @PostMapping("/product")
    public Result<List<Coupon>> productCoupons(@RequestBody Map<String, Object> params) {
        Long productId = Long.valueOf(params.get("productId").toString());
        return Result.success(couponService.listByProduct(productId));
    }

    /** 会员权益展示:JOIN 赠券规则 */
    @PostMapping("/join-rules")
    public Result<List<Coupon>> joinRules() {
        return Result.success(couponService.listJoinRules());
    }
  • 步骤 3:检查路由冲突

运行:

grep -rn '@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep -E 'coupon'

预期:现有 coupon 路由为 /api/coupon/list、/claim、/apply,无 /exchange、/exchangeable、/product、/join-rules 冲突

  • 步骤 4:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java
    git commit -m "feat(backend): 积分兑换优惠券exchangeCoupon+优惠券兑换/查询端点"
    

任务 8:商品统一价(停用商品侧会员价)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

  • [ ] 步骤 1:ProductDTO 统一价

ProductDTO.from(Product p, boolean isGuest, String memberLevel) 中替换(约 98 行):

        // Member-aware pricing: resolve final display price based on membership level
        // Guests (no memberLevel) see base price; members see memberPrice if applicable
        d.price = ActivityDTO.resolveMemberPrice(p.getPrice(), p.getMemberPrice(), memberLevel);

为:

        // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)
        d.price = p.getPrice();
  • 步骤 2:ProductRecommendationService

定位 item.put("memberPrice", p.getMemberPrice());(约 129 行),改为:

        item.put("memberPrice", p.getPrice());
  • 步骤 3:编译验证

运行:mvn clean compile(workdir: /app/cfc/cfc-backend) 预期:BUILD SUCCESS(确认无其他位置因 memberPrice 语义受影响)

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
    git commit -m "feat(backend): 商品统一价停用memberPrice(活动侧保留)"
    

任务 9:管理端(后端端点 + cfc-web 页面)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  • 修改:cfc-web/src/views/admin/CouponManagement.vue
  • 创建:cfc-web/src/views/admin/CouponGrantLog.vue
  • 修改:cfc-web/src/router/index.js
  • 修改:cfc-web/src/views/admin/ProductManage.vue
  • 修改:cfc-web/src/views/admin/ProductEdit.vue

  • [ ] 步骤 1:AdminCouponController 新增 /grant-log 端点

在类末尾新增:

    @Resource
    private CouponGrantLogMapper couponGrantLogMapper;

    /** 发券记录查询 */
    @PostMapping("/grant-log")
    public Result<List<CouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
                                                 @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权限");
        }
        Long userId = params.get("userId") != null ? Long.valueOf(params.get("userId").toString()) : null;
        Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
        LambdaQueryWrapper<CouponGrantLog> wrapper = new LambdaQueryWrapper<>();
        if (userId != null) {
            wrapper.eq(CouponGrantLog::getUserId, userId);
        }
        if (couponId != null) {
            wrapper.eq(CouponGrantLog::getCouponId, couponId);
        }
        wrapper.orderByDesc(CouponGrantLog::getCreatedAt);
        return Result.success(couponGrantLogMapper.selectList(wrapper));
    }

(补充 import:com.etotem.cfc.entity.CouponGrantLog、com.etotem.cfc.mapper.CouponGrantLogMapper、com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper)

  • 步骤 2:CouponManagement.vue 表单扩展

先读现有文件,在表单中新增字段(Element UI el-form-item):

  • type:下拉 FIXED(满减)/ CASH(无门槛)/ DISCOUNT(折扣)
  • discountRate:输入框(折扣率千分比,仅 DISCOUNT 显示)
  • productId:输入框(绑定商品 ID,可留空=全场通用)
  • pointsPrice:输入框(积分兑换价,0=不可兑换)
  • grantType:下拉 JOIN / PERIODIC / POPULATION / 空(不自动发放)
  • grantLevelCode:下拉 FREE / FAMILY / PREMIUM(仅 JOIN/PERIODIC 显示)
  • grantPeriod:下拉 MONTHLY / QUARTERLY(仅 PERIODIC 显示)
  • grantQuantity:数字输入(每次发放数量)

字段名与后端 Coupon 实体一致,提交对象沿用现有 create/update 接口(/api/admin/coupon/create、/update 接收完整 Coupon JSON,无需改后端)。

  • 步骤 3:创建 CouponGrantLog.vue

参照现有 admin 列表页结构(如 CouponManagement.vue 的表格+分页),实现:

  • 查询表单:userId(可选)、couponId(可选)
  • 表格列:id、userId、couponId、grantType、period、quantity、source、createdAt
  • 调 /api/admin/coupon/grant-log

  • [ ] 步骤 4:注册路由

在 cfc-web/src/router/index.js 新增:

{ path: '/admin/coupon-grant-log', component: () => import('@/views/admin/CouponGrantLog.vue'), meta: { role: 'admin' } }

(按现有路由配置风格调整路径与懒加载写法)

  • 步骤 5:ProductManage.vue / ProductEdit.vue 移除会员价输入

搜索 member_price / 会员价 / memberPrice,删除对应表单项/列(保留接口字段,仅 UI 隐藏)。

  • 步骤 6:管理端构建验证

运行:npm run build(workdir: /app/cfc/cfc-web) 预期:构建成功,无编译错误

  • [ ] 步骤 7:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java cfc-web/src/views/admin/CouponManagement.vue cfc-web/src/views/admin/CouponGrantLog.vue cfc-web/src/router/index.js cfc-web/src/views/admin/ProductManage.vue cfc-web/src/views/admin/ProductEdit.vue
    git commit -m "feat(web): 优惠券配置表单扩展+发券记录页+移除商品会员价输入"
    

任务 10:小程序前端(cfc-frontend)

文件:

  • 修改:cfc-frontend/utils/api.js
  • 修改:cfc-frontend/pages/profile-extra/coupons.vue
  • 修改:cfc-frontend/pages/shop/detail/detail.vue
  • 修改:cfc-frontend/pages/family/add-member.vue
  • 修改:cfc-frontend/pages/membership/upgrade.vue

  • [ ] 步骤 1:api.js 新增 API 函数

在现有优惠券 API 附近新增:

export const getExchangeableCoupons = () => request('/api/coupon/exchangeable', 'POST', {})
export const getProductCoupons = (data) => request('/api/coupon/product', 'POST', data)
export const exchangeCoupon = (data) => request('/api/coupon/exchange', 'POST', data)
export const getJoinCouponRules = () => request('/api/coupon/join-rules', 'POST', {})
  • 步骤 2:coupons.vue 加「可兑换」tab(兑换中心入口)

先读现有 pages/profile-extra/coupons.vue,在其 tab 结构(若已有「我的券」)基础上新增「可兑换」tab,或新增兑换区块:

<!-- 可兑换券列表 -->
<view v-if="activeTab === 'exchangeable'">
  <view class="coupon-card" v-for="(item, index) in exchangeableList" :key="getCouponKey(item, index)">
    <view class="coupon-main">
      <view class="coupon-value">{{ item.pointsPrice }}积分</view>
      <view class="coupon-name">{{ item.name }}</view>
      <view class="coupon-desc">{{ getCouponDesc(item) }}</view>
    </view>
    <button class="exchange-btn" @tap="doExchange(item)">兑换</button>
  </view>
  <view v-if="exchangeableList.length === 0" class="empty-tip">暂无可用兑换的优惠券</view>
</view>

脚本部分:

data() {
  return {
    activeTab: 'mine',          // mine=我的券, exchangeable=可兑换
    exchangeableList: [],
  }
},
onShow() {
  this.loadCoupons()
  this.loadExchangeable()
},
methods: {
  loadExchangeable() {
    getExchangeableCoupons().then(res => {
      if (res.code === 200) {
        this.exchangeableList = res.data || []
      }
    })
  },
  doExchange(item) {
    uni.showModal({
      title: '确认兑换',
      content: '消耗 ' + item.pointsPrice + ' 积分兑换「' + item.name + '」?',
      success: (r) => {
        if (r.confirm) {
          exchangeCoupon({ couponId: item.id }).then(res => {
            if (res.code === 200) {
              uni.showToast({ title: '兑换成功', icon: 'success' })
              this.loadExchangeable()
              this.loadCoupons()
            } else {
              uni.showToast({ title: res.message || '兑换失败', icon: 'none' })
            }
          })
        }
      }
    })
  },
}

注意:小程序禁止 :key 表达式与可选链,getCouponKey(item, index) 为方法调用返回 item.id + '-' + index;res.code === 200 用 === 而非可选链。核对现有页面的 loadCoupons() 方法名与结构,复用其加载方式。

  • 步骤 3:商品详情 detail.vue 加「积分兑换优惠券」区块

先读现有 pages/shop/detail/detail.vue,在价格/购买区域附近新增:

<!-- 积分兑换优惠券 -->
<view class="coupon-exchange-section" v-if="productCoupons.length > 0">
  <view class="section-title">积分兑换优惠券</view>
  <view class="coupon-item" v-for="(item, index) in productCoupons" :key="getCouponKey(item, index)">
    <view class="coupon-info">
      <view class="coupon-name">{{ item.name }}</view>
      <view class="coupon-desc">{{ getCouponDesc(item) }}</view>
    </view>
    <view class="coupon-price">{{ item.pointsPrice }}积分</view>
    <button class="coupon-exchange-btn" @tap="doExchangeCoupon(item)">兑换</button>
  </view>
</view>

脚本部分(在 onLoad/数据加载处调用):

loadProductCoupons() {
  const productId = this.product && this.product.id
  if (!productId) return
  getProductCoupons({ productId: productId }).then(res => {
    if (res.code === 200) {
      this.productCoupons = res.data || []
    }
  })
},
  • 步骤 4:add-member.vue 添加成功提示

先读 pages/family/add-member.vue,在添加成员成功回调处追加(在现有成功 toast 之后):

// 提示家庭人口券发放(后端已自动发放,此处仅提示)
uni.showToast({ title: '成员添加成功,已发放优惠券', icon: 'none' })

(若页面已有成功 toast 逻辑,改为合并文案或在 toast 后追加提示)

  • 步骤 5:upgrade.vue 会员权益展示 JOIN 券规则

先读 pages/membership/upgrade.vue,在等级卡片/权益区新增(onShow 时调用):

loadJoinRules() {
  getJoinCouponRules().then(res => {
    if (res.code === 200) {
      const rules = res.data || []
      this.joinRules = rules
    }
  })
},

模板中按 grantLevelCode 分组展示:「加入 {levelName} 会员赠送 {grantQuantity} 张 {券名}」;getLevelName(code) 映射 FREE/FAMILY/PREMIUM → 免费版/家庭会员/高级会员。

  • 步骤 6:小程序构建验证

运行:npm run build:mp-weixin(workdir: /app/cfc/cfc-frontend;若构建脚本不同以 package.json 为准) 预期:构建成功

  • [ ] 步骤 7:Commit

    git add cfc-frontend/utils/api.js cfc-frontend/pages/profile-extra/coupons.vue cfc-frontend/pages/shop/detail/detail.vue cfc-frontend/pages/family/add-member.vue cfc-frontend/pages/membership/upgrade.vue
    git commit -m "feat(miniapp): 优惠券积分兑换入口+商品详情兑换区块+发券提示+会员权益展示"
    

验证清单(全部完成后执行)

  • cd cfc-backend && mvn clean compile — BUILD SUCCESS
  • cd cfc-backend && mvn test -Dtest=CouponServiceTest — 8 tests PASS
  • cd cfc-web && npm run build — 构建成功
  • cd cfc-frontend && npm run build:mp-weixin — 构建成功
  • 路由冲突检查:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u 中 coupon 相关无重复
  • git log --oneline -12 确认 10 个任务 commit 齐全

对规格的偏差说明

  1. 不新增 coupon_type 列:现有 coupon.type 字段已是券类型(默认 FIXED 且未参与逻辑),复用为 FIXED/CASH/DISCOUNT,避免双类型列冗余(规格 4.1 原列 8 个,实为 7 个)。
  2. 兑换校验无 status 字段:coupon 表无上架状态列,兑换校验改用 points_price > 0 + 有效期。
  3. 小程序兑换中心入口:小程序不存在积分兑换中心页面(API 已定义但无页面调用),以 coupons.vue「可兑换」tab 承担兑换中心入口。
  4. apply() 折扣语义修正(执行前审查发现):原计划 DISCOUNT 公式 orderAmount * rate / 10000 返回折后价,与现有调用方语义冲突(PackagePaymentService:91、MembershipService:321 均按 originalAmount - discount 使用返回值)。修正为返回抵扣额:orderAmount * (10000 - rate) / 10000;同步修正 3 个单元测试期望值(CASH 500→300、DISCOUNT 2700→300、cap 测试改为 FIXED 值超订单场景 + 新增小订单折扣测试),测试数 7→8。