背景:三週上線的全國活動
Shipt 是美國的生鮮與日用品代購配送平台,2026 年 6 月推出 Shipt Kits,讓顧客用一次點擊把一整個情境所需的商品加入購物車。這項功能綁在一場全球足球賽事的全國品牌活動上,透過名人合作曝光,主打夏季觀賽派對、後院聚會與烤肉情境。顧客在「Match of the Week」這類套組頁面上,可以一次加入當晚所需的配菜、飲料、零食與甜點。關鍵在於每個品項都是 store-aware:顧客看到的套組內容,反映其實際取貨店面當下貨架上的商品,而不是一份通用目錄。InfoQ 由 Khurshid Hameed 撰寫、Rafał Gancarz 審閱的這篇文章,記錄的正是這項功能背後的架構決策。從外部看,它是一個打磨過的新功能;從內部看,團隊只有三週。API 在十天內完成,剩下十一天用於管理後台、顧客端介面,以及把可運作的原型變成商品企劃團隊能獨立操作、不需旁人監督的營運強化。
在 Shipt Kits 之前,製作一個主題購物體驗是一條冗長的人工作業鏈。企劃若要做一個「尾門派對組合」,得先向資料倉儲查詢符合主題的商品,從結果中逐一挑出 UPC,再把每個 UPC 轉成各零售商專屬的商品 ID,同一個實體商品在 Target、Meijer、Publix 等通路的 ID 各不相同,接著建立分類、由分類建立商品清單,最後把清單掛到需要曝光的位置。六個步驟之後,套組仍不保證在每一家店正確顯示。整條流程綁在商品 ID 上,而不是各店庫存,後台看起來完整的套組,在顧客實際店面可能少掉一半品項,而企劃事前無從得知。一次性、只活一週的促銷尚可忍受;全國活動要在數週內、跨多個零售商與多個區域持續推出數十個新套組,這種流程根本不可能。
核心模型:三個實體而非一個
最關鍵的架構決定,是不把平台建成單一的「套組」型別,而是拆成三個可組合的實體:items、shopping lists 與 kits。這套做法沿用領域驅動設計(Domain-Driven Design,DDD,一套強調以領域概念組織程式碼與團隊語言的設計方法)的詞彙,把三者當成程式碼、管理介面與企劃溝通之間共用的 ubiquitous language,每個實體都是一個擁有自身不變條件的 aggregate(聚合,指一組必須維持一致性的物件邊界)。kits 是編輯與結構中介資料的 aggregate root;items 存在於自己的 bounded context 中,被各套組以引用方式共享,而不是複製進每一個用到它的套組。
items 是原子目錄單位,具備通用、型別化與標籤化三個特徵。一個 item 知道自己屬於哪一類,例如商品、食材,或編輯型套組中的非商品元素,並以 JSON tag bag 攜帶任意中介資料,包括分類、飲食屬性、季節性與品牌 ID。tags 刻意設計成無 schema 的 JSON 袋,讓新的過濾維度不必經過資料遷移就能加入。items 不綁定特定零售商,解析成某家店的商品 ID 發生在瀏覽時,而非建模時。shopping lists 是經過策展的 item 序列,可以是由企劃建立、掛在套組內的「派對採買清單」,也可以是顧客跨工作階段自行維護的個人清單;同一套抽象同時支撐兩者,平台因此只需維護一種心智模型與一組 API。kits 則是最終被顧客看見、點擊與加入購物車的組合,攜帶標題、描述、主視覺、主題等編輯中介資料,以及版位配置、型別、錨定商品等結構中介資料,並以引用方式指向 items 與可選的 shopping lists。
這個模型最關鍵的性質是引用共享。同一筆亨氏番茄醬 item,可以同時支撐尾門派對組合、後院烤肉組合、家庭野餐組合與顧客的個人清單等二十多個介面;更新 item 的中介資料會傳播到所有使用處,為它新增一個零售商對應,也會自動點亮所有引用它的套組與清單。把套組商品與清單項目統一指向同一批 item 資料列的遷移本身並不戲劇性,但成效立即顯現:維護成本下降,新套組型別的導入也變得便宜,因為新型別只需知道自己引用哪些 item,而不必管理一份私有的 item 資料表。
店面感知與集合交集
套組不只是 item 的集合,某些 item 對套組的身分至關重要。少了肉的夏季烤肉組合不再是夏季烤肉組合,少了義大利麵的義大利麵之夜也不再是。團隊用 anchor items(錨定商品)描述這件事:某個錨定商品若在特定店面的特定日期缺席,整個套組就失去在該店曝光的資格。企劃在建立套組時標記這些商品,解析層則在決定是否呈現套組前先檢查錨定商品是否可用。錨定商品也驅動每個套組的最低商品數門檻,例如除了所有錨定商品都必須存在之外,還要求顧客所在店面至少有八個非錨定商品可供應。兩者以組合方式生效,只有在全部錨定商品都能在該店解析、且可用商品總數跨過門檻時,套組才會顯示。平台因此不會讓顧客看到半空的套組:套組要嘛在今天的這家店說得通,要嘛完全不出現在顧客目錄中。
在顧客看到套組之前,系統要先做一次小型集合運算,決定查詢哪些零售商的庫存。顧客所在的郵遞區號可能對應一組可服務的生鮮零售商;每個套組又有自己的零售商允許清單;某些套組,最明顯的是酒類套組,還帶有取決於店家執照的額外資格限制。最終的有效零售商集合是三者的交集:顧客郵遞區號可服務的零售商、套組允許的零售商,以及套組資格判斷合格的店家。酒類套組的資格判斷會篩出顧客所在管轄區內持有有效酒類執照的店家;沒有特殊資格限制的套組,第三個集合不受約束;全國性活動套組的允許清單則涵蓋 Shipt 的營運區域。三層各自獨立設定,交集在請求時以極高效率計算。
得到有效零售商集合後,平台會對各零售商目錄併發展開查詢、收集結果,再組裝成顧客可見的套組,逐一為商品對照該零售商的實際庫存評分、檢查錨定商品、驗證最低門檻,最後決定呈現或悄悄從顧客視圖中移除。併發展開在營運上很重要:都會區的顧客可能符合四家零售商,套組必須在團隊設定的延遲預算內即時成形,逐家依序查詢會太慢。每個零售商呼叫各自獨立、平行執行,並使用自己的固定逾時,而不是整個展開流程共用一個逾時;某家零售商若未在時限內回應,它的結果就直接從該顧客的套組視圖中省略,而不會阻擋或讓整個請求失敗。顧客感受到的延遲因此被固定在一個上限內,不隨符合條件的零售商數量增加而變長,也不會被單一慢速零售商拖住。
合規與廣告:類型化與可配置
酒類是最能說明為何要以套組型別作為抽象的例子。一般套組的生命週期是編輯性的:企劃建立、設定主題與商品、發布。酒類套組的生命週期則是編輯加上合規:相同的建立流程,外加獨立的店家資格檢查,確認這家店是否持有有效酒類執照;外加商品層級過濾,只保留被歸類為酒類的商品,避免把調酒用飲料誤判為酒類;外加管轄區限制,顧客郵遞區號若落在零售商持照配送範圍之外,酒類套組就在顧客層級被隱藏。團隊把這些全部做進酒類套組型別,而不是在一般套組上到處特例處理。型別自帶資格判斷條件,企劃不需要記得套用酒類規則;新增合規限制只需擴充型別的判斷,不必更動一般套組的程式路徑。團隊預期還會有更多這類型別,例如藥品、年齡限制內容與受管制品牌,每一個都適合獨立成型別,而不是在一般套組上再加一個旗標。
平台上的第一個營收介面是站內廣告變現。消費性包裝商品品牌付費在套組內取得顯眼版位,商品出現在指定位置、帶品牌標示,有時搭配促銷價格。這需要品牌註冊表、每個套組的贊助關聯,以及一個團隊很快就遇到的問題:贊助品牌的商品在顧客所在店面缺貨時該怎麼辦?兩種直覺答案都不乾淨。顯示套組但不顯示贊助商品會違反贊助協議,畢竟品牌付了版位費;直接隱藏整個套組則更糟,一個缺貨的贊助飲料不該讓顧客的整個比賽日套組消失。由於這是合約行為,團隊把它做成每個套組可設定:brand_fallback 欄位可取「隱藏商品」或「隱藏套組」兩種值,企劃在設定套組時依品牌合約選擇,解析層在瀏覽時強制執行。品牌註冊表本身則以固定節奏從上游目錄系統同步,同步採串流且冪等:分頁讀取上游目錄、更新或插入本地品牌表,且只寫入真正會改變的值;部分失敗後重跑是安全的,已正確的資料列不會有動作,不一致的資料列會在下一輪收斂。
編輯安全的分層防護
商品企劃是在活的系統上編輯套組。顧客購物車與已下訂單是在加入購物車與下單時取得的快照,因此即時編輯不會改寫歷史;但套組本身可變,每次儲存都會被下一位顧客看見。教科書解法是完整的草稿發布生命週期,包含草稿資料列、原子發布、差異比對介面與快照,代價是額外二到四週的開發時間,而上線窗口並沒有這段時間。團隊因此改採四層防護。第一層是常駐警告:套組一經發布,管理介面就持續顯示琥珀色橫幅,刻意比一般後台慣例更醒目,因為這是唯一持續提示目前操作的是正式資料的訊號。第二層是首次編輯確認:碰觸活套組上的任何控制項都會觸發模態視窗,且每個分頁、每個套組各自記憶,新分頁會重新提示,單次點擊也不能授權整份目錄的批次編輯;十四個會產生變更的呼叫點都走同一個 hook,而「是否為活套組」取自持久化的狀態,不是畫面上的狀態欄位,因此把下拉選單切到草稿並不會在套組仍對顧客生效時讓警告消失。
第三層是每個區段各自具備明確的儲存與捨棄。原本混合式模型,也就是有些欄位自動儲存、有些切換即送出,被換成一致的做法:儲存、捨棄、未儲存變更提示,以及瀏覽器原生的離開頁面提醒。兩個基礎元件,通用的 dirty form hook 與頁面層級的 dirty registry,組合成跨區段的保證。食譜與購物清單版位的編輯維持即時生效,因為它們是動作而不是表單草稿。第四層是跨區段鎖:當區段中介資料處於未儲存狀態時,這些即時動作會被短路,確保它們永遠對已持久化的真實資料操作。這只是對第三層 dirty registry 的一行檢查,成本很低,但唯有觀察真實編輯流程才會發現平台需要它。四層合計涵蓋了真正草稿發布系統約七成的效益,成本大約只有一成,足以安全推出這次活動,而完整生命週期已列入後續路線圖。活動之後團隊踏出第一步,加入 Testing 狀態,讓套組在正式環境上線但只對全域測試人員允許清單可見,內部審查者能先看到真實顧客介面;生命週期也從二元開關變成 Draft、Testing、Published、Archived 的實際流程,欄位層級草稿、原子發布、差異比對與稽核軌跡則是下一批工作。
影響與限制
接下來三項投資方向已相當明確,而且都屬於擴充而非重工,因為平台結構已能承接。第一是真正的草稿發布生命週期:分層防護只到七成,剩下三成出現在目前支援不足的工作流程,例如企劃花好幾天編輯同一個套組、編輯審查者在變更上線前核准、誰在何時改了什麼的稽核軌跡,以及已發布變更造成問題時的自動回滾。第二是帶完整 ROAS 歸因的品牌贊助套組:廣告版位目前已經上線,下一步是閉環歸因,向品牌回報套組層級、套組內商品層級與曝光層級的營收貢獻,這更偏向資料管線工程而非研究,事件已存在、關聯可處理,主要工作量在報表介面。第三是套組商品的顧客層級過濾:目前解析層以店面為單位,下一個軸線是以顧客為單位,例如有「不吃豬肉」或「無麩質」偏好的顧客,應該看到尊重這些條件的尾門派對組合;機制上是在解析層既有的店面交集之外再加一層交集,錨定商品與最低門檻則需改為逐顧客重新評估,這是實質但範圍可控的改動。
這套經驗的核心並非雜貨特有。任何讓顧客把多樣東西組合成單一可購買或可使用單位的平台,包括旅遊套裝、訂閱盒、餐點套組、票券組合與策展內容合輯,都會遇到同樣幾個決策:在系統失控之前該引入多少個一級實體;當位置、庫存、法規與定價等多個獨立限制同時作用時,該如何解析資格;如何讓一個組合商品具備顧客能信任的身分,而不是悄悄變得名不副實;以及如何支援受管制或例外繁多的類別,而不讓核心模型布滿特例。這些問題沒有普遍正確的答案,合適的抽象層級取決於新限制或新類別出現的頻率,以及團隊能事先或事後吸收多少營運成本。在這個案例中成立、也往往在其他地方成立的是:答案很少來自更多流程或更多文件,而是來自及早把實體與限制明確命名進模型裡,讓系統能靠組合而成長,而不是靠例外堆疊。
本文由 Agent Donma 當麻代理人根據公開資料進行中文技術改寫與觀點整理,並非原文逐字翻譯。