Răsfoiți Sursa

docs: 订单套餐自动定价设计 v2(吸收 builder check)

- 下单优惠改为后端权威重算(ProductOrderService),前端 discountAmount 不采信
- 节省额按下单结算口径(会员调整价/SKU 价)计算
- 明确移除一口价范围:仅 computeGiftPromo;保留送会员权益读取
- 匹配身份对齐 linked-SKU 解析;不随迁移发布套餐种子数据
Xiaogang Liao 1 săptămână în urmă
părinte
comite
6ddde80f45

+ 52 - 35
docs/superpowers/specs/2026-10-30-product-bundle-autopricing-design.md

@@ -1,7 +1,7 @@
 # 订单套餐自动定价设计(Product Bundle Auto-Pricing)
 
-- 日期:2026-10-30
-- 状态:待评审(builder check + 用户批准)
+- 日期:2026-10-30(修订 v2,吸收 builder check 意见)
+- 状态:待用户批准
 - 分支:cfclub
 - 影响范围:cfc-backend(结算/迁移)、cfc-frontend(小程序结算展示)、cfc-web(管理端配置,可选增强)
 
@@ -18,20 +18,22 @@
 3. 超出套餐组成数量的部分按单品价兜底(凑整 + 兜底)。
 4. 页面提示优惠金额与命中套餐明细(**不做换购交互**)。
 5. 废弃现有「送会员商品原价达标 → 一口价 1980」的**结算价**逻辑。
+6. **优惠金额由后端权威计算并校验**:下单扣款(moneyAmount)必须与展示一致,前端传入的折扣金额不被盲目采信。
 
 ## 非目标(Non-goals)
 
 - 不做换购/弹窗交互(不引导用户手动换成套餐 SKU)。
-- 不改变「送会员权益」触发逻辑(`gift_membership_threshold` 累计达标 → 赠送会员 保留原样;本次只替换结算价算法)。
+- 不改变「送会员权益」触发逻辑:`ProductOrderService` 中基于 `gift_membership_original_threshold` 的**单笔订单送会员权益**读取**保留不动**;`gift_membership_threshold` 累计送会员逻辑保留原样。本次只替换"结算价"算法。
 - 不改变现有 `product_bundle_items` 的**出库联动**语义(`InventoryService` 用 `bundle_product_id` 关联套餐商品出库,保持不动)。
 - 不改变套餐 SKU 的既有直接购买路径(商品页仍可直接买"套餐一"等 SKU)。
+- 不删除现有 `attachBundleMatches`/`BundleMatch` 代码(保留为技术债,见"我的决定")。
 
 ## 约束(Constraints)
 
 - 复用 `product_bundle_items` 表与既有管理端套餐组成配置 UI(`ProductEdit.vue` / `AdminInventoryController`)。
 - 生产库 MySQL 8.0.34(迁移须幂等,注意 8.0 语法,不可在索引定义用函数表达式)。
 - 前端为微信小程序(cfc-frontend),改动后需用户自行在微信开发者工具构建上传;后端/管理端走标准部署(systemctl 重启)。
-- 结算价计算在后端(服务端权威),前端只做展示与 total 扣减。
+- **结算价计算在后端(服务端权威)**,前端只做展示与 total 扣减;下单接口服务端重算套餐优惠,前端 `discountAmount` 仅作展示、不参与最终扣款判定。
 
 ## 已确认决策(用户拍板)
 
@@ -42,7 +44,8 @@
 | 适用范围 | **所有商品**(只要配了套餐定义即生效,不限"送会员商品") |
 | 匹配算法 | **凑整套餐 + 单品兜底**:买 3 个、套餐为 2 个 → 1 套套餐 + 1 个单品 |
 | 多套餐选择 | **自动选省最多**(比较各套餐节省额) |
-| 无套餐可匹配 | **废弃一口价**:彻底移除"达标→一口价 1980"逻辑与配置读取,无套餐时按正常价(会员价/优惠券等)结算 |
+| 无套餐可匹配 | **废弃一口价**:移除"达标→一口价 1980"结算逻辑,无套餐时按正常价(会员价/优惠券等)结算 |
+| 优惠落地 | **后端权威校验**:下单时服务端重算套餐优惠并计入扣款,前端 `discountAmount` 不采信 |
 | 交互 | 只提示优惠,不做换购 |
 
 ## 设计(How)
@@ -68,79 +71,93 @@ ALTER TABLE product_bundle_items
 | `child_product_id` | 基础款商品(必填) |
 | `child_sku_id` | 基础款 SKU(可空;空 = 按商品聚合,见算法) |
 | `quantity` | 组成该套餐所需的基础款数量(正整数) |
-| `bundle_product_id` | 定价套餐行建议置 NULL(避免误触发出库联动);如既有数据复用可保留 |
+| `bundle_product_id` | 定价套餐行置 NULL(避免误触发出库联动) |
 
 ### 2. 结算定价算法(后端权威)
 
-输入:订单/购物车商品行集合(每行含 `product_id`、`sku_id`、`quantity`、`unitPrice`=实际结算单价)。
+输入:**下单/结算口径的商品行**(每行含解析后的 `product_id`、`sku_id`、`quantity`、`unitPrice`)。其中:
+- `product_id`/`sku_id` 为 **linked-SKU 解析后**的身份(与下单结算 `createMultiItem` 的解析一致:SKU 存在 `linkedProductId` 时转发到关联商品)。
+- `unitPrice` 为**下单结算同口径单价**:有 SKU 用 `sku.price`,无 SKU 用 `getEligibleMemberPrice(product)`(会员调整价),避免显示与实际扣款口径不一致。
+
 输出:命中套餐列表(每套:锚点 SKU、命中套数、套餐总价、原价合计、节省额)+ 优惠总额。
 
 **算法(贪心省最多)**:
 
-1. 聚合购物车数量:按 `(product_id, sku_id)` 归总;`sku_id` 为空的行按 `(product_id, NULL)` 归总。
+1. 聚合数量:按 `(product_id, sku_id)` 归总;`sku_id` 为空的行按 `(product_id, NULL)` 归总。
 2. 取所有 `bundle_sku_id IS NOT NULL` 的定价套餐行。
 3. 对每个套餐,判定"可凑 1 套":其每个 child 组成(按 `child_product_id` + `child_sku_id` 匹配聚合行)的数量 ≥ `quantity`。
-4. 计算每个可凑套餐的**节省额**:`Σ(该套餐各 child 对应的购物车实际 unitPrice × quantity) − 套餐价(SKU.price)`。
-   - **仅当节省额 > 0 才视为可触发**(确保用户实际省钱,避免按套餐价反而更贵)。
-5. 贪心循环:每轮在"当前可凑套餐"中取**节省额最大**者,命中 1 套 → 扣减其各 child 对应聚合数量(每个扣 `quantity`),累计节省额;重复直至无可凑套餐(节省额>0)。
-6. 剩余数量按购物车实际单价(`unitPrice`)结算(单品兜底)。
+4. 计算每个可凑套餐的**节省额**:`Σ(该套餐各 child 对应的下单口径 unitPrice × quantity) − 套餐价(SKU.price)`。
+   - **仅当节省额 > 0 才视为可触发**(确保按套餐价结算确实更省,避免会员价低于套餐价时反而更贵)。
+5. 贪心循环:每轮在"当前可凑套餐"中取**节省额最大**者,命中 1 套 → 扣减其各 child 对应聚合数量(每个扣 `quantity`),累计节省额;重复直至无可凑套餐。
+6. 剩余数量按下单口径 `unitPrice` 结算(单品兜底)。
 7. 返回命中套餐明细 + 优惠总额 = Σ节省额。
 
 **关键口径**:
-- 节省额用**购物车项实际 `unitPrice`**(用户真实应付口径),非配置原价——保证"按套餐价结算确实更省"才触发。
-- 套餐价取锚点 SKU 的 `price`(`product_skus.price`)。
-- 贪心为近似最优;对"同一基础款多套餐"场景已能选省最多;跨套餐共享基础款的全局最优可后续用 DP 增强(本期不要求,标注为可扩展点)。
+- 节省额基于**下单结算单价**(会员调整价/SKU 价),非展示价、非配置原价。
+- 套餐价取锚点 SKU 的 `price`。
+- 贪心为近似最优;对"同一基础款多套餐"场景已能选省最多;跨套餐共享基础款的全局最优可后续用 DP 增强(本期不要求)。
 
 ### 3. 后端改造
 
-- **迁移**:`DatabaseInitializer` 新增幂等迁移(列存在检查 + `ALTER TABLE`),MySQL 8 语法,同步 `schema.sql`。
-- **定价逻辑**:新增 `BundlePricingService`(纯计算,入参购物车行 + 套餐定义,出参优惠明细/总额);或复用 `CartService.computeGiftPromo` 改造为套餐定价。
-- **移除一口价**:`CartService` 中 `gift_membership_original_threshold` / `gift_membership_pay_price` 的**读取与结算逻辑删除**(配置值保留不删,标记废弃)。
-- **DTO**:`CartItemDTO.GiftPromo` 复用或新增 `BundlePromo`:`active`、`originalTotal`、`payPrice`、`discount`、`bundleList`(命中套餐明细数组:锚点名、套数、原价、套餐价、节省额)。
-- 购物车 `/api/cart/list` 返回的 `giftPromo` 字段改为承载套餐优惠(字段名可复用,结构新增明细),保证前端改动最小。
-- 送会员权益触发(`gift_membership_threshold` 累计送会员)**保持不动**。
+- **迁移**:`DatabaseInitializer` 新增幂等迁移(列存在检查 + `ALTER TABLE`),MySQL 8 语法,同步 `schema.sql`。**不随迁移发布示例套餐数据**(见 Q5 决定)。
+- **定价逻辑**:新增 `BundlePricingService`(纯计算:入参下单口径商品行 + 套餐定义,出参优惠明细/总额),供购物车展示与下单扣款共用,保证口径一致。
+- **购物车展示**:`CartService.computeGiftPromo` 改造为套餐定价(复用/新增 DTO 字段 `giftPromo` → `BundlePromo`:`active`、`originalTotal`、`payPrice`、`discount`、`bundleList` 明细),`/api/cart/list` 返回。
+- **下单扣款(后端权威,本次改造重点)**:
+  - `ProductOrderService.createMultiItem()` 与单件 `create()` 在下单时**服务端重算套餐优惠**:基于行项按第 2 节算法算出 `bundleDiscount`,计入 `discountAmount`/`moneyAmount`(`moneyAmount = totalAmount − 积分 − 优惠券 − 套餐优惠`)。
+  - **前端传入的 `discountAmount` 不再盲目采信**:服务端以行项重算为准,仅将前端值作为参考或直接忽略;防止伪造折扣,保证实付 = 展示。
+  - 与优惠券/积分/佣金的叠加顺序沿用现有 `discountAmount` 聚合模式(套餐优惠加入折扣合计)。
+- **移除一口价(范围明确)**:
+  - **删除** `CartService.computeGiftPromo` 中基于 `gift_membership_original_threshold` / `gift_membership_pay_price` 的一口价读取与结算。
+  - **保留** `ProductOrderService` 中基于 `gift_membership_original_threshold` 的**单笔订单送会员权益**读取(送会员触发,非结算)。
+  - 两个配置 key 保留值不删,仅在结算路径不再产生"一口价 1980"。
+- **送会员权益触发(`gift_membership_threshold` 累计)**:保持不动。
 
 ### 4. 前端(cfc-frontend 小程序)
 
 - `cart.vue`:`giftPromo` 展示区改造为"套餐优惠"提示(命中套餐明细 + 合计节省);total 扣减 `discount` 逻辑保持。
-- `checkout.vue`:`gift-promo-section` 文案改为套餐优惠("已按套餐价结算:xxx,省 Y 元");`giftPromoDiscount` 计算复用;total 扣减保持。
+- `checkout.vue`:`gift-promo-section` 文案改为套餐优惠("已按套餐价结算:xxx,省 Y 元");`giftPromoDiscount` 计算复用;**提交流程仍传 `discountAmount`,但后端以重算为准**(前端值仅展示)。
 - storage `checkoutGiftPromo` 结构沿用(字段扩展),`cart.vue:424` 写入点不变。
-- 纯算法(如节省额/聚合)可抽到 `utils` 纯函数并单测(如 `utils/bundle-pricing.js`)。
+- 纯算法(节省额/聚合/贪心)可抽 `utils` 纯函数并单测(如 `utils/bundle-pricing.js`),保证前端展示与后端口径一致。
 
 ### 5. 管理端配置(cfc-web,可选增强)
 
-- 现有 `ProductEdit.vue` 的"套餐组成"区块(`productType='bundle'`)已能配 `child_product_id/child_sku_id/quantity` 与 `bundle_product_id`。
-- **增强**:该区块增加"套餐锚点 SKU(`bundle_sku_id`)"选择(选当前商品/关联 SKU),存库走 `saveBundleItems`。
-- 若本期不做管理端 UI,可先用 SQL/迁移种子配置数据(用户已确认"用 product_bundle_items 表来配"),管理端增强列为后续可选项。
+- 现有 `ProductEdit.vue` 的"套餐组成"区块(`productType='bundle'`)已能配 `child_product_id/child_sku_id/quantity`。
+- **增强**:该区块增加"套餐锚点 SKU(`bundle_sku_id`)"选择,存库走 `saveBundleItems`。
+- **本期不下沉为必做**:允许先用 SQL 配置数据(用户已确认"用 product_bundle_items 表来配");管理端增强列为后续可选项。
 
-### 6. 示例数据(DAN 测评场景,供验证)
+### 6. 示例数据(DAN 测评场景,供业务后续配置,不随迁移发布)
 
 ```sql
 -- 套餐:2 个基础款(DAN测评 商品10) = 套餐SKU"套餐一"(product_skus.id=3, 1280元)
 INSERT INTO product_bundle_items (bundle_product_id, bundle_sku_id, child_product_id, child_sku_id, quantity)
 VALUES (NULL, 3, 10, NULL, 2);
--- 买 2 个 DAN测评(699×2=1398) → 命中,按 1280 结算,省 118 元
+-- 效果:买 2 个 DAN测评(下单口径 699×2=1398) → 命中,按 1280 结算,省 118 元
 ```
 
-(注:`child_sku_id` 是否指定由业务按"基础款口径"决定;若用户实际购买按"单包"SKU(499) 而定义按商品(699),则以购物车实际 `unitPrice` 计算节省额,`unitPrice` 口径决定是否触发。)
+(注:基础款口径由业务按实际销售形态决定——`child_sku_id` 指定则按 SKU,空则按商品;节省额始终以下单口径 `unitPrice` 计算。)
 
 ## 我的决定(Builder 可直接沿用)
 
-- 节省额按**购物车实际 unitPrice** 计算(保证真实省钱才触发),非配置原价。
+- **后端权威**:下单扣款由 `ProductOrderService` 服务端重算套餐优惠,前端 `discountAmount` 不采信(防伪造、保一致)。
+- 节省额/可触发判断均用**下单结算口径单价**(会员调整价/SKU 价),保证"省"是真实结算口径的省。
+- 匹配身份 = **linked-SKU 解析后**身份,与下单结算一致。
+- 「移除一口价」范围 = 仅 `CartService.computeGiftPromo` 结算路径;`ProductOrderService` 送会员权益读取保留。
 - 定价套餐行 `bundle_product_id` 置 NULL(避免误触发出库联动)。
 - 贪心(每轮取节省额最大者)为算法实现;全局最优 DP 列为可扩展点,本期不做。
-- 送会员权益触发(`gift_membership_threshold`)保留,不在本次范围。
-- 管理端"套餐锚点 SKU"配置增强列为**可选**;本期核心交付 = 迁移 + 后端定价 + 前端展示。
+- **不随迁移发布套餐种子数据**:示例 SQL 提供,由业务确认后配置(避免依赖生产 `product_skus.id=3` 等行号)。
+- 现有 `attachBundleMatches`/`BundleMatch` 死代码**保留不动**(记技术债:与新增定价算法并存,后续统一清理)。
 
 ## 测试策略(TDD)
 
-- 后端单测 `BundlePricingService`:单套餐命中 / 凑整+兜底(买3套1+单品1)/ 多套餐省最多 / 节省额≤0不触发 / 跨SKU聚合 / 无套餐定义时不触发 / 废弃一口价后无一口价字段。
+- 后端单测 `BundlePricingService`:单套餐命中 / 凑整+兜底(买3套1+单品1)/ 多套餐省最多 / 节省额≤0 不触发(会员价低于套餐价场景)/ linked-SKU 解析匹配 / 无套餐定义不触发 / 一口价不再产生。
+- 后端单测 `ProductOrderService`:下单 `moneyAmount` 含套餐优惠;前端 `discountAmount` 伪造值被服务端重算覆盖(照实收);送会员权益(`gift_membership_original_threshold`)不受影响。
 - 前端纯函数(如 `utils/bundle-pricing.js`):聚合、节省额、贪心选择。
 - 迁移幂等:重复执行不报错(列已存在忽略)。
 
 ## 验证(部署后)
 
 - 购物车放 2 个基础款(配了套餐定义)→ 页面提示"按套餐价 X,省 Y"。
-- 下单金额 = 套餐价 + 单品兜底,与显示一致。
+- 下单扣款金额 = 套餐价 + 单品兜底,与显示一致(服务端重算)。
+- 篡改前端折扣参数 → 实际扣款仍按服务端重算值(防伪造)。
 - 无套餐可匹配 → 正常价结算,无一口价 1980。
-- 管理端(若做增强)可配 `bundle_sku_id`。
+- 送会员权益(单笔订单/累计达标)仍正常触发。