文档版本: v1.0 日期: 2026-08-31 状态: 待确认(Design Review) 决策: 用户确认 ① 两张入口都改为家庭(CouponManagement + Families.vue) ② 先写设计文档 + 计划
用户要求"优惠券只发放给家庭"("不放在用户身上,应该在家庭管理中实现")。本次变更把优惠券从个人维度重构为家庭维度,覆盖全部发放和消费链路。
| 类别 | 位置 | 现状 |
|---|---|---|
| 模板 | coupon 表 |
复用,不动 |
| 发放载体 | user_coupon 表 |
per-user:user_id, coupon_id, status(AVAILABLE/USED), received_at, used_at, order_id |
| 发放流水 | coupon_grant_log 表 |
per-user:user_id, coupon_id, grant_type(JOIN/PERIODIC/POPULATION/EXCHANGE), period, quantity, source,唯一键 uk_grant(user_id, coupon_id, grant_type, period) |
| 发放路径 | 6 条 | 见下表 |
| 消费接口 | CouponController |
/api/coupon/list, /api/coupon/my, /api/coupon/apply, /api/coupon/claim, /api/coupon/checkout-list |
| 订单绑定 | PackageOrder.userCouponId, PaymentOrder.userCouponId |
存 wallet record id |
| # | 触发场景 | 调用方 | 目标 |
|---|---|---|---|
| 1 | 后台批量发放 | AdminCouponController.issue |
List<Long> userIds |
| 2 | 开通/续费会员赠券 | MembershipService.grantJoinCoupons |
family 管理员 userId |
| 3 | 新成员加入家庭人口券 | FamilyMemberService.grantPopulationCoupons |
成员 userId / 家庭创建者 |
| 4 | 周期补发(每天 5:00) | CouponGrantTask.grantPeriodicCoupons |
有效会员 userId 列表 |
| 5 | 积分兑换 | PointsExchangeService.exchangeCoupon |
操作用户 userId |
| 6 | CF 值兑换 | FamilyPlatformPointsService.exchangeCouponByCf |
操作用户 refUserId(已有 familyId) |
CouponService 核心、6 个发放调用方、CouponController、AdminCouponControllerFamilies.vue(新增按钮)、CouponManagement.vue(批量发放改为家庭 ID)family_coupon)-- family_coupon:家庭维度的优惠券实例表
CREATE TABLE IF NOT EXISTS family_coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
family_id BIGINT NOT NULL COMMENT '所属家庭ID',
coupon_id BIGINT NOT NULL COMMENT '券模板ID',
status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME,
order_id BIGINT COMMENT '核销时写入的订单号',
INDEX idx_family_coupon (family_id, coupon_id),
INDEX idx_status (status),
INDEX idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
-- family_coupon_grant_log:家庭维度发券流水(防重 + 审计)
CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
coupon_id BIGINT NOT NULL COMMENT '券模板ID',
grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE(与coupon_grant_log枚举对齐,新增CF_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_family_grant (family_id, coupon_id, grant_type, period),
INDEX idx_coupon (coupon_id),
INDEX idx_family (family_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
user_coupon与coupon_grant_log保留,不删除、不迁移历史数据。新发放一律写family_coupon/family_coupon_grant_log。
| 决策 | 方案 | 理由 |
|---|---|---|
| API 路径与响应字段是否变化? | 不变 | 小程序已上线(coupons.vue / checkout.vue / pay.vue / wealth-cf),不能破坏 |
| 家庭券的 id 如何用于订单? | 写入 PackageOrder.userCouponId / PaymentOrder.userCouponId |
保持字段名不动,语义上存储的是 family_coupon.id(wallet record id) |
| admin 订单详情页如何展示家庭券? | 暂不改动显示逻辑;字段名为 userCouponId,查 family_coupon 表 |
最小改动原则 |
| 历史 user_coupon 如何处理? | 保留,不再写入也不读取(旧家庭券静默过期) | 避免大规模数据迁移风险 |
FamilyCoupon 实体(@TableName("family_coupon"))FamilyCouponGrantLog 实体(@TableName("family_coupon_grant_log"))FamilyCouponMapper、FamilyCouponGrantLogMapper(均继承 BaseMapper)保留向后兼容的方法(签名不变,内部替换实现):
| 旧签名 | 新签名 | 说明 |
|---|---|---|
listAvailable(Long userId) |
listAvailable(Long userId) |
内部:userMapper.selectById(userId).getFamilyId() → 查 family_coupon |
listMyCoupons(Long userId) |
listMyCoupons(Long userId) |
同上 |
apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) |
不变 | 内部:userId→familyId,校验 family_coupon.family_id == familyId |
claim(Long userId, Long couponId) |
不变 | 内部:发给家庭,插入 family_coupon |
markUsed(Long userCouponId, Long orderId) |
不变 | 内部:操作 family_coupon 行 |
revert(Long userCouponId) |
不变 | 同上 |
listUnownedExchangeable(Long userId, Long productId) |
不变 | 内部:familyId |
新增/改名的私用方法:
| 新方法 | 说明 |
|---|---|
issueToFamily(Long couponId, Long familyId) |
单条发放到家庭(替代 issueToUser) |
grantFamily(familyId, couponId, grantType, period, source) |
通用家庭发放(含去重 + 落流水) |
grantFamilyJoinCoupons(familyId, levelCode, source) |
JOIN 赠券(替换原 grantJoinCoupons(userId, ...)) |
grantFamilyPopulationCoupons(familyId, memberId) |
家庭人口券(替换原 grantPopulationCoupons(userId, ...)) |
listActiveMemberFamilyIds(String levelCode) |
查指定等级的活跃家庭 ID 列表(供 CouponGrantTask) |
listAvailable(userId):通过 User 实体拿 familyId,查 family_coupon where family_id=familyId and status=AVAILABLE,join coupon 模板过滤有效期listMyCoupons(userId):同理,返回包含 userCouponId(= family_coupon.id)+ 模板字段的 Map 列表,与现有 coupons.vue 兼容apply(userId, userCouponId, ...):familyId 校验必须通过;否则返回 null(不可用)grantFamilyJoinCoupons(familyId, levelCode, source):取模板 grantType=JOIN, grantLevelCode=levelCode 的券 → 逐张调用 grantFamilygrantFamilyPopulationCoupons(familyId, memberId):取模板 grantType=POPULATION → 逐张 grantFamily(familyId, ...)listActiveMemberFamilyIds(levelCode):等价于 MembershipService.listActiveMemberUserIds,但返回 Set<Long> familyIds| # | 场景 | 改写点 |
|---|---|---|
| 1 | Admin 发放 | AdminCouponController.issue 改为支持 familyIds(同时保留旧 userIds 参数兼容,但返回警告日志) |
| 2 | JOIN 会员赠券 | MembershipService 中 grantJoinCoupons(adminUserId, ...) → grantFamilyJoinCoupons(order.getFamilyId(), levelCode, order.getOrderNo()) |
| 3 | 新成员人口券 | FamilyMemberService 中 grantPopulationCoupons(targetUserId, memberId) → grantFamilyPopulationCoupons(familyId, member.getId()) |
| 4 | PERIODIC 周期补发 | CouponGrantTask 改为调用 listActiveMemberFamilyIds + grantFamily |
| 5 | 积分兑换 | PointsExchangeService.exchangeCoupon(userId, childId, couponId) → 取 familyMemberMapper.selectOne(FamilyMember.userId=userId).getFamilyId() 得 familyId,调 grantFamily(familyId, ...) |
| 6 | CF 值兑换 | FamilyPlatformPointsService.exchangeCouponByCf(familyId, couponId, refUserId) → 直接 grantFamily(familyId, ...) |
CouponController(小程序端)userId → 解析 userMapper.selectById(userId).getFamilyId()apply 方法中增加 familyCouponId 归属校验:familyCoupon.getFamilyId() == user.familyIdAdminCouponController(管理端)/issue:body 改为 { couponId, familyIds: [Long] },逐条调用 issueToFamily/issue-family:(familyId, couponId, quantity) 快捷单家庭发放(供 Families.vue 调用)/grant-log:支持 familyId 查询,返回时 join family.name/api/admin/coupon/issue-family:经全量扫描 admin 下所有 @PostMapping 路径,无冲突(现有 /issue 与 /issue-family 不重叠)Get-ChildItem cfc-backend/src/main/java/com/etotem/cfc/controller/admin -Filter *.java | Select-String '@PostMapping'Families.vueel-dialog,表单包含:
/api/admin/coupon/list)/api/admin/coupon/issue-familyCouponGrantLog.vue 并预填 familyIdCouponManagement.vueCouponGrantLog.vuefamilyIdapi/coupon.jsissueFamilyCoupon(data) → POST /api/admin/coupon/issue-familygetFamilyCouponGrantLog(data) → POST /api/admin/coupon/grant-log(支持 familyId 参数)DatabaseInitializer.runMigrations(),参照 AGENTS.md 规范用 ensureColumn 或 try-catch 包裹schema.sql迁移内容:
// 迁移269: 创建 family_coupon 表 + family_coupon_grant_log 表
// (优惠券全链路改为家庭维度:2026-08-31)
mvn clean compile — 必须通过API 路径检查:
grep -rn '@PostMapping("' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
确认无冲突
Bean 命名检查:新增 FamilyCouponMapper / FamilyCouponGrantLogMapper 的 Bean name 不冲突
API 端点注册(如适用):确认 FamilyCouponController 不在(本次不改小程序控制器的路由)
Admin 操作流:在 cfc-web 家庭管理页测试发放 → 管理端查看记录
| 风险 | 缓解 |
|---|---|
| 小程序用户现有优惠券消失 | user_coupon 历史数据保留,但前端 /api/coupon/my 改读 family_coupon → 旧券不可见。影响面:已使用的券不受影响;未使用的历史券会"消失"。建议:本功能上线前清理或迁移旧券(本设计暂不包含迁移,留作一期后续) |
CouponGrantTask 跑批前 family 尚未存在 |
迁移后首次运行即按家庭发券,行为一致 |
| 旧订单(PackageOrder.userCouponId)指向 user_coupon 历史数据 | 订单详情中的优惠券展示逻辑不变(字段名不更名),仅新订单写入 family_coupon.id |
关键注意:用户现有 user_coupon 未使用券在新链路下不可用(因为 /api/coupon/my 改读 family_coupon)。建议在上线说明中告知历史券有效期内的使用方式(如有)。
user_coupon 历史数据清理/迁移plans/2026-08-31-coupon-family-based.md(编写中)docs/superpowers/api/API_REFERENCE.md(发放相关章节需更新)docs/superpowers/PROJECT-OVERVIEW.md(本项完成后需同步更新状态)