# 优惠券全链路改为家庭维度 — 设计文档 **文档版本:** 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 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 个发放调用方、`CouponController`、`AdminCouponController` - **前端管理端(cfc-web)**:`Families.vue`(新增按钮)、`CouponManagement.vue`(批量发放改为家庭 ID) - **小程序前端(cfc-frontend)**:**零改动**(API 路径与响应字段不变,内部改为读 `family_coupon`) --- ## 二、目标架构 ### 2.1 新数据表 ```sql -- 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`。 ### 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")`) - `FamilyCouponMapper`、`FamilyCouponGrantLogMapper`(均继承 `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 familyIds` --- ## 四、发放路径改写(6 条) | # | 场景 | 改写点 | |---|------|--------| | 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, ...)` | --- ## 五、控制器层改动 ### 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 规范用 `ensureColumn` 或 `try-catch` 包裹 - 同步 `schema.sql` 迁移内容: ``` // 迁移269: 创建 family_coupon 表 + family_coupon_grant_log 表 // (优惠券全链路改为家庭维度:2026-08-31) ``` --- ## 八、验证步骤 1. **编译**:`mvn clean compile` — 必须通过 2. **API 路径检查**: ```bash 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`(本项完成后需同步更新状态)