写点什么

可组合商品套餐:Shipt Kits 平台内部解析

作者:Khurshid Hameed
  • 2026-10-28
    北京
  • 本文字数:6336 字

    阅读完需:约 21 分钟

上线发布

Shipt 的 Kits 是为特定时刻而生的——比赛日、节日晚餐、周末招待。从商品运营人员决定上线一个主题套餐到顾客真正需要它,中间可能只隔了几天,而不是几周。下文描述的每一个架构决策都受制于这个约束

2026 年 6 月,Shipt 上线了 Kits,这是一个一键加购购物车的功能,旨在帮助顾客规划招待活动。产品上线恰逢一场全球性足球赛事,公司借助明星合作,通过全国品牌活动推广 Kits,重点展示观赛派对、后院聚会、户外烧烤等场景。顾客进入“本周比赛”页面后只需点击一下就能把一整晚所需的全部食材——配菜、饮料、零食、甜点——全部加入购物车。每件商品都具备门店感知能力:你在门店看到的套餐,展示的是你附近门店货架上实际在售商品,而不是一份通用目录。

该活动被 Progressive Grocer、Supermarket News、¡HOLA! 和 Better Homes and Gardens 等媒体报道。从外部看,这是一个成熟亮眼的新功能。

但在内部,团队只有三周时间把它做出来。API 用了十天,剩下的十一天用于搭建管理后台、面向顾客的界面,以及生产环境稳定性加固,把原型打磨成商品运营人员可无人值守使用的系统。

本文将介绍在这三周内搭建的系统架构:平台如何用一个后台管理界面替代耗时数日的人工流程;如何抽象出三个可组合的一等实体;如何实现端到端门店感知商品目录;如何支持线上实时编辑,且无需完整的草稿发布生命周期。

Kits API 位于面向商品运营人员的 Admin UI 与实体层之间,通过门店感知的解析和套餐类型合规规则决定顾客最终看到的内容,并由编辑安全护栏保护变更过程。

在 Kits 之前:手动组装工作流

在 Kits 出现之前,在 Shipt 构建一个主题购物体验是这样的。商品运营人员想要组装一个“车尾野餐套餐”需要:

  1. 查询数据仓库,找出符合主题的产品。

  2. 从查询结果中手工挑选相关的 UPC。

  3. 将每个 UPC 转成零售商专属的产品 ID——同一实体商品在 Target、Meijer、Publix 等渠道对应不同的 ID。

  4. 用这些零售商专属 ID 构建一个分类。

  5. 基于分类构建一个商品列表。

  6. 配置到套餐需要展示的位置。

完成全部操作后,仍无法保证套餐在每家门店都能正常展示。整个流程依赖商品 ID,不考虑门店库存。因此在管理工具中看起来很完整的套餐,在顾客实际所在的门店可能缺了一半的商品,而运营人员无法提前预知。

对于只持续一周的临时性促销活动,这尚可容忍,但对于按照固定节奏推出数十个新套餐的全国性活动——横跨多个零售商、多个区域、多周时间——这根本不可接受。当活动获批且上线日期固定后,团队只有三周时间构建一个系统,让商品运营人员只需描述一次套餐,平台就能自动处理按门店、按零售商的具体化工作。

三个一等实体:商品项、购物清单、套餐

第一个架构决策也是最重要的一个:将平台抽象为三个可组合实体,而不是一个单一的“套餐”类型。

该思路借鉴了领域驱动设计(DDD)的设计词汇。商品项(Item)、购物清单(Shopping List)和套餐(Kit)构成了贯穿代码、管理界面和商品运营沟通的统一语言,并且每一个实体都是拥有自身不变量的聚合。套餐作为聚合根,管理文案与结构元数据;商品项位于独立限界上下文,通过引用复用,不会在每个套餐内重复拷贝。尽早明确这些边界,那么后续的功能工作——新套餐类型、广告变现、按门店解析——都是增量式的,而不是破坏性的。

商品项是原子化的目录单元——通用、带类型、带标签。商品项知道自己是什么类型的东西(产品、食材、套餐中的非商品元素),可通过 JSON 标签集合带上元数据。商品项不特定于某个零售商;解析为对应门店商品 ID 的动作发生在生成视图时,而不是在建模时。

购物清单是一组精选的商品项序列。购物清单可以是商品运营人员配置并附加到套餐上的(例如赛事套餐内“聚会采购清单”),也可以是顾客自己创建的(顾客跨会话维护的个人杂货清单)。同一套购物清单抽象同时支持两种场景,这意味着平台只需要维护一种模型和 API,而不是两套并行。

套餐是可组合的打包商品——顾客看到、点击并加入购物车的对象。套餐带有编辑元数据(标题、描述、主图、主题)、结构元数据(槽位布局、类型、锚点商品),并引用商品项,也可选择性地引用购物清单。

该平台最具决定性的属性是:商品项在购物清单和套餐之间通过引用的方式共享。同一款亨氏番茄酱商品项可用于车尾野餐套餐、后院烧烤套餐、家庭野餐套餐、顾客个人购物清单以及其他二十多处页面。更新商品项的元数据会传播到所有使用它的地方。为商品项添加一个新的零售商映射关系,所有引用它的套餐、清单都会自动生效。

商品项大致结构如下:

{"id": "itm_01H2N7X...","item_type": "product","canonical_name": "Heinz Tomato Ketchup, 20 oz","tags": {"category": "condiments","dietary": ["vegetarian", "gluten-free"],"seasonal": ["summer", "grilling"],"brand_id": "brand_heinz"}}
复制代码

tags 字段采用无模式 JSON 集合设计——新的维度(膳食属性、季节属性、促销属性或商品运营人员及下游服务需要的筛选条件)可以在不迁移数据库的情况下添加。零售商专属产品 ID 不放在商品项中,它们会在生成视图时按门店解析,这是下一节的内容。

这次迁移把商品项统一为唯一可信源——套餐商品、购物清单商品全部指向同一批商品项数据,技术实现很平淡,但收益立竿见影——维护成本下降了。新增套餐类型成本很低,新类型只需要关联商品项,不需要维护独立的商品项表。

锚点商品与套餐属性

套餐不只是一堆商品的集合。有些商品决定了套餐的属性。没有肉类的夏日烧烤套餐不是真正的夏日烧烤套餐,没有意面的意面晚餐套餐不是真正的意面晚餐套餐,不含酒类的酒水套餐从法律和概念上看都变成了别的商品。

团队引入了锚点商品的概念。锚点商品是指这样一种商品:它的缺失——在特定门店、特定日期——会使整个套餐失去在该门店展示的资格。商品运营人员在创建套餐时标记锚点商品,解析层在决定是否展示套餐之前会先检查锚点商品的可用性。

锚定商品同时定义了套餐最低可用商品数量阈值。套餐可以要求:除全部锚定商品必须可售外,顾客门店至少要有例如 8 件非锚定商品可用,才展示套餐。锚定商品与数量阈值组合生效:套餐展示条件为所有锚定商品在该门店可解析且可用商品总数达到阈值。

因此,平台绝不会向顾客展示一个半空的套餐。套餐要么在当前门店完整可用,要么完全不出现在顾客商品目录里。对于这种人工精选的内容化产品来说,这正是团队想要的产品约定。

按门店、按顾客解析套餐

顾客看到的每个套餐背后都会执行集合运算,决定查询哪些零售商的库存。给定邮编的顾客可能由一组杂货零售商提供服务——本地的 Hy-Vee、Target、Meijer 和 Giant Eagle。每个套餐都有自己的零售商白名单。某些套餐——最典型的是酒类——还有额外的门店级别的许可约束。

解析结果是三者的交集:

effective_retailers = customer_zip_retailers ∩ kit_allowed_retailers ∩ kit_eligibility(store)

对于酒类套餐,资格检查会过滤出顾客所在区域持有有效酒类经营许可的门店。对于没有特殊资格要求的套餐,第三个集合不受约束。对于全国性活动的通用套餐,白名单覆盖 Shipt 的运营区域。每一层都可以独立配置,在请求时高效计算出交集。

一旦确定了有效的零售商集合,平台就会对每个零售商的目录发起并发搜索,收集结果,并组装顾客端套餐。商品项根据零售商的实际库存进行评分,检查锚点商品,验证最小阈值,然后要么展示套餐,要么悄悄从顾客视图中移除。

并行扇出查询至关重要。一个都会区邮编的顾客可能会匹配到四家零售商,套餐页面加载必须足够快,做到即时响应。按顺序逐个查询零售商,无法满足团队设定的延迟指标。采用并行扇出查询,再结合单零售商超时与优雅降级机制,无论顾客匹配多少零售商,页面感知延迟都能保持平稳。

每个零售商请求独立执行,有各自固定的超时时间,而不是共用一个超时。如果某个零售商在该时间窗口内没有响应,会从顾客的套餐视图中将其剔除,不会阻塞或导致整个请求失败。这样,顾客感知延迟被固定的上限约束,不会随零售商数量增加,也不会因为单个慢请求卡住页面。

酒类套餐:当合规性成为套餐类型

酒水场景最能说明为什么团队选择套餐类型作为抽象方案。

普通套餐生命周期偏向内容运营:运营创建,设置主题与商品,发布上线。酒水套餐的生命周期是内容运营加合规校验:同样的创建流程,外加单独的门店资格检查(门店是否持有有效酒类许可)、商品级别过滤(仅包含酒类商品,避免把混合饮料误判为酒类)、区域限制(如果顾客的邮编超出零售商的许可配送区域,就对该顾客隐藏酒类套餐)。

我们把所有这些作为酒类套餐类型的一部分,而不是对通用套餐做特殊处理。套餐类型自带准入判断逻辑;运营人员不需要手动配置酒水规则,类型会自动应用。新增合规约束只需扩展该类型判断逻辑,无需改动通用套餐主流程。

团队预计会有更多这样的类型——药房商品、年龄限制内容、受监管品牌。每一个都适合作为自己的套餐类型存在,而不是在通用套餐上加布尔标志。

站内广告变现,不破坏套餐完整性

平台的第一个商业化场景是站内广告变现。一个消费品品牌付费在套餐内获得重点展示位——他们的产品出现在指定槽位中,带品牌标识,有时还附带促销定价。

实现这个功能需要有品牌注册表、套餐维度的品牌关联配置,而团队很快会遇到一个关键问题:如果付费品牌商品在顾客门店缺货该如何处理?

两种直观的方案都存在缺陷。在没有赞助商品的情况下展示套餐会违反赞助协议——品牌付费购买的是展示位,商品被悄悄移除,会留下尴尬的空缺位置。隐藏整个套餐则走向另一个极端——一款赞助商饮料缺货不应该让整个比赛日套餐在该顾客面前无法展示。

该行为属于合同约定,因此我们将其设为可按套餐配置。brand_fallback 字段有两个取值:hide item(撤下赞助商品,渲染套餐其余部分)或 hide kit(在此门店不展示该套餐)。商品运营人员在套餐设置时根据品牌合同选择策略,由解析层在页面渲染时执行。

品牌注册表按照固定周期从 Shipt 上游商品目录系统同步。同步采用流式、幂等设计:分页读取上游目录,更新本地品牌表,只写入发生变更的数据。同步中途失败后的重试是安全的,正确的数据不会重复处理,不一致的数据会在下一轮自动修正。

编辑安全:实时编辑的多层护栏

运营人员在生产环境直接编辑套餐。顾客加入购物车、下单时会生成商品快照,实时编辑不会导致历史订单发生变化,但套餐本身是可变的,保存后立刻对后续顾客生效。标准方案是采用完整的草稿发布流程(草稿数据、原子发布、差异界面、快照),但需要额外的 2 到 4 周开发时间。

在上线窗口内没有这个时间,因此团队改用四层防护方案:

  • 第一层:持久化警告。套餐处于发布状态时,页面持续显示琥珀色横幅。它比管理界面的常规元素更醒目,作为持续提醒,表明这是生产数据。

  • 第二层:首次编辑确认。对线上套餐的任意控件进行修改都会弹出确认弹窗,弹窗按标签页、套餐维度记忆保存。新开标签页会重新进行确认,无法通过单次点击批量授权全目录修改。14 个变更入口复用同一钩子。“线上”状态读取的是套餐的持久化字段,而不是页面的本地状态下拉框,即使下拉框切换到了草稿,只要套餐仍对顾客可见,警告就不会消失。

  • 第三层:分区独立保存与放弃。放弃部分字段自动保存、部分数据切换后自动提交的混合模式,取而代之的是统一模式:保存、放弃、“存在未保存修改”提示,以及浏览器关闭前的提示。两个基础组件(通用表单脏数据钩子、页面级脏数据注册表)保障了跨分区的状态一致性。食谱与购物清单占位操作为即时生效动作,不是表单草稿。

  • 第四层:跨分区锁。当分区元数据存在未保存变更时,即时操作会被拦截,保证所有操作都是基于已持久化的可靠数据。只需用一行代码读取第三层的脏数据注册表,实现成本极低,但也是在观察了真实的编辑流程后才明确平台确实需要这项能力。

四层防护方案实现了草稿发布系统约 70% 的能力,但开发成本仅 10%,足以安全支撑营销活动,完整的草稿发布机制已列入路线图。

后续规划

接下来的三项投入方向清晰,属于增量构建而非重构——平台架构本身支持这些扩展。

完整的草稿发布生命周期

分层护栏的 70% 能力虽然已经很不错,但草稿发布机制可以提供 100% 的能力。还差的 30% 目前体现在我们目前支持不佳的工作流中:商品运营人员跨多天编辑套餐、编辑审核员在变更上线前进行审批、变更审计日志、线上变更引发故障时自动回滚。

自活动上线后,我们已经迈出了第一步——增加 Testing 状态,让套餐在生产环境上线,但仅对内部测试白名单可见,因此内部审核员能在真实顾客之前看到真实的顾客界面。生命周期现在是一个真正的流水线(草稿 → 测试中 → 已发布 → 已归档),而不是一个二元开关。按字段草稿、原子发布、差异界面和审计追踪是下一阶段的开发目标。

具有完整 ROAS 归因的品牌赞助套餐

广告变现目前支持展位投放。下一步是闭环归因:品牌付费获得展示,平台按套餐、套餐内单品、曝光维度统计并反馈归因收入。事件数据已经存在,只需进行关联查询即可,主要工作量在报表页面。

基于顾客偏好过滤套餐商品

现在的解析逻辑是按门店维度。下一个维度是按顾客:一个设置了“不吃猪肉”或“无麸质”偏好的顾客应该看到一个尊重其偏好的车尾野餐套餐。这是解析层的一个额外的交集,与已有的按门店交集并列。锚点商品和最小阈值需要按顾客而非按门店重新评估,不过其影响范围是可控的。

给同类项目开发者的建议

如果早点意识到下面三点,可以节省大量时间:

  • 选择少量可组合实体,克制增加更多实体的冲动。我们只有商品项、购物清单和套餐三个实体。增加第四个实体(“集合”、“推荐位”、“轮播”)的压力始终存在,但几乎都是没有必要的。每增加一个新实体,平台的复杂度就乘数级增长,以引用方式共享已有的三个实体几乎能做到新实体想做的一切,但少了一个需要额外维护的负担。

  • 把真实业务约束建模成类型,而不是标志。酒水是典型案例。酒类是最明显的例子。下一个例子会是别的什么——药房商品、受监管品牌、受年龄限制的内容。每一类做成独立套餐类型,自带准入判断逻辑,不要在通用套餐表新增布尔字段。

  • 先交付可用版本,再基于真实使用场景完善剩余能力。多层编辑防护不是最优方案,只是适配上线窗口的折中方案。但它们已经足够好,足够运营人员在生产环境中使用,同时帮助团队理解真实场景下草稿发布系统需要哪些能力。如果我们试图在第一周就构建完整的生命周期,就只能基于猜测而非真实的编辑行为进行设计。

Shipt Kits 已经上线几周了,在真实的商品运营工作流和真实的顾客流量中运行着上述护栏、解析和合规层。团队现在正基于真实使用情况而非假设规划下一阶段的开发工作——草稿发布、ROAS 归因、按顾客过滤。

总结

上文案例是关于生鲜套餐的,但底层的建模问题并不局限于生鲜,甚至不限于电商。任何让顾客把多个东西组合成一个可购买或可消费单元的平台——旅行套餐、订阅礼盒、预制菜套餐、门票组合、精选内容集合——都会遇到相似的问题:在系统变得不可管理之前,应该引入多少个一等实体;当位置、库存、法规、定价等多条独立约束同时生效时,如何解析资格;如何让套餐拥有可靠的产品定义,不会悄无声息地失效;如何支持强监管、异常场景多的品类,不在核心模型堆砌大量特殊分支。

这些问题没有一个通用的正确答案。正确的抽象层级取决于新约束或新品类出现的频率,以及团队可承担的前期运维成本。在本案例中被证明成立、且在其他地方也往往成立的是:解决方案很少来自更多的流程或文档,而是在模型中显式定义实体与约束,尽早设计,让系统依靠组合扩展,而不是不断增加特殊分支。

感谢 Marc Kapke(Shipt 高级首席工程师)的审阅和技术反馈,感谢 Shipt Marketplace 团队的合作,使这个平台成为可能。

查看英文原文:https://www.infoq.com/articles/shipt-composable-commerce-design-ddd/