面向 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
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 发券断言 |
| 文件 | 操作 | 职责 |
|---|---|---|
src/views/admin/CouponManagement.vue |
修改 | 表单扩展(类型/折扣率/绑定商品/积分价/发放规则) |
src/views/admin/CouponGrantLog.vue |
创建 | 发券记录查询页 |
src/router/index.js |
修改 | 注册发券记录路由 |
src/views/admin/ProductManage.vue |
修改 | 移除会员价输入 |
src/views/admin/ProductEdit.vue |
修改 | 移除会员价输入 |
| 文件 | 操作 | 职责 |
|---|---|---|
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 券规则 |
文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java修改:cfc-backend/src/main/resources/schema.sql
[ ] 步骤 1:在 DatabaseInitializer 的 runMigrations() 末尾追加迁移 164
先定位文件末尾(搜索 // 迁移163 确认最新编号)。追加:
// 迁移164: coupon表扩展会员券字段 + 创建coupon_grant_log表(会员体系重构: 统一价+优惠券发放/积分兑换)
try {
ensureColumn("coupon", "discount_rate", "INT COMMENT '折扣券折扣率(千分比,9000=9折)'");
ensureColumn("coupon", "product_id", "BIGINT COMMENT '绑定商品ID,NULL=全场通用'");
ensureColumn("coupon", "points_price", "INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换'");
ensureColumn("coupon", "grant_type", "VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放'");
ensureColumn("coupon", "grant_level_code", "VARCHAR(32) COMMENT '发放规则对应会员等级'");
ensureColumn("coupon", "grant_period", "VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY'");
ensureColumn("coupon", "grant_quantity", "INT DEFAULT 1 COMMENT '每次发放数量'");
log.info("已为coupon表添加会员券字段");
} catch (Exception e) {
log.warn("添加coupon表会员券字段失败: {}", e.getMessage());
}
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS coupon_grant_log (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"user_id BIGINT NOT NULL COMMENT '收券用户ID', " +
"coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
"grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE', " +
"period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
"quantity INT DEFAULT 1 COMMENT '发放数量', " +
"source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"UNIQUE KEY uk_grant (user_id, coupon_id, grant_type, period), " +
"INDEX idx_coupon (coupon_id), " +
"INDEX idx_user (user_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表'");
log.info("已创建coupon_grant_log表");
} catch (Exception e) {
log.warn("创建coupon_grant_log表失败: {}", e.getMessage());
}
注意:先读文件确认 ensureColumn 辅助方法的确切签名(private void ensureColumn(String table, String column, String definition)),如不符则用现有 try { jdbcTemplate.execute("ALTER TABLE ... ADD COLUMN ...") } catch { } 标准模式。
在 coupon 的 CREATE TABLE(约 1910 行)中加入 7 列:
discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
grant_period VARCHAR(16) COMMENT 'PERIODIC周期:MONTHLY/QUARTERLY',
grant_quantity INT DEFAULT 1 COMMENT '每次发放数量',
在文件末尾追加 coupon_grant_log 建表语句(与迁移 164 中一致)。
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
git commit -m "feat(backend): 迁移164 coupon表扩展会员券字段+新建coupon_grant_log发券流水表"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.javacfc-backend/src/main/java/com/etotem/cfc/entity/CouponGrantLog.java创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/CouponGrantLogMapper.java
[ ] 步骤 1:Coupon.java 加字段
在 private Integer minSpend; 后追加(注意 type 字段复用,不新增):
/** 折扣券折扣率(千分比, 9000=9折) */
private Integer discountRate;
/** 绑定商品ID, NULL=全场通用 */
private Long productId;
/** 积分兑换价, 0=不可积分兑换 */
private Integer pointsPrice;
/** 发放规则: JOIN/PERIODIC/POPULATION, NULL=不自动发放 */
private String grantType;
/** 发放规则对应会员等级(FREE/FAMILY/PREMIUM) */
private String grantLevelCode;
/** PERIODIC周期: MONTHLY/QUARTERLY */
private String grantPeriod;
/** 每次发放数量 */
private Integer grantQuantity;
[ ] 步骤 2:创建 CouponGrantLog.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("coupon_grant_log")
public class CouponGrantLog implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long couponId;
/** JOIN/PERIODIC/POPULATION/EXCHANGE */
private String grantType;
/** 周期标识(YYYY-MM或YYYY-Qn), PERIODIC防重用 */
private String period;
private Integer quantity;
/** 触发来源(订单号/成员ID等) */
private String source;
private Date createdAt;
}
[ ] 步骤 3:创建 CouponGrantLogMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.CouponGrantLog;
public interface CouponGrantLogMapper extends BaseMapper<CouponGrantLog> {
}
[ ] 步骤 4:编译验证
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java cfc-backend/src/main/java/com/etotem/cfc/entity/CouponGrantLog.java cfc-backend/src/main/java/com/etotem/cfc/mapper/CouponGrantLogMapper.java
git commit -m "feat(backend): Coupon实体扩展会员券字段+新增CouponGrantLog实体与Mapper"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java创建:cfc-backend/src/test/java/com/etotem/cfc/service/CouponServiceTest.java
[ ] 步骤 1:注入 CouponGrantLogMapper
@Resource
private CouponGrantLogMapper couponGrantLogMapper;
[ ] 步骤 2:编写失败的单元测试 CouponServiceTest.java
package com.etotem.cfc.service;
import com.etotem.cfc.entity.Coupon;
import com.etotem.cfc.entity.CouponGrantLog;
import com.etotem.cfc.entity.UserCoupon;
import com.etotem.cfc.mapper.CouponGrantLogMapper;
import com.etotem.cfc.mapper.CouponMapper;
import com.etotem.cfc.mapper.UserCouponMapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.Date;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.class)
public class CouponServiceTest {
@Mock
private CouponMapper couponMapper;
@Mock
private UserCouponMapper userCouponMapper;
@Mock
private CouponGrantLogMapper couponGrantLogMapper;
@InjectMocks
private CouponService couponService;
private UserCoupon userCoupon(Long id, Long userId, Long couponId) {
UserCoupon uc = new UserCoupon();
uc.setId(id);
uc.setUserId(userId);
uc.setCouponId(couponId);
uc.setStatus("AVAILABLE");
return uc;
}
private Coupon coupon(String type, Integer value, Integer minSpend, Integer discountRate) {
Coupon c = new Coupon();
c.setType(type);
c.setValue(value);
c.setMinSpend(minSpend);
c.setDiscountRate(discountRate);
c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L));
c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L));
c.setApplicableTo("ALL");
return c;
}
@Test
public void applyFixedEnforcesMinSpend() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
// 订单金额1000分 < 起用门槛2000分 -> 不可用
assertNull(couponService.apply(100L, 1L, "ALL", 1000));
}
@Test
public void applyFixedWorksAboveMinSpend() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
assertEquals(Integer.valueOf(1000), couponService.apply(100L, 1L, "ALL", 3000));
}
@Test
public void applyFixedCappedByOrderAmount() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 500, 0, null));
// 满减券抵扣不能超过订单金额
assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
}
@Test
public void applyCashIgnoresMinSpend() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("CASH", 500, 1000, null));
// 无门槛券(CASH)忽略min_spend:订单300 < 配置门槛1000 仍可用,抵扣300(不超订单)
assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
}
@Test
public void applyDiscountComputesRate() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
// 订单3000分 9折(9000) -> 抵扣 3000*(10000-9000)/10000=300,实付2700
assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 3000));
}
@Test
public void applyDiscountSmallOrder() {
when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
// 小订单100分 9折 -> 抵扣10,实付90
assertEquals(Integer.valueOf(10), couponService.apply(100L, 1L, "ALL", 100));
}
@Test
public void grantSkipsExistingPeriod() {
when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 0, null));
when(couponGrantLogMapper.selectCount(any())).thenReturn(1L);
boolean granted = couponService.grant(100L, 1L, "PERIODIC", "2026-08", null);
org.junit.Assert.assertFalse(granted);
verify(userCouponMapper, never()).insert(any());
}
@Test
public void grantIssuesQuantityAndLogs() {
Coupon c = coupon("FIXED", 1000, 0, null);
c.setGrantQuantity(4);
when(couponMapper.selectById(1L)).thenReturn(c);
when(couponGrantLogMapper.selectCount(any())).thenReturn(0L);
boolean granted = couponService.grant(100L, 1L, "JOIN", null, "ORD123");
org.junit.Assert.assertTrue(granted);
verify(userCouponMapper, times(4)).insert(any());
verify(couponGrantLogMapper, times(1)).insert(any(CouponGrantLog.class));
}
}
[ ] 步骤 3:运行测试确认失败
运行:mvn test -Dtest=CouponServiceTest -DfailIfNoTests=false(workdir: /app/cfc/cfc-backend)
预期:编译失败(CouponService 尚无 grant 方法)或测试失败
修改 apply():
public Integer apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) {
UserCoupon uc = userCouponMapper.selectById(userCouponId);
if (uc == null || !uc.getUserId().equals(userId) || !"AVAILABLE".equals(uc.getStatus())) {
return null;
}
Coupon coupon = couponMapper.selectById(uc.getCouponId());
if (coupon == null) {
return null;
}
Date now = new Date();
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
return null;
}
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
return null;
}
if (!"ALL".equals(coupon.getApplicableTo()) && !coupon.getApplicableTo().equals(orderType)) {
return null;
}
// 无门槛券(CASH)忽略起用门槛;满减/折扣券校验
if (!"CASH".equals(coupon.getType()) && coupon.getMinSpend() != null && orderAmount < coupon.getMinSpend()) {
return null;
}
// 返回抵扣金额(调用方以 originalAmount - discount 计算最终价,见 PackagePaymentService/MembershipService)
if ("DISCOUNT".equals(coupon.getType())) {
int rate = coupon.getDiscountRate() != null ? coupon.getDiscountRate() : 9000;
int discount = orderAmount * (10000 - rate) / 10000;
return Math.max(discount, 0);
}
return Math.min(coupon.getValue(), orderAmount);
}
新增发券与查询方法(放在 issueToUser 之后):
/**
* 发放优惠券并落流水(幂等)。
* period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
*/
@Transactional
public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
Coupon coupon = couponMapper.selectById(couponId);
if (coupon == null) {
return false;
}
if (period != null) {
Long exists = couponGrantLogMapper.selectCount(
new LambdaQueryWrapper<CouponGrantLog>()
.eq(CouponGrantLog::getUserId, userId)
.eq(CouponGrantLog::getCouponId, couponId)
.eq(CouponGrantLog::getGrantType, grantType)
.eq(CouponGrantLog::getPeriod, period));
if (exists != null && exists > 0) {
return false;
}
}
int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
? coupon.getGrantQuantity() : 1;
for (int i = 0; i < quantity; i++) {
issueToUser(couponId, userId);
}
CouponGrantLog log = new CouponGrantLog();
log.setUserId(userId);
log.setCouponId(couponId);
log.setGrantType(grantType);
log.setPeriod(period);
log.setQuantity(quantity);
log.setSource(source);
couponGrantLogMapper.insert(log);
return true;
}
/** JOIN: 开通/续费指定等级会员时发放 */
@Transactional
public void grantJoinCoupons(Long userId, String levelCode, String source) {
List<Coupon> coupons = couponMapper.selectList(
new LambdaQueryWrapper<Coupon>()
.eq(Coupon::getGrantType, "JOIN")
.eq(Coupon::getGrantLevelCode, levelCode));
for (Coupon c : coupons) {
grant(userId, c.getId(), "JOIN", null, source);
}
}
/** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
@Transactional
public void grantPopulationCoupons(Long userId, Long memberId) {
List<Coupon> coupons = couponMapper.selectList(
new LambdaQueryWrapper<Coupon>()
.eq(Coupon::getGrantType, "POPULATION"));
for (Coupon c : coupons) {
grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
}
}
/** PERIODIC: 返回所有周期性补发券模板 */
public List<Coupon> listPeriodicTemplates() {
return couponMapper.selectList(
new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "PERIODIC"));
}
/** 积分兑换中心:所有可兑换券(points_price>0 且在有效期内) */
public List<Coupon> listExchangeable() {
Date now = new Date();
return couponMapper.selectList(
new LambdaQueryWrapper<Coupon>()
.gt(Coupon::getPointsPrice, 0)
.le(Coupon::getValidFrom, now)
.ge(Coupon::getValidUntil, now)
.orderByAsc(Coupon::getPointsPrice));
}
/** 商品详情:绑定该商品的兑换券 */
public List<Coupon> listByProduct(Long productId) {
Date now = new Date();
return couponMapper.selectList(
new LambdaQueryWrapper<Coupon>()
.eq(Coupon::getProductId, productId)
.gt(Coupon::getPointsPrice, 0)
.le(Coupon::getValidFrom, now)
.ge(Coupon::getValidUntil, now)
.orderByAsc(Coupon::getPointsPrice));
}
/** JOIN 权益展示:所有加入赠券模板 */
public List<Coupon> listJoinRules() {
return couponMapper.selectList(
new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "JOIN"));
}
运行:mvn test -Dtest=CouponServiceTest -DfailIfNoTests=false(workdir: /app/cfc/cfc-backend)
预期:7 个测试全部 PASS
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java cfc-backend/src/test/java/com/etotem/cfc/service/CouponServiceTest.java
git commit -m "feat(backend): CouponService支持三类型核销+幂等发券grant方法与查询"
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
[ ] 步骤 1:activateMembership() 接入 JOIN 发券
定位 activateMembership(PaymentOrder order, String transactionId, String payMethod),在 memberUpgradeRecordMapper.insert(upgradeRecord); 之后、// 结算佣金(L1+L2) 之前插入:
// 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
try {
couponService.grantJoinCoupons(adminUserId, order.getLevelCode(), order.getOrderNo());
} catch (Exception e) {
log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
}
注意:couponService 已在 MembershipService 中注入(第 50 行),无需新增注入。
定位 grantMembershipByGift,在 memberUpgradeRecordMapper.insert(record); 之后、return true; 之前插入:
// 发放JOIN会员券(赠送会员同样享有加入赠券)
try {
couponService.grantJoinCoupons(adminUserId, levelCode, sourceOrderNo);
} catch (Exception e) {
log.error("发放JOIN会员券异常(赠送): userId={}, levelCode={}", adminUserId, levelCode, e);
}
检查 activateTrialMembership —— 不添加任何发券调用(试用不发 JOIN 券,防零成本刷券)。
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
git commit -m "feat(backend): 会员开通/续费/赠送时发放JOIN会员券(试用除外)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java创建:cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java
[ ] 步骤 1:MembershipService 新增 listActiveMemberUserIds()
在 getMemberLevel 方法后新增:
/**
* 查询指定会员等级当前有效的家庭创建者用户ID列表(PERIODIC发券用)
*/
public List<Long> listActiveMemberUserIds(String levelCode) {
Date now = new Date();
List<FamilyMembership> memberships = membershipMapper.selectList(
new LambdaQueryWrapper<FamilyMembership>()
.eq(FamilyMembership::getLevelCode, levelCode)
.eq(FamilyMembership::getPaymentStatus, "paid")
.gt(FamilyMembership::getEndDate, now));
List<Long> userIds = new ArrayList<>();
for (FamilyMembership m : memberships) {
Family family = familyMapper.selectById(m.getFamilyId());
if (family != null && family.getCreatorId() != null) {
userIds.add(family.getCreatorId());
}
}
return userIds.stream().distinct().collect(Collectors.toList());
}
[ ] 步骤 2:创建 CouponGrantTask.java
package com.etotem.cfc.task;
import com.etotem.cfc.entity.Coupon;
import com.etotem.cfc.service.CouponService;
import com.etotem.cfc.service.MembershipService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 会员优惠券周期性补发任务
* - 每天 5:00 执行,给有效会员按 grant_period 补发 PERIODIC 券
*/
@Slf4j
@Component
public class CouponGrantTask {
@Resource
private CouponService couponService;
@Resource
private MembershipService membershipService;
@Scheduled(cron = "0 0 5 * * ?")
public void grantPeriodicCoupons() {
log.info("开始执行周期性会员券补发任务");
try {
List<Coupon> templates = couponService.listPeriodicTemplates();
if (templates.isEmpty()) {
log.info("无PERIODIC券模板,跳过");
return;
}
LocalDate now = LocalDate.now();
String monthKey = now.format(DateTimeFormatter.ofPattern("yyyy-MM"));
int quarter = (now.getMonthValue() - 1) / 3 + 1;
String quarterKey = now.getYear() + "-Q" + quarter;
for (Coupon c : templates) {
String periodKey = "QUARTERLY".equals(c.getGrantPeriod()) ? quarterKey : monthKey;
List<Long> userIds = membershipService.listActiveMemberUserIds(c.getGrantLevelCode());
int granted = 0;
for (Long uid : userIds) {
if (couponService.grant(uid, c.getId(), "PERIODIC", periodKey, null)) {
granted++;
}
}
log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放用户数={}",
c.getId(), c.getGrantLevelCode(), periodKey, granted);
}
log.info("周期性会员券补发任务执行完成");
} catch (Exception e) {
log.error("周期性会员券补发任务执行失败", e);
}
}
}
[ ] 步骤 3:确认 @Scheduled 已启用
检查 application.yml 或配置类中 @EnableScheduling 已存在(MembershipScheduledTasks 已在运行,说明已启用;无需改动)。
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java
git commit -m "feat(backend): 新增CouponGrantTask周期性补发PERIODIC会员券"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java修改:cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java
[ ] 步骤 1:注入 CouponService
在 FamilyMemberService 的 @Resource 区域添加:
@Resource
private CouponService couponService;
定位 addMember(Long userId, AddFamilyMemberDTO dto) 方法末尾(familyRelationshipService.createRelationsForNewMember(...) 之后、return toFamilyMemberVO(member); 之前)插入:
// 发放家庭人口券(POPULATION):有账号发到成员自己,无账号发到家庭创建者
try {
Long targetUserId = member.getUserId();
if (targetUserId == null) {
targetUserId = user.getFamilyId() != null
? familyMapper.selectById(user.getFamilyId()).getCreatorId()
: userId;
}
couponService.grantPopulationCoupons(targetUserId, member.getId());
} catch (Exception e) {
log.error("发放家庭人口券异常: memberId={}", member.getId(), e);
}
注意:确认 familyMapper 已在 FamilyMemberService 注入;如未注入,补充 @Resource private FamilyMapper familyMapper;。
先读 FamilyMemberManagementFlowTest.java 了解现有断言模式(@SpringBootTest + 真实 DB 或 H2)。在现有添加成员流程测试后追加断言(若测试基建不支持则跳过此步骤并记录):
// 断言:新增成员后 POPULATION 券发放到成员/创建者账户
// 伪代码——按现有测试基建实现:
// 1. 查询 coupon_grant_log where source = 'member:' + 新成员ID
// 2. 断言存在且 grant_type = 'POPULATION'
// 3. 查询 user_coupon 确认发放数量 = grant_quantity
若测试基建无法运行(无 DB 连接),改为在 mvn clean compile 通过后于步骤 4 说明跳过原因。
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java
git commit -m "feat(backend): 新增家庭成员时发放POPULATION家庭人口券"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java修改:cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java
[ ] 步骤 1:PointsExchangeService 注入依赖 + 实现 exchangeCoupon()
在 PointsExchangeService 的 @Resource 区域添加:
@Resource
private CouponMapper couponMapper;
@Resource
private CouponService couponService;
@Resource
private CouponGrantLogMapper couponGrantLogMapper;
在 exchangeProduct 方法后新增:
/**
* 积分兑换优惠券:校验 -> 扣积分 -> 发券 -> 落兑换记录
*/
@Transactional
public PointsExchangeRecord exchangeCoupon(Long userId, Long childId, Long couponId) {
Coupon coupon = couponMapper.selectById(couponId);
if (coupon == null) {
throw new RuntimeException("优惠券不存在");
}
if (coupon.getPointsPrice() == null || coupon.getPointsPrice() <= 0) {
throw new RuntimeException("该优惠券不支持积分兑换");
}
Date now = new Date();
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
throw new RuntimeException("优惠券尚未开始");
}
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
throw new RuntimeException("优惠券已过期");
}
// 目标家庭成员(复用现有解析逻辑)
Long targetFamilyMemberId = childId;
if (targetFamilyMemberId == null) {
FamilyMember child = familyMemberMapper.selectOne(
new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId).last("LIMIT 1")
);
if (child != null) {
targetFamilyMemberId = child.getId();
}
}
if (targetFamilyMemberId == null) {
throw new RuntimeException("未找到关联的家庭成员信息");
}
// 全局日限次(复用 exchange_daily_limit)
int dailyLimit = 5;
try {
String limitStr = sysConfigService.getValue("exchange_daily_limit");
if (limitStr != null && !limitStr.isEmpty()) {
dailyLimit = Integer.parseInt(limitStr);
}
} catch (Exception e) {
dailyLimit = 5;
}
Calendar cal = Calendar.getInstance();
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
Date todayStart = cal.getTime();
long todayCount = pointsExchangeRecordMapper.selectCount(
new LambdaQueryWrapper<PointsExchangeRecord>()
.eq(PointsExchangeRecord::getUserId, userId)
.ge(PointsExchangeRecord::getCreatedAt, todayStart)
);
if (todayCount >= dailyLimit) {
throw new RuntimeException("今日兑换次数已达上限");
}
// 单券模板日限次(coupon_exchange_daily_limit 默认1)
int couponDailyLimit = 1;
try {
String cdlStr = sysConfigService.getValue("coupon_exchange_daily_limit");
if (cdlStr != null && !cdlStr.isEmpty()) {
couponDailyLimit = Integer.parseInt(cdlStr);
}
} catch (Exception e) {
couponDailyLimit = 1;
}
long couponTodayCount = couponGrantLogMapper.selectCount(
new LambdaQueryWrapper<CouponGrantLog>()
.eq(CouponGrantLog::getUserId, userId)
.eq(CouponGrantLog::getCouponId, couponId)
.eq(CouponGrantLog::getGrantType, "EXCHANGE")
.ge(CouponGrantLog::getCreatedAt, todayStart)
);
if (couponTodayCount >= couponDailyLimit) {
throw new RuntimeException("该优惠券今日兑换次数已达上限");
}
// 最低积分校验
int minPoints = 100;
try {
String minStr = sysConfigService.getValue("exchange_points_min");
if (minStr != null && !minStr.isEmpty()) {
minPoints = Integer.parseInt(minStr);
}
} catch (Exception e) {
minPoints = 100;
}
if (coupon.getPointsPrice() < minPoints) {
throw new RuntimeException("兑换积分不得低于最低限制");
}
// 扣积分(system_points)
int deductResult = pointsService.deductSystemPoints(targetFamilyMemberId, coupon.getPointsPrice(),
"积分兑换优惠券: " + coupon.getName());
if (deductResult < 0) {
throw new RuntimeException("积分不足");
}
// 发券 + 流水(EXCHANGE,period=null)
couponService.grant(userId, couponId, "EXCHANGE", null, null);
// 兑换记录
String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
PointsExchangeRecord record = new PointsExchangeRecord();
record.setUserId(userId);
record.setFamilyMemberId(targetFamilyMemberId);
record.setProductId(couponId);
record.setProductName(coupon.getName());
record.setPointsCost(coupon.getPointsPrice());
record.setQuantity(1);
record.setStatus("completed");
record.setRedeemCode(redeemCode);
record.setCreatedAt(new Date());
record.setUpdatedAt(new Date());
pointsExchangeRecordMapper.insert(record);
return record;
}
注入 PointsExchangeService 并新增端点(现有端点 /list、/claim、/apply 不动):
@Resource
private PointsExchangeService pointsExchangeService;
/** 积分兑换优惠券 */
@PostMapping("/exchange")
public Result<Map<String, Object>> exchange(@RequestAttribute("userId") Long userId,
@RequestBody Map<String, Object> params) {
Long couponId = Long.valueOf(params.get("couponId").toString());
Object memberId = params.get("memberId");
Long childId = memberId != null ? Long.valueOf(memberId.toString()) : null;
PointsExchangeRecord record = pointsExchangeService.exchangeCoupon(userId, childId, couponId);
return Result.success(java.util.Collections.singletonMap("recordId", record.getId()));
}
/** 积分兑换中心:可兑换券列表 */
@PostMapping("/exchangeable")
public Result<List<Coupon>> exchangeable() {
return Result.success(couponService.listExchangeable());
}
/** 商品详情:绑定该商品的兑换券 */
@PostMapping("/product")
public Result<List<Coupon>> productCoupons(@RequestBody Map<String, Object> params) {
Long productId = Long.valueOf(params.get("productId").toString());
return Result.success(couponService.listByProduct(productId));
}
/** 会员权益展示:JOIN 赠券规则 */
@PostMapping("/join-rules")
public Result<List<Coupon>> joinRules() {
return Result.success(couponService.listJoinRules());
}
运行:
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 冲突
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java
git commit -m "feat(backend): 积分兑换优惠券exchangeCoupon+优惠券兑换/查询端点"
文件:
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java修改:cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
[ ] 步骤 1:ProductDTO 统一价
ProductDTO.from(Product p, boolean isGuest, String memberLevel) 中替换(约 98 行):
// Member-aware pricing: resolve final display price based on membership level
// Guests (no memberLevel) see base price; members see memberPrice if applicable
d.price = ActivityDTO.resolveMemberPrice(p.getPrice(), p.getMemberPrice(), memberLevel);
为:
// 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)
d.price = p.getPrice();
定位 item.put("memberPrice", p.getMemberPrice());(约 129 行),改为:
item.put("memberPrice", p.getPrice());
运行:mvn clean compile(workdir: /app/cfc/cfc-backend)
预期:BUILD SUCCESS(确认无其他位置因 memberPrice 语义受影响)
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
git commit -m "feat(backend): 商品统一价停用memberPrice(活动侧保留)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.javacfc-web/src/views/admin/CouponManagement.vuecfc-web/src/views/admin/CouponGrantLog.vuecfc-web/src/router/index.jscfc-web/src/views/admin/ProductManage.vue修改:cfc-web/src/views/admin/ProductEdit.vue
[ ] 步骤 1:AdminCouponController 新增 /grant-log 端点
在类末尾新增:
@Resource
private CouponGrantLogMapper couponGrantLogMapper;
/** 发券记录查询 */
@PostMapping("/grant-log")
public Result<List<CouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权限");
}
Long userId = params.get("userId") != null ? Long.valueOf(params.get("userId").toString()) : null;
Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
LambdaQueryWrapper<CouponGrantLog> wrapper = new LambdaQueryWrapper<>();
if (userId != null) {
wrapper.eq(CouponGrantLog::getUserId, userId);
}
if (couponId != null) {
wrapper.eq(CouponGrantLog::getCouponId, couponId);
}
wrapper.orderByDesc(CouponGrantLog::getCreatedAt);
return Result.success(couponGrantLogMapper.selectList(wrapper));
}
(补充 import:com.etotem.cfc.entity.CouponGrantLog、com.etotem.cfc.mapper.CouponGrantLogMapper、com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper)
先读现有文件,在表单中新增字段(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,无需改后端)。
参照现有 admin 列表页结构(如 CouponManagement.vue 的表格+分页),实现:
调 /api/admin/coupon/grant-log
[ ] 步骤 4:注册路由
在 cfc-web/src/router/index.js 新增:
{ path: '/admin/coupon-grant-log', component: () => import('@/views/admin/CouponGrantLog.vue'), meta: { role: 'admin' } }
(按现有路由配置风格调整路径与懒加载写法)
搜索 member_price / 会员价 / memberPrice,删除对应表单项/列(保留接口字段,仅 UI 隐藏)。
运行:npm run build(workdir: /app/cfc/cfc-web)
预期:构建成功,无编译错误
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java cfc-web/src/views/admin/CouponManagement.vue cfc-web/src/views/admin/CouponGrantLog.vue cfc-web/src/router/index.js cfc-web/src/views/admin/ProductManage.vue cfc-web/src/views/admin/ProductEdit.vue
git commit -m "feat(web): 优惠券配置表单扩展+发券记录页+移除商品会员价输入"
文件:
cfc-frontend/utils/api.jscfc-frontend/pages/profile-extra/coupons.vuecfc-frontend/pages/shop/detail/detail.vuecfc-frontend/pages/family/add-member.vue修改:cfc-frontend/pages/membership/upgrade.vue
[ ] 步骤 1:api.js 新增 API 函数
在现有优惠券 API 附近新增:
export const getExchangeableCoupons = () => request('/api/coupon/exchangeable', 'POST', {})
export const getProductCoupons = (data) => request('/api/coupon/product', 'POST', data)
export const exchangeCoupon = (data) => request('/api/coupon/exchange', 'POST', data)
export const getJoinCouponRules = () => request('/api/coupon/join-rules', 'POST', {})
先读现有 pages/profile-extra/coupons.vue,在其 tab 结构(若已有「我的券」)基础上新增「可兑换」tab,或新增兑换区块:
<!-- 可兑换券列表 -->
<view v-if="activeTab === 'exchangeable'">
<view class="coupon-card" v-for="(item, index) in exchangeableList" :key="getCouponKey(item, index)">
<view class="coupon-main">
<view class="coupon-value">{{ item.pointsPrice }}积分</view>
<view class="coupon-name">{{ item.name }}</view>
<view class="coupon-desc">{{ getCouponDesc(item) }}</view>
</view>
<button class="exchange-btn" @tap="doExchange(item)">兑换</button>
</view>
<view v-if="exchangeableList.length === 0" class="empty-tip">暂无可用兑换的优惠券</view>
</view>
脚本部分:
data() {
return {
activeTab: 'mine', // mine=我的券, exchangeable=可兑换
exchangeableList: [],
}
},
onShow() {
this.loadCoupons()
this.loadExchangeable()
},
methods: {
loadExchangeable() {
getExchangeableCoupons().then(res => {
if (res.code === 200) {
this.exchangeableList = res.data || []
}
})
},
doExchange(item) {
uni.showModal({
title: '确认兑换',
content: '消耗 ' + item.pointsPrice + ' 积分兑换「' + item.name + '」?',
success: (r) => {
if (r.confirm) {
exchangeCoupon({ couponId: item.id }).then(res => {
if (res.code === 200) {
uni.showToast({ title: '兑换成功', icon: 'success' })
this.loadExchangeable()
this.loadCoupons()
} else {
uni.showToast({ title: res.message || '兑换失败', icon: 'none' })
}
})
}
}
})
},
}
注意:小程序禁止 :key 表达式与可选链,getCouponKey(item, index) 为方法调用返回 item.id + '-' + index;res.code === 200 用 === 而非可选链。核对现有页面的 loadCoupons() 方法名与结构,复用其加载方式。
先读现有 pages/shop/detail/detail.vue,在价格/购买区域附近新增:
<!-- 积分兑换优惠券 -->
<view class="coupon-exchange-section" v-if="productCoupons.length > 0">
<view class="section-title">积分兑换优惠券</view>
<view class="coupon-item" v-for="(item, index) in productCoupons" :key="getCouponKey(item, index)">
<view class="coupon-info">
<view class="coupon-name">{{ item.name }}</view>
<view class="coupon-desc">{{ getCouponDesc(item) }}</view>
</view>
<view class="coupon-price">{{ item.pointsPrice }}积分</view>
<button class="coupon-exchange-btn" @tap="doExchangeCoupon(item)">兑换</button>
</view>
</view>
脚本部分(在 onLoad/数据加载处调用):
loadProductCoupons() {
const productId = this.product && this.product.id
if (!productId) return
getProductCoupons({ productId: productId }).then(res => {
if (res.code === 200) {
this.productCoupons = res.data || []
}
})
},
先读 pages/family/add-member.vue,在添加成员成功回调处追加(在现有成功 toast 之后):
// 提示家庭人口券发放(后端已自动发放,此处仅提示)
uni.showToast({ title: '成员添加成功,已发放优惠券', icon: 'none' })
(若页面已有成功 toast 逻辑,改为合并文案或在 toast 后追加提示)
先读 pages/membership/upgrade.vue,在等级卡片/权益区新增(onShow 时调用):
loadJoinRules() {
getJoinCouponRules().then(res => {
if (res.code === 200) {
const rules = res.data || []
this.joinRules = rules
}
})
},
模板中按 grantLevelCode 分组展示:「加入 {levelName} 会员赠送 {grantQuantity} 张 {券名}」;getLevelName(code) 映射 FREE/FAMILY/PREMIUM → 免费版/家庭会员/高级会员。
运行:npm run build:mp-weixin(workdir: /app/cfc/cfc-frontend;若构建脚本不同以 package.json 为准)
预期:构建成功
[ ] 步骤 7:Commit
git add cfc-frontend/utils/api.js cfc-frontend/pages/profile-extra/coupons.vue cfc-frontend/pages/shop/detail/detail.vue cfc-frontend/pages/family/add-member.vue cfc-frontend/pages/membership/upgrade.vue
git commit -m "feat(miniapp): 优惠券积分兑换入口+商品详情兑换区块+发券提示+会员权益展示"
cd cfc-backend && mvn clean compile — BUILD SUCCESScd cfc-backend && mvn test -Dtest=CouponServiceTest — 8 tests PASScd 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 齐全coupon_type 列:现有 coupon.type 字段已是券类型(默认 FIXED 且未参与逻辑),复用为 FIXED/CASH/DISCOUNT,避免双类型列冗余(规格 4.1 原列 8 个,实为 7 个)。coupon 表无上架状态列,兑换校验改用 points_price > 0 + 有效期。orderAmount * rate / 10000 返回折后价,与现有调用方语义冲突(PackagePaymentService:91、MembershipService:321 均按 originalAmount - discount 使用返回值)。修正为返回抵扣额:orderAmount * (10000 - rate) / 10000;同步修正 3 个单元测试期望值(CASH 500→300、DISCOUNT 2700→300、cap 测试改为 FIXED 值超订单场景 + 新增小订单折扣测试),测试数 7→8。