|
|
@@ -0,0 +1,252 @@
|
|
|
+# 优惠券全链路改为家庭维度 — 设计文档
|
|
|
+
|
|
|
+**文档版本:** 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 个发放调用方、`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<Long> 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`(本项完成后需同步更新状态)
|