交易主線的最後一站:單怎麼從購物車走到訂單。留言進了購物車、身分掛好了單、庫存卡住了不變量——這章把它們聚合成一筆可以付錢、可以出貨、可以開發票的東西。明天的標題不是比喻:這個系統裡真的有一台狀態機,被主播們用行動拔掉了。
全景說過購物車有兩種:直播下單的佔庫存、商城自己加的不佔。資料模型的答案在身分章亮過相:一張 cart item 表,用 content type + object id(泛型外鍵)標記來源。佔不佔、算直播價還是商城價,由來源決定;數量調整、結帳、清除,全走同一套邏輯。
調整數量的邊界也因此很乾淨:佔庫存的單要加量,就是再打一次庫存章那句條件更新——搶得到才加;減量就是釋放,把差額還給計數。留言的 LWW 改單、客人自己調、客服代調,三條路徑走的是同一組動作,只是觸發者不同。
上面數過購物車的寫入者:LWW 改單、客人自調、客服代調,加上庫存章的檔期結算清除與重喊重置——一張表,五路寫入。但五路的「留痕」嚴重不對稱:留言來的變更有完整溯源(msg → cart item 那條鏈,結構化、可 join);人來的變更,當年不是沒記——我們會手動把操作寫進 Django 內建的那張 log 表——問題是那張表是全系統的大雜燴:購物車調整、商品改動、各種雜項操作通通混在同一張表,欄位泛用到只剩「誰、動了哪個東西、一段文字」。要回答「這筆單怎麼變成這樣」,得在大雜燴裡撈文字:撈得到靠運氣,撈到了也沒有前後數量、沒有差額、跟重算對不起來。客服接到「我的購物車怎麼變這樣」,留言那半查得到,人那半只能考古。這件事的教訓很具體:「有記錄」和「查得動的記錄」是兩回事——泛用 log 表寫的時候人人安心,查的時候形同沒有。
重來我會為 cart item 專門開一張操作紀錄表——不是「開始記錄」,是把記錄從大雜燴裡搬出來、給它結構:每次變更,在同一筆交易內 append 一筆,觸發者(哪則留言、客人、哪位客服、檔期結算、重喊)、前後數量、差額,欄位打死、可以 join。三個講究:
結帳有個當年就支援的狠需求:跨檔期合併結帳——不同檔期、不同實況主買的東西,一次付清。這逼出了三層結構:

三層各自的存在理由,比「正規化」更務實:
而「購物車到訂單」這個動作本身的形狀,已經預告了明天的主題:結帳不是去改 cart item 的狀態——付款當下新增一筆 order item,和庫存帳本上「購物車數量轉訂單數量」在同一筆交易內完成。狀態轉移用新增記錄表達,溯源鏈因此自然多接一段:msg → cart item → order item,任何一筆成交都能一路回溯到當初那則留言。
價格在這個系統裡有一條清楚的生命線:
一句話收:snapshot 只發生在承諾點——承諾之前永遠查事實,承諾之後永遠不再變。 cart 是意向,order 是承諾,兩邊的價格策略相反,而且都是對的。
payment、order、order item 這三層,如果當年是為了「架構好看」而分,大概撐不過第一次需求變更。它們撐住了,是因為每層背後有一個獨立變動的現實:客人要一次付清(付錢的節奏)、貨按檔期到(履約的節奏)、帳要經得起發票與退款(會計的節奏)。後來跨檔期合併、部分付款、檔期限定券這些複雜需求一個個冒出來,結構都接得住——複雜需求殺不死職責單純的結構,殺得死聰明但混雜的結構。 判斷要不要多一層的標準,從來不是「教科書說要」,是「這層對應的現實,會不會獨立於其他層變動」。
寫到這章我發現一件事:它沒有事故可講。沒有超賣等級的爆炸、沒有被打爆的 API——結帳這段當年就是穩穩地跑。回頭看不是運氣:購物車一張表多型,所以合併結帳不用縫合兩套邏輯;單掛在身分上,所以誰來結帳都不用搬資料;帳本雙計數,所以 cart→order 的轉移一筆交易就完成。結帳只是把前面章節做對的事實聚合起來而已。 系統設計裡最被低估的讚美就是「無聊」——會爆炸的章節好寫,無聊的章節難得。願你的結帳流程也無聊。
但這個系統裡,有一台真的被拔掉的狀態機。明天講它的故事。
本文改寫自我的部落格系列《Re:從零開始做直播代購電商平台》,本篇完整版:https://blog.aidan.tw/blog/rezero-cart-order/