2026-08-15-sku-price-cents-and-spec-selector-design.md 7.8 KB

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 方法:

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 时按钮行为不变(始终可点、商品价)。