# 会员体系重构:统一定价 + 优惠券发放/积分兑换 实现计划 > **面向 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 { } ``` - [ ] **步骤 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() .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 coupons = couponMapper.selectList( new LambdaQueryWrapper() .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 coupons = couponMapper.selectList( new LambdaQueryWrapper() .eq(Coupon::getGrantType, "POPULATION")); for (Coupon c : coupons) { grant(userId, c.getId(), "POPULATION", null, "member:" + memberId); } } /** PERIODIC: 返回所有周期性补发券模板 */ public List listPeriodicTemplates() { return couponMapper.selectList( new LambdaQueryWrapper().eq(Coupon::getGrantType, "PERIODIC")); } /** 积分兑换中心:所有可兑换券(points_price>0 且在有效期内) */ public List listExchangeable() { Date now = new Date(); return couponMapper.selectList( new LambdaQueryWrapper() .gt(Coupon::getPointsPrice, 0) .le(Coupon::getValidFrom, now) .ge(Coupon::getValidUntil, now) .orderByAsc(Coupon::getPointsPrice)); } /** 商品详情:绑定该商品的兑换券 */ public List listByProduct(Long productId) { Date now = new Date(); return couponMapper.selectList( new LambdaQueryWrapper() .eq(Coupon::getProductId, productId) .gt(Coupon::getPointsPrice, 0) .le(Coupon::getValidFrom, now) .ge(Coupon::getValidUntil, now) .orderByAsc(Coupon::getPointsPrice)); } /** JOIN 权益展示:所有加入赠券模板 */ public List listJoinRules() { return couponMapper.selectList( new LambdaQueryWrapper().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 listActiveMemberUserIds(String levelCode) { Date now = new Date(); List memberships = membershipMapper.selectList( new LambdaQueryWrapper() .eq(FamilyMembership::getLevelCode, levelCode) .eq(FamilyMembership::getPaymentStatus, "paid") .gt(FamilyMembership::getEndDate, now)); List 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 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 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().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() .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() .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> exchange(@RequestAttribute("userId") Long userId, @RequestBody Map 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> exchangeable() { return Result.success(couponService.listExchangeable()); } /** 商品详情:绑定该商品的兑换券 */ @PostMapping("/product") public Result> productCoupons(@RequestBody Map params) { Long productId = Long.valueOf(params.get("productId").toString()); return Result.success(couponService.listByProduct(productId)); } /** 会员权益展示:JOIN 赠券规则 */ @PostMapping("/join-rules") public Result> 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> grantLog(@RequestBody Map 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 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 {{ item.pointsPrice }}积分 {{ item.name }} {{ getCouponDesc(item) }} 暂无可用兑换的优惠券 ``` 脚本部分: ```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 积分兑换优惠券 {{ item.name }} {{ getCouponDesc(item) }} {{ item.pointsPrice }}积分 ``` 脚本部分(在 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。