فهرست منبع

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

E2E Test Bot 2 هفته پیش
والد
کامیت
c10dcb1ec5
2فایلهای تغییر یافته به همراه256 افزوده شده و 3 حذف شده
  1. 4 3
      docs/superpowers/PROJECT-OVERVIEW.md
  2. 252 0
      docs/superpowers/specs/2026-08-31-coupon-family-based-design.md

+ 4 - 3
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,8 +1,8 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.9
-**日期:** 2026-08-23
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)**
+**文档版本:** v2.10
+**日期:** 2026-08-31
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)**
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -364,6 +364,7 @@
 | `2026-08-28-plan-standard-content-design.md` | 🟢 已实施 | 生成方案标准内容格式(plan_json.sections[].tasks 唯一事实源 + LangGraph prompt tasks 输出 + Pydantic 校验 + 正则兜底 + 存量懒加载回填 + 规划师端结构化任务编辑 UI) |
 | `2026-08-28-innate-portrait-mind-dimension-design.md` | ✅ 已实施(迁移270-272/3实体/3Mapper/7DTO/2Controller/Service+Agent/前端2页+路由) | 星座/八字/血型/数字能量引入心维度先天画像体系 — 先天画像聚合层、可配置权重、AI解读+模板降级、成长轨迹打通、管理端配置化 |
 | `2026-08-30-diet-preferences-redesign-design.md` | 🟡 设计稿(3 步引导问卷 + 暖橙主题 + 补全 mustEat/cookingMethods 采集,纯前端零后端改动) | 饮食偏好调研页面重新设计 |
+| `2026-08-31-coupon-family-based-design.md` | 🟡 设计稿 | 优惠券全链路改为家庭维度(新建 family_coupon / family_coupon_grant_log 表,6 条发放路径全部改家庭,消费/核销同步;API 路径与响应字段保持不变) |
 | `2026-08-31-self-check-reminder-design.md` | 🟡 设计稿(15 天复检周期 + 首页入口显示自检分数 + 题目轮换 + 后端忽略记录,纯设计方案) | 五维家庭自检 15 天复检周期与题目轮换设计 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 

+ 252 - 0
docs/superpowers/specs/2026-08-31-coupon-family-based-design.md

@@ -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`(本项完成后需同步更新状态)