|
|
@@ -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。
|