2.1 商品模型:SPU 与 SKU 集合设计

场景

开工第一件事不是写列表页,而是和店员一起盘货。店里两百来个 SKU,手工器物的规格特别碎:同一款杯子,白釉、青釉、窑变三个釉色,每个釉色又有 200ml 和 300ml 两个容量,价格还各不相同;有的款式是现货,有的是预售款,预售款要在标题上打角标;上新的批次、产地、匠人,朋友希望都能展示出来。

我们花了两个下午整理出来一份字段清单,过程很朴素:把最近三个月的进货单全翻一遍,每一列都问一句“顾客买的时候会看吗、店员管货时会改吗”。顾客不看的坚决不进字段,这一刀砍掉了供货商编号、入库成本等一堆内部信息——它们继续留在 Excel 里,小程序只管卖货不管记账。朋友本来纠结了好几天的字段取舍,被这个标准一筛,当场就清爽了。

最初店员在 Excel 里管货,一张表里同一个杯子出现六次,图片链接复制六遍,改一次价格要在六个地方动手。大促前她要批量改价,对着表格按 Ctrl+F 找了半小时,改完还是有四处没对上,当晚超卖了两个釉色。朋友问我:小程序里能不能就把 Excel 搬上去?我说能,但你会比现在更累。商品模型设计的第一性原则是:把“这件商品是什么”和“这件商品具体哪个规格、多少钱、有几件”分开存。前者是 SPU,后者是 SKU。

除了结构,还有两个硬约束得考虑进去:一是店员上新时手机操作居多,字段越少越好,最好一个页面填完不用滑动;二是朋友要的预售和限购都发生在规格级别,不是商品级别,字段必须落在 SKU 上。这两条逼着模型往“SPU 轻量、SKU 厚实”的方向走。

字段命名也定了规矩:一律驼峰、布尔值用 is 前缀、金额一律 price 加单位分、时间一律毫秒时间戳。听上去是芝麻小事,但全队统一命名之后,前后端联调再没为字段意思扯过皮,新同事看集合结构一眼就能上手。

方案

我设计了 goods 与 skus 两张集合,用 spuId 关联。goods 存展示信息——标题、封面、图文、分类、标签;skus 存交易信息——规格、价格、库存、限购、上下架。拆分的收益直观:改一个规格的价格只动一条文档;详情页从 SPU 取图,列表从 SKU 取价,各取所需。朋友的店员原本最怕“上新”这两个字,拆表之后第一天独立上了三款新品,没出任何错,还自己琢磨出了批量改价的门道。

字段类别 所属集合 例子
展示信息 goods 标题、封面、图文详情
交易信息 skus 价格、库存、限购
规格描述 skus 白釉 / 300ml
关联键 两者都有 spuId
活动标记 skus activityId、是否预售

这样设计还顺带解决了 Excel 时代的另一个痛点:同一款杯子的图只存一份,靠 goods 的封面字段引用,再也不会六个单元格贴六遍链接。

有同行问过为什么不干脆一张大表存完,字段平铺。我当时算过一笔账:一张表的话,每个规格都是完整一行,SPU 的图文详情几千个字符要重复存六份,改一次文案要同步六处;而拆两张表之后,图文只存一份,SKU 行非常窄,列表查询时只取需要的字段,网络包小一大圈。更重要的是单条 SKU 的价格、库存修改变成原子操作,这是我们后来敢扛大促并发的底气。分类和标签则刻意做轻:分类单字段 categoryId,标签存数组,不做树形结构——两百个 SKU 的量级用不上,店员也理解不了三层分类,剩下的扩展空间就留给真正需要的那一天。

配置/代码

两条集合文档的实际结构如下,字段命名在开工前就跟店员对齐过,尽量贴近她们嘴里的叫法:

// goods 集合:一件 SPU
{
  _id: "spu_2001",
  title: "手作柴窑马克杯",
  cover: "cloud://mini-shop-prod.xxxx/goods/spu_2001/cover.jpg",
  categoryId: "cat_cup",
  tags: ["匠人手作", "景德镇"],
  detailHtml: "<rich-text>...</rich-text>",
  createdAt: 1735660800000
}

// skus 集合:同一 SPU 下的一个规格
{
  _id: "sku_1001",
  spuId: "spu_2001",
  spec: "白釉 / 300ml",
  price: 12800,          // 单位:分
  stock: 60,
  limitPerUser: 0,       // 0 表示不限购
  status: 1,             // 1 在售 0 下架
  activityId: ""
}

价格一律用整数分存库,这是电商的基本卫生:浮点数经过前端展示和运算会出现 0.1 加 0.2 不等于 0.3 的经典事故,用分则永远精确。详情页一次性取 SPU 和它的全部 SKU,云函数里两步查询合并返回,顾客点进详情平均只等一次网络往返:

const spu = await db.collection("goods").doc(spuId).get();
const skus = await db.collection("skus")
  .where({ spuId, status: 1 }).get();
return { spu: spu.data, skuList: skus.data };

上下架也是 SKU 级别的:某釉色停产了,把那条 SKU 的 status 置 0 即可,SPU 的图文历史评价都不用动。库存为 0 时也可以自动下架,云函数里下个钩子,每次 createOrder 扣减后检查 stock 是否归零,归零自动置位 status,免得顾客把售罄款加进购物车又被拦下,体验很糟。批量改价也给店员做了个入口,一次更新一个 SPU 下全部在售 SKU,大促前一键七折,再也不用逐条改。这套结构后来支撑了大促改价、预售、限购三个需求,几乎没改过表。上新流程的效率对比也出来了:

环节 Excel 时代 模型化之后
上新一款三规格 复制六行、贴六遍图 一条 SPU 加三条 SKU
改单个规格价格 找六处改六处 改一条文档
停产某规格 整行删除怕误删 置 status 为 0

索引也一并建好:skus 按 spuId 建索引用于详情聚合,再按 spuId 加 status 建组合索引用于列表筛选在售款;goods 按 categoryId 建索引用于分类页。云数据库的索引在控制台点两下就能建,但一定要在建表当天就做,否则等数据填满再建会全表扫描拖慢接口。我们上线第一天就因为漏建 spuId 索引,详情页聚合查询慢了三百毫秒,建完立刻降到三十毫秒以内。这套索引习惯在搜索与筛选那节还会展开讲。

踩坑

第一个坑是规格字段不敢拍。店员一会说按釉色一会说按系列,我等了她三天才定下来“规格就是顾客下单前必须选的那些项”。结论:规格字段必须回答“顾客在详情页要点几下才能买”,而不是按店里的内部管理习惯。这个判断后来反复被验证,算是我这个项目做得最值的一次坚持。后来事实证明这个判断救命:大促时临时加了个“礼盒装”,挂在规格上一个选项就搞定,没动表结构,当晚就上线了。

第二个坑是价格存了元。早期一条草稿集合里 price 存的是 128.5,做退款计算时小数精度翻车,一笔 0.5 元的零头对不上账,只能连夜写脚本全部转成分。吃一堑长一智:钱的单位永远在第一天定死,展示层再除以 100 转元。

第三个坑是 SPU 与 SKU 的库存口径混。一开始朋友以为改 SPU 的一个“总库存”就行,其实库存只属于 SKU——白釉卖断了不代表青釉没货,反过来也一样。店员培训了二十分钟才转过弯,之后再没错过,还反过来提醒过我一次预售款的库存填错位置。

第四个坑是图片字段存了 tempFileURL。临时链接有有效期,详情页改版那天没注意刷新,第二天全店封面裂图,那天下午客服收到好几条反馈。统一换成 cloud:// 的 fileID,永久有效还省存储配额,迁移的坑在图床方案那节细说。

第五个坑是冗余字段舍不得删。设计时塞了一堆“以后可能用”的字段,两个月后大部分是空的,徒增调试成本。后来定规矩:新字段必须当下就有一个页面在用,否则不加。这条规矩让我们整个项目只用到三十二个字段,算是小而干净的典范。

小结

商品模型是整个商城的地基:SPU 管展示、SKU 管交易、价格用分、库存跟 SKU 走、图片用 fileID。这套结构定下来之后,后续的列表、详情、库存、支付都是照着它长出来的。模型设计的检验标准很简单——给店员讲一遍上新流程,她要是觉得比 Excel 还麻烦,就是设计错了。朋友第一次独立上新一整套餐具只花了四分钟,还自己改了个错别字,跟我吹了半天。下一节让这些商品在前端列表里刷起来,顺带把分页和骨架屏做了。

延伸阅读