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

docs(plan): 会员体系重构实现计划(修正apply折扣语义+8个单测)

Xiaogang Liao 1 месяц назад
Родитель
Сommit
15bc908191
1 измененных файлов с 1290 добавлено и 0 удалено
  1. 1290 0
      docs/superpowers/plans/2026-08-05-membership-coupon-system-redesign.md

+ 1290 - 0
docs/superpowers/plans/2026-08-05-membership-coupon-system-redesign.md

@@ -0,0 +1,1290 @@
+# 会员体系重构:统一定价 + 优惠券发放/积分兑换 实现计划
+
+> **面向 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` 确认最新编号)。追加:
+
+```java
+// 迁移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 列:
+
+```sql
+    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**
+
+```bash
+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` 字段复用,不新增):
+
+```java
+    /** 折扣券折扣率(千分比, 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**
+
+```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**
+
+```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**
+
+```bash
+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**
+
+```java
+    @Resource
+    private CouponGrantLogMapper couponGrantLogMapper;
+```
+
+- [ ] **步骤 2:编写失败的单元测试 CouponServiceTest.java**
+
+```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()`:
+
+```java
+    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` 之后):
+
+```java
+    /**
+     * 发放优惠券并落流水(幂等)。
+     * 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**
+
+```bash
+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)` 之前插入:
+
+```java
+            // 发放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;` 之前插入:
+
+```java
+        // 发放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**
+
+```bash
+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` 方法后新增:
+
+```java
+    /**
+     * 查询指定会员等级当前有效的家庭创建者用户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**
+
+```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**
+
+```bash
+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` 区域添加:
+
+```java
+    @Resource
+    private CouponService couponService;
+```
+
+- [ ] **步骤 2:addMember() 末尾接入 POPULATION 发券**
+
+定位 `addMember(Long userId, AddFamilyMemberDTO dto)` 方法末尾(`familyRelationshipService.createRelationsForNewMember(...)` 之后、`return toFamilyMemberVO(member);` 之前)插入:
+
+```java
+        // 发放家庭人口券(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)。在现有添加成员流程测试后追加断言(若测试基建不支持则跳过此步骤并记录):
+
+```java
+        // 断言:新增成员后 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**
+
+```bash
+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` 区域添加:
+
+```java
+    @Resource
+    private CouponMapper couponMapper;
+
+    @Resource
+    private CouponService couponService;
+
+    @Resource
+    private CouponGrantLogMapper couponGrantLogMapper;
+```
+
+在 `exchangeProduct` 方法后新增:
+
+```java
+    /**
+     * 积分兑换优惠券:校验 -> 扣积分 -> 发券 -> 落兑换记录
+     */
+    @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 不动):
+
+```java
+    @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:检查路由冲突**
+
+运行:
+```bash
+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**
+
+```bash
+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 行):
+
+```java
+        // 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);
+```
+
+为:
+
+```java
+        // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)
+        d.price = p.getPrice();
+```
+
+- [ ] **步骤 2:ProductRecommendationService**
+
+定位 `item.put("memberPrice", p.getMemberPrice());`(约 129 行),改为:
+
+```java
+        item.put("memberPrice", p.getPrice());
+```
+
+- [ ] **步骤 3:编译验证**
+
+运行:`mvn clean compile`(workdir: `/app/cfc/cfc-backend`)
+预期:BUILD SUCCESS(确认无其他位置因 memberPrice 语义受影响)
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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 端点**
+
+在类末尾新增:
+
+```java
+    @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` 新增:
+
+```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**
+
+```bash
+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 附近新增:
+
+```js
+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,或新增兑换区块:
+
+```html
+<!-- 可兑换券列表 -->
+<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>
+```
+
+脚本部分:
+
+```js
+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`,在价格/购买区域附近新增:
+
+```html
+<!-- 积分兑换优惠券 -->
+<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/数据加载处调用):
+
+```js
+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 之后):
+
+```js
+// 提示家庭人口券发放(后端已自动发放,此处仅提示)
+uni.showToast({ title: '成员添加成功,已发放优惠券', icon: 'none' })
+```
+
+(若页面已有成功 toast 逻辑,改为合并文案或在 toast 后追加提示)
+
+- [ ] **步骤 5:upgrade.vue 会员权益展示 JOIN 券规则**
+
+先读 `pages/membership/upgrade.vue`,在等级卡片/权益区新增(onShow 时调用):
+
+```js
+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**
+
+```bash
+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。