2026-08-31-coupon-family-based-design.md 13 KB

优惠券全链路改为家庭维度 — 设计文档

文档版本: v1.0 日期: 2026-08-31 状态: 待确认(Design Review) 决策: 用户确认 ① 两张入口都改为家庭(CouponManagement + Families.vue) ② 先写设计文档 + 计划


一、背景与审计发现

1.1 业务诉求

用户要求"优惠券只发放给家庭"("不放在用户身上,应该在家庭管理中实现")。本次变更把优惠券从个人维度重构为家庭维度,覆盖全部发放和消费链路。

1.2 当前系统审计结果

类别 位置 现状
模板 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)

1.3 改造影响面

  • 后端(Java)CouponService 核心、6 个发放调用方、CouponControllerAdminCouponController
  • 前端管理端(cfc-web)Families.vue(新增按钮)、CouponManagement.vue(批量发放改为家庭 ID)
  • 小程序前端(cfc-frontend)零改动(API 路径与响应字段不变,内部改为读 family_coupon

二、目标架构

2.1 新数据表

-- 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_couponcoupon_grant_log 保留,不删除、不迁移历史数据。新发放一律写 family_coupon/family_coupon_grant_log

2.2 关键设计决策

决策 方案 理由
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 如何处理? 保留,不再写入也不读取(旧家庭券静默过期) 避免大规模数据迁移风险

三、服务层改动(CouponService)

3.1 新增 Mapper / Entity

  • FamilyCoupon 实体(@TableName("family_coupon")
  • FamilyCouponGrantLog 实体(@TableName("family_coupon_grant_log")
  • FamilyCouponMapperFamilyCouponGrantLogMapper(均继承 BaseMapper

3.2 方法签名变更

保留向后兼容的方法(签名不变,内部替换实现):

旧签名 新签名 说明
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)

3.3 方法实现要点

  • 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 的券 → 逐张调用 grantFamily
  • grantFamilyPopulationCoupons(familyId, memberId):取模板 grantType=POPULATION → 逐张 grantFamily(familyId, ...)
  • listActiveMemberFamilyIds(levelCode):等价于 MembershipService.listActiveMemberUserIds,但返回 Set<Long> familyIds

四、发放路径改写(6 条)

# 场景 改写点
1 Admin 发放 AdminCouponController.issue 改为支持 familyIds(同时保留旧 userIds 参数兼容,但返回警告日志)
2 JOIN 会员赠券 MembershipServicegrantJoinCoupons(adminUserId, ...)grantFamilyJoinCoupons(order.getFamilyId(), levelCode, order.getOrderNo())
3 新成员人口券 FamilyMemberServicegrantPopulationCoupons(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, ...)

五、控制器层改动

5.1 CouponController(小程序端)

  • 不修改路由或响应字段
  • 内部所有 userId → 解析 userMapper.selectById(userId).getFamilyId()
  • apply 方法中增加 familyCouponId 归属校验:familyCoupon.getFamilyId() == user.familyId

5.2 AdminCouponController(管理端)

  • /issue:body 改为 { couponId, familyIds: [Long] },逐条调用 issueToFamily
  • 新增 /issue-family(familyId, couponId, quantity) 快捷单家庭发放(供 Families.vue 调用)
  • /grant-log:支持 familyId 查询,返回时 join family.name

5.3 路由冲突检查

  • /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'

六、前端改动(cfc-web)

6.1 Families.vue

  • 「更多」下拉新增 「发放优惠券」 按钮
  • 点击打开 el-dialog,表单包含:
    • 优惠券下拉(调 /api/admin/coupon/list
    • 发放数量(默认 1)
    • 确认按钮 → POST /api/admin/coupon/issue-family
  • 弹窗底部加「查看该家庭发放记录」链接 → 打开 CouponGrantLog.vue 并预填 familyId

6.2 CouponManagement.vue

  • 批量发放弹窗:由"用户ID列表(每行一个)"改为"家庭ID列表(每行一个)"
  • 文案与 placeholder 同步调整

6.3 CouponGrantLog.vue

  • 搜索框新增「家庭ID」输入项(与现有"用户ID"并存,两者可选其一)
  • 查询条件支持 familyId
  • 列表列"用户"改为"家庭"(显示家庭 name 或 ID)

6.4 api/coupon.js

  • 新增 issueFamilyCoupon(data) → POST /api/admin/coupon/issue-family
  • 新增 getFamilyCouponGrantLog(data) → POST /api/admin/coupon/grant-log(支持 familyId 参数)

七、数据库迁移

  • 迁移编号: 迁移 269
  • 入口:DatabaseInitializer.runMigrations(),参照 AGENTS.md 规范用 ensureColumntry-catch 包裹
  • 同步 schema.sql

迁移内容:

// 迁移269: 创建 family_coupon 表 + family_coupon_grant_log 表
// (优惠券全链路改为家庭维度:2026-08-31)

八、验证步骤

  1. 编译mvn clean compile — 必须通过
  2. API 路径检查

    grep -rn '@PostMapping("' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
    

    确认无冲突

  3. Bean 命名检查:新增 FamilyCouponMapper / FamilyCouponGrantLogMapper 的 Bean name 不冲突

  4. API 端点注册(如适用):确认 FamilyCouponController 不在(本次不改小程序控制器的路由)

  5. 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)。建议在上线说明中告知历史券有效期内的使用方式(如有)。


十、不在本次范围内

  • 小程序端优惠券展示/核销流程的优化(API 路径与字段不变,但页面逻辑暂不动)
  • user_coupon 历史数据清理/迁移
  • 订单详情页(cfc-web)中优惠券信息的增强展示

十一、关联文档

  • 实施计划:plans/2026-08-31-coupon-family-based.md(编写中)
  • API 参考:docs/superpowers/api/API_REFERENCE.md(发放相关章节需更新)
  • 项目管理:docs/superpowers/PROJECT-OVERVIEW.md(本项完成后需同步更新状态)