Przeglądaj źródła

docs: SKU规格价格统一分单位 + 详情页规格按钮响应式修复设计文档

asus 1 miesiąc temu
rodzic
commit
d25a378e7d

+ 115 - 0
docs/superpowers/specs/2026-08-15-sku-price-cents-and-spec-selector-design.md

@@ -0,0 +1,115 @@
+# SKU 规格价格单位统一(分)+ 详情页规格选择按钮逻辑修复 — 设计
+
+日期:2026-08-15
+状态:待评审
+
+## 背景与问题
+
+用户报告两个相互关联的缺陷:
+
+1. **规格价格显示错误**:小程序商品详情页选中规格后,规格价格显示为"后台填写值 ÷ 100"。例:管理端填 10(元),小程序显示 ¥0.10。
+2. **规格选择后按钮不变化**:有规格的商品,即便选择完所有规格,底部"加入购物车/立即购买"按钮仍保持"请选规格/请选择完整规格"的禁用状态。
+
+## 根因分析
+
+### 缺陷 1:SKU 价格单位混乱(元/分混用)
+
+| 层 | 现状 | 问题 |
+|----|------|------|
+| DB `product_skus.price` | `INT COMMENT 'SKU价格(分)'` | 列本身语义是**分** |
+| 实体 `ProductSku.price` | `BigDecimal`(**元**语义) | 与列注释冲突 |
+| 管理端 ProductSkuEditor | `el-input-number :precision="2"` 输入**元** | 元值直接写入 INT 列被截断 |
+| specMap 接口(ProductController L132) | `option.put("price", sku.getPrice())`(BigDecimal 元) | 前端拿到元 |
+| 小程序详情页 | `formatPriceWithSymbol(displayPrice)` 一律 ÷100 | 元 ÷100 → **显示缩小 100 倍** |
+| 订单 ProductOrderService L464 | `sku.getPrice().multiply(100)`(元→分转换) | 订单金额反而是对的 |
+
+**结论**:specMap 返回"元"、前端当"分"÷100 → 用户看到"后台价值÷100"。同时管理端录入 10.50 元会被 INT 列截断为 10(分),小数价格静默丢失。`Product.price`(商品价)是 Integer 分,与 SKU 不一致。
+
+### 缺陷 2:Vue 2 响应式失效导致按钮不更新
+
+`product-detail.vue` 的 `selectSpec` 方法:
+
+```js
+selectSpec(groupId, option) {
+  if (this.selectedSpecs[groupId] === option.id) {
+    delete this.selectedSpecs[groupId]   // 删除属性 — Vue2 不可侦测
+    this.selectedSku = null
+  } else {
+    this.selectedSpecs[groupId] = option.id  // 新增属性 — Vue2 不可侦测
+    this.matchSku()
+  }
+  this.$forceUpdate()
+}
+```
+
+- `selectedSpecs: {}` 初始为空对象,`this.selectedSpecs[groupId] = option.id` 是**对象新增属性**,Vue 2(Object.defineProperty)无法侦测 → 依赖它的 computed(`allSpecsSelected`、`buyButtonDisabled`、`buyButtonText`)不失效 → `$forceUpdate()` 只强制重渲染但不重算已缓存 computed → **按钮文案永远停留在"请选规格"**。
+
+## 方案设计
+
+### A. 后端:SKU 价格统一为"分"(Integer)
+
+1. **实体 `ProductSku`**:`price`/`originalPrice` 从 `BigDecimal` → `Integer`(分)。`purchasePrice`(进价)保持 DECIMAL 不动(管理端不进价,无显示问题)。
+2. **DTO `ProductSkuDTO`**:`price`/`originalPrice` 同步 `BigDecimal` → `Integer`。
+3. **`ProductController.specMap`**:`option.put("price", sku.getPrice())` 无需改代码——实体变 Integer 后天然返回分。前端 `formatPriceWithSymbol(分)` → 正确显示。
+4. **`ProductOrderService.createMultiItem` L463-465**:删除 `×100` 转换,直接 `unitPrice = sku.getPrice()`(已是分)。
+5. **`CartService.buildCartItemDTO` L149**:有 skuId 时 `unitPrice` 用 `sku.getPrice()`(分),无 skuId 用商品价——修复"购物车显示商品价而非规格价"。
+
+### B. 数据库迁移(DatabaseInitializer.runMigrations)
+
+- **存量数据 ×100**:现有 `product_skus.price` 列里存的是"元语义"(10 = 10 元,因 BigDecimal 写入 INT 截断)。统一为分后需 `price = price * 100, original_price = original_price * 100`。
+- **幂等标记**:`sys_config` 表(已有,config_key UNIQUE)写入 `sku_price_to_cents_done = 1`;迁移执行前先查标记,已迁移则跳过。
+- **schema.sql**:`price INT COMMENT 'SKU价格(分)'`、`original_price INT COMMENT '原价(分)'` 注释已是分 ✓ 无需改 DDL;仅追加注释说明数据语义(可选)。
+- 迁移编号:按 DatabaseInitializer 末尾最新 `// 迁移N` 递增。
+
+### C. 管理端 ProductSkuEditor.vue(元 ↔ 分转换)
+
+与 `ProductEdit.vue`(ActivityEdit/CouponManagement 同款模式)对齐——**录入保持元,边界 ×100/÷100**:
+
+- 保存 `handleSave`:`price: Math.round(parseFloat(this.skuForm.price) * 100)`;`originalPrice` 同。注意 `pendingSkus` 暂存路径(新建商品时)同样 ×100。
+- 回显 `showDialog`:`price: sku.price ? (sku.price / 100) : 0`(现在接口返回分)。
+- 表格 `formatPrice`:`'¥' + (parseFloat(val) / 100).toFixed(2)`。
+- 校验 `skuRules` 不变(仍输入元)。
+
+### D. 小程序详情页 product-detail.vue
+
+1. **修复响应式**:`selectSpec` 改用 `this.$set(this.selectedSpecs, groupId, option.id)` / `this.$delete(this.selectedSpecs, groupId)`,删除 `$forceUpdate()` → computed 自动更新,按钮实时变化。
+2. **按钮始终可点**(用户已确认"不选规格也可直接购买"):
+   - `buyButtonDisabled` 移除禁用逻辑(或恒 false)——但保留库存为 0 的禁用?见下。
+   - `buyButtonText` 固定"立即购买",不再显示"请选择完整规格"。
+   - 加购按钮文案固定"加入购物车"。
+3. **定价规则**:不选规格 → 商品价(`product.price`,分);选了规格 → 规格价(`selectedSku.price`,分)。已由 `originalPriceVal`/`actualPrice` computed 覆盖(SKU 优先)。
+4. **`onBuy`/`onAddToCart`**:去掉 `buyButtonDisabled` 拦截;`selectedSku` 存在才传 `skuId`。价格显示逻辑不变(`actualPrice` 已正确取规格价)。
+5. 库存边界:`displayStock` 已显示规格库存/商品库存;未选规格时按钮可点但若商品库存为 0 后端会拒绝——保持现状,不额外加禁用。
+
+### 影响范围(改动文件清单)
+
+| 文件 | 改动 |
+|------|------|
+| `cfc-backend/.../entity/ProductSku.java` | price/originalPrice: BigDecimal→Integer |
+| `cfc-backend/.../dto/ProductSkuDTO.java` | 同步类型 |
+| `cfc-backend/.../controller/product/ProductController.java` | specMap 无代码改动(类型传导) |
+| `cfc-backend/.../service/ProductOrderService.java` | L464 删 ×100 |
+| `cfc-backend/.../service/CartService.java` | buildCartItemDTO 用 SKU 价 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 迁移:存量 ×100 + sys_config 标记 |
+| `cfc-backend/src/main/resources/schema.sql` | 注释微调(可选) |
+| `cfc-web/src/views/admin/components/ProductSkuEditor.vue` | 元↔分转换 |
+| `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | $set/$delete + 按钮逻辑 |
+
+**不改动**:`Product.price`(已是分)、商品管理端录入(已是元→×100 模式)、其他价格显示页面。
+
+### 验证方案
+
+1. `cd cfc-backend && mvn clean compile`
+2. 迁移幂等:重启两次,确认 ×100 只执行一次(sys_config 标记)。
+3. 管理端:新建 SKU 价格 10.50 元 → 保存 → 列表显示 ¥10.50;编辑回显 ¥10.50。
+4. 小程序:specMap 返回 price=1050 → 详情页显示 ¥10.50;选中规格后按钮变"加入购物车/立即购买"。
+5. 购物车:选规格加购 → 购物车单价显示规格价。
+6. 订单:选规格下单 → 订单金额 = 规格价 × 数量(分)。
+7. 存量数据:迁移后 10 → 1000(分),订单金额不变。
+
+### 风险与注意事项
+
+- **存量数据语义假设**:现有 INT 值全部视为"元"(×100 前)。若存在已按"分"手工录入的数据会翻 100 倍——经查唯一写入入口是管理端(元语义),风险可控。迁移前可先 `SELECT id, product_id, price FROM product_skus` 抽查。
+- **ProductSkuEditor 新建商品暂存路径**:`pendingSkus` 里 price 已 ×100,`flushPendingSkus` 直传——保持一致。
+- **小程序端不打包**:改代码后需用户在 HBuilderX 重新打包(AGENTS.md 约定)。
+- **无规格商品**:`hasSku=false` 时按钮行为不变(始终可点、商品价)。