Selaa lähdekoodia

docs: 商品套餐独立模型重写设计(2026-10-31)

Xiaogang Liao 1 viikko sitten
vanhempi
sitoutus
f6c4b8f401
1 muutettua tiedostoa jossa 177 lisäystä ja 0 poistoa
  1. 177 0
      docs/superpowers/specs/2026-10-31-bundle-redesign-design.md

+ 177 - 0
docs/superpowers/specs/2026-10-31-bundle-redesign-design.md

@@ -0,0 +1,177 @@
+# 商品套餐独立模型重写设计
+
+日期:2026-10-31
+状态:待评审
+范围:cfc-backend(Spring Boot 2.7.18 / MyBatis-Plus / MySQL 8.0.34)、cfc-web(管理端 Vue2 + Element UI)、cfc-frontend(消费端 uni-app)
+
+## 1. 背景与意图
+
+代码库目前存在**两套并行**的「套餐/组合」机制,均与业务目标不符:
+
+| 机制 | 归属 | 现状 | 问题 |
+|---|---|---|---|
+| combo(`product_sku_combo_items`) | SKU 级:SKU 可作为「组合」变体 | 迁移434 建表;SKU 编辑器「商品组合」模式;下单时扣**子商品**库存(deductComboStock/restockComboOrder 及 3 处回补) | 组合被当作 SKU 变体处理,违反「组合≠SKU」 |
+| bundle(`product_bundle_items`) | product 级:`product_type='bundle'` 独立商品 + `bundle_sku_id` 锚点 | 迁移435;ProductEdit 套餐编辑;`BundlePricingCalculator` 购物车**自动**计价(saving>0 才触发);消费端 cart.vue **自动展示已享套餐价** | 自动合并计价,用户要的是「提示 + 一键替换」 |
+
+用户确认的新设计(2026-10-31):
+- **套餐 = 独立商品**:有自己的 SKU(售价/库存)、主图、上下架,用户**直接加购下单**,下单/支付**只扣套餐 SKU 自身库存**,不扣子商品库存。
+- **组合/套餐内容 ≠ SKU**:内容是「一个商品套餐上包含哪些商品」的定义——子商品 SKU(**精确到规格**)× 数量。
+- **购物车结算优惠购买**:仅当勾选商品恰好凑成某套餐全部内容、且 **套餐价 < 子商品单买总和** 时,提示「一键替换为套餐」(把勾选子商品替换为套餐商品后继续结算);否则**不提示,按原商品价**。
+- 线上两表均 **0 数据**(combo items 0、bundle items 0、bundle 商品 0)——**无需数据迁移**,可整体删除旧模型。
+
+决策:**重写全新套餐模型**,整体替换上述两套机制。
+
+## 2. 目标 / 非目标 / 反目标
+
+### 目标
+- 套餐作为独立在售商品(`product_type='bundle'`),用户直接加购、下单、支付、售后。
+- 套餐可配置「包含清单」:子商品 SKU × 数量(SKU 级精确,跨分类可选)。
+- 套餐 SKU 独立售价/库存;套餐订单只动套餐 SKU 库存。
+- 套餐详情页展示「内含 A×2、B×1」,订单按套餐整体展示。
+- 购物车结算勾选时:命中套餐且套餐价 < 子商品总和 → 提示「一键替换为套餐」;替换后订单按套餐商品结算。
+
+### 非目标
+- 不做「套餐内子商品可单独购买/联动库存」(详情仅展示清单)。
+- 不做套餐多规格商品化(套餐以单规格为主)。
+- 不做「购物车自动合并计价」(旧 bundle 行为)——改为提示 + 用户确认替换。
+
+### 反目标(即使技术上能做也不要)
+- 套餐 SKU 下单时扣减/回补子商品库存(旧 combo 行为)。
+- 库存校验依赖子商品库存(旧 checkBundleStock)。
+- 前端自动应用套餐优惠而不经用户确认(旧 bundlePromo 自动扣减)。
+- 保留两套并行机制造成语义混乱。
+
+## 3. 约束
+
+- 生产库目前无套餐/组合数据 → 新表直接创建、旧表直接删除(幂等迁移)。
+- 套餐价格比较基准:套餐 SKU 售价(分)vs 子商品 SKU 单买价合计(分),均以「在下单口径下的结算单价」计算。
+- 替换操作必须防篡改:数量、份数、金额一律以后端重算为准。
+- 迁移走 `DatabaseInitializer.runMigrations()` 幂等模式,并同步 `schema.sql`。
+- MySQL 8.0:索引/唯一键不得使用函数表达式(历史教训)。
+
+## 4. 新数据模型
+
+### products(沿用,无 schema 变更)
+- `product_type='bundle'` 表示套餐商品(现有枚举值)。
+- 套餐商品的主图/描述/详情沿用现有 product 字段。
+
+### product_skus(沿用)
+- 套餐自身的售价/库存/上下架走现有 SKU 表。
+- 套餐通常单 SKU(单规格);内容按 SKU 键控,天然支持未来多规格。
+
+### 新表 product_combo_items(套餐内容,商品级定义、SKU 级键控)
+| 列 | 类型 | 说明 |
+|---|---|---|
+| id | BIGINT PK AUTO | |
+| combo_sku_id | BIGINT NOT NULL | 套餐 SKU id(内容归属;套餐单规格时即套餐唯一 SKU) |
+| child_product_id | BIGINT NOT NULL | 子商品 |
+| child_sku_id | BIGINT NOT NULL | 子商品 SKU(**必填**,精确到规格) |
+| quantity | INT NOT NULL DEFAULT 1 | 数量(>0) |
+| created_at | DATETIME | |
+
+- UNIQUE KEY `uk_combo_sku_child` (`combo_sku_id`, `child_sku_id`)。
+- 不引用旧命名(`product_sku_combo_items`/`product_bundle_items`),避免与旧机制混淆。
+
+### 删除
+- 旧表 `product_sku_combo_items`、`product_bundle_items`(0 数据,幂等 drop)。
+
+## 5. 后端改动(cfc-backend)
+
+### 5.1 新增
+- `entity/ProductComboItem.java` + `mapper/ProductComboItemMapper.java`。
+- 套餐内容接口(admin):
+  - `saveComboItems(comboProductId, items[])`:先删后插;校验 combo 商品 `product_type='bundle'`、child_sku 存在且属于 child_product、quantity>0。
+  - `listComboItems(comboProductId)`:返回带子商品名称/图/规格描述的清单。
+- 购物车套餐 offer 检测(服务端权威):对**勾选结算行**聚合 `(child_product_id, child_sku_id): qty`,遍历所有套餐商品内容:
+  - 覆盖判定:每个 child 的数量 ≥ 组成数量,取可覆盖组数 `groups = min over children(floor(have/need))`;
+  - 子商品单买总和 = 覆盖组内各 child 实际数量和 × 下单口径单价;
+  - 套餐价 = 套餐 SKU 售价 × groups(按覆盖组计),仅当 **套餐价 < 子商品单买总和**(节省额 > 0)才返回 offer;
+  - 命中多个套餐取节省额最大者(同额取先出现,确定性);offer 含:套餐 SKU id、套餐名、覆盖组数、套餐价、子商品单买总和、节省额。
+- 购物车「一键替换」接口:`replaceCartWithCombo(userId, checkedCartItemIds, comboProductId, groups)`:
+  - 后端重算覆盖组数(不信任前端);
+  - 从购物车移除足额子商品(按 (product, sku) 扣减数量,剩余不足组数的余量保留);
+  - 加入套餐 SKU 商品 `groups` 份,返回新购物车。
+
+### 5.2 删除
+- `entity/ProductSkuComboItem.java`、`mapper/ProductSkuComboItemMapper.java`、`ProductSku.comboItems` 字段。
+- `entity/ProductBundleItem.java`、`mapper/ProductBundleItemMapper.java`。
+- `pricing/` 包:`BundlePricingCalculator`、`BundleDef`、`BundleChild`、`BundleHit`、`BundleDiscountResult`、`BundlePricingService`、`LineItem`。
+- `ProductSkuService`:combo 相关(fillComboItems/validateComboItems/saveComboItems/buildComboSummary/hasComboItems)。
+- `CartService`:SKU 组合摘要(specDesc)、`attachBundleMatches`、`computeBundlePromo`、`toBundlePromo`、bundle 相关 import。
+- `ProductOrderService`:combo 子商品库存扣减/回补(`deductComboStock`、`restockComboOrder`、3 处回补调用、combo 行特殊处理)、`checkBundleStock` 调用(3 处)、`bundleDiscount` 应用(结算价)。
+- `InventoryService`:`checkBundleStock`、`saveBundleItems`、`listBundleItems` 及 bundle 出库联动逻辑。
+- `ProductController.detail`:SKU comboItems 组装。
+- `AdminProductPickerController`:bundle 相关(视情况)。
+
+### 5.3 调整
+- 套餐商品下单:按普通商品流程(套餐 SKU 自身库存校验 + 扣减),订单行展示套餐商品 + 内含清单快照。
+- 详情接口:套餐商品挂「内含清单」(combo items + 子商品名/SKU 规格/数量),供消费端展示。
+
+### 5.4 迁移
+- 迁移436(下一编号):`CREATE TABLE IF NOT EXISTS product_combo_items ...` + `DROP TABLE IF EXISTS product_sku_combo_items, product_bundle_items`(0 数据安全)。
+- `schema.sql` 同步新表。
+
+## 6. 前端改动
+
+### 6.1 cfc-web(管理端)
+- `ProductEdit.vue`:`productType='bundle'` 时展示「套餐内容编辑」:跨分类搜索子商品 → 选子商品 SKU 规格 + 数量(复用 ProductPicker 的多选+SKU 选择能力);保存调 `saveComboItems`,加载调 `listComboItems`。替换现有 bundleItems 编辑。
+- `ProductSkuEditor.vue`:**移除「商品组合」模式**(radio 只留「规格变体」),删除 comboItems/ProductPicker 组合调用与相关样式;保留单选/多选公共组件 ProductPicker 无需改动或按需精简。
+
+### 6.2 cfc-frontend(消费端 uni-app)
+- `pages/shop/cart/cart.vue`:
+  - 删除「已享套餐价」自动优惠区块与总价扣减(bundlePromo 展示/applyBundleDiscount)。
+  - 结算勾选后渲染套餐 offer 提示:「这 N 件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)」+「一键替换为套餐」按钮;点击调替换接口 → 刷新购物车与选中态。
+- `pages/shop/checkout/checkout.vue`:适配(移除自动优惠展示;若有未处理 offer 时不自动保留)。
+- `utils/bundle-pricing.js`:改为套餐 offer 展示辅助(安全读取后端 offer 字段,纯展示,杜绝前端算金额)。
+
+## 7. 交互流程(购物车优惠购买)
+
+```
+用户在购物车勾选 A×2 + B×1 准备结算
+  │
+  ├─ 后端 offer 检测:A、B 恰为套餐X内容,套餐X价 < A+B 单买总和?
+  │    是 → 购物车顶部提示「这 3 件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)」[一键替换为套餐]
+  │    否 → 不提示,按原商品价结算
+  │
+  └─ 用户点「一键替换为套餐」
+        → 后端重算:购物车删除 A×2、B×1,加入「套餐X」SKU ×1
+        → 刷新购物车 → 用户继续按套餐商品结算
+```
+
+边界:
+- 覆盖不足(如购物车 A×1+B×1,套餐需 A×2+B×1)→ 无 offer。
+- 超量(A×4+B×2 = 可覆盖 2 组)→ offer 提示可替换 2 份,余量(若有)保留。
+- 套餐商品已被直接加购 → 不触发替换提示(避免同套餐重复)。
+- 套餐价 ≥ 子商品总和 → 无 offer(原商品价)。
+- 替换后购物车金额以后端重算为准(防篡改)。
+
+## 8. 测试计划
+
+### 后端
+- 单元测试(计算逻辑纯度):
+  - 覆盖检测:恰好覆盖 / 超量 / 不足 / 跨 child 共用行。
+  - 节省额规则:套餐价 = 子商品总和 → 无 offer;套餐价 > 总和 → 无 offer;套餐价 < 总和 → offer。
+  - 多套餐候选:取节省额最大者;同额确定性。
+- 接口/服务测试:
+  - `saveComboItems`/`listComboItems` 校验(非 bundle 商品拒绝、child_sku 不存在拒绝、quantity≤0 拒绝)。
+  - `replaceCartWithCombo`:购物车行扣减/新增/余量保留/防篡改(组数后端重算)。
+  - 下单:套餐订单仅扣套餐 SKU 库存;子商品库存不变;支付/取消回补只对套餐 SKU。
+- 删除/改写旧测试:`ProductSkuComboServiceTest`、`ProductOrderComboStockTest`(组合语义已删除)。
+
+### 前端
+- cfc-web:jest(ProductEdit 套餐内容编辑逻辑;ProductSkuEditor 无组合模式)。既有 90 测试回归。
+- cfc-frontend:jest(offer 展示辅助;购物车替换按钮触发;移除 bundlePromo 自动扣减相关断言)。
+
+## 9. 迁移与兼容
+
+- 数据迁移:无(0 数据)。仅结构迁移(建新表、删旧表)。
+- 兼容:消费端/管理端与后端**同批部署**;后端删除旧接口前先保证新旧前端不再调用(管理端与消费端同步发布)。
+- 已存在套餐商品(0 个)无需转换。
+
+## 10. 实施者决策(我的裁定,可按约定调整)
+
+- 新表命名 `product_combo_items`、归属列 `combo_sku_id`(命名决策,避开旧表名)。
+- 替换算法:可用覆盖组数 = floor(have/need) 逐 child 取最小;余量保留。
+- 多套餐候选:仅提示节省额最大的 1 个(确定性:同额取先出现)。
+- 套餐内容编辑复用 ProductPicker 的子商品/SKU 选择能力(避免重复造轮子)。
+- 迁移编号暂定 436(上线前以数据库当前最大迁移号为准调整)。