購物車存的是「客戶做了什麼選擇」,不是「算出來多少錢」。
我看過很多購物車,session 裡面長這樣:
items、subtotal、discount、shipping_fee、total、free_shipping、gift_name ...
金額被寫進 session,然後系統到處讀它。這種寫法上線之後一定會出事。因為金額是「算出來的東西」,它會過期。商品改價了、活動檔期結束了、折扣碼被停用了,session 裡那份數字卻還活著。客戶放著購物車三天沒動,回來結帳,看到的是三天前的價格。
我的做法是:session 只存客戶的原始輸入。
items sku => 數量
coupon 折扣碼
addons sku => 數量(加價購勾選)
points 想折抵的點數
note 訂單備註
就這五個欄位,全部都是「客戶按了什麼」,沒有任何一個是算出來的。
金額呢?每次要顯示、每次要結帳,都從這份輸入重新撈 DB、重新跑一次計價,從頭算到尾。不保留任何上一次的計算結果。
這件事聽起來很浪費,但實際上它解決了電商最麻煩的一整類 bug:購物車頁顯示的金額,跟結帳當下算出來的金額不一致。因為兩邊跑的是同一段程式、吃的是同一份輸入,它們不可能不一致。
再進一步,我會把這份輸入包成一個 immutable 的 CartInput:
Session Array ──fromSession──▶ CartInput
CartInput ──addItem / removeItem / withCoupon / addAddOn──▶ CartInput
CartInput ──toSession──▶ Session Array
每個操作都不改原本的物件,而是回傳一個新的 CartInput。Controller 的每個 action 就變成三行:讀出來、變形、存回去。
這樣做還有一個好處:購物車的「不變條件」可以直接寫在建構子裡,強制執行。
例如我的規則是「加價購不能單獨存在」。客戶如果加了一件商品、勾了加價購,然後把正常商品刪光,加價購就應該一起清掉,不然他會用加購價單買加購品。
這條規則我不寫在 controller,我寫在 CartInput 的建構子:沒有正常商品時,加購、點數、備註一律清空。因為所有變更方法都會經過建構子,所以 session 永遠是合法、自洽的,不會出現「session 裡有、計算時卻被忽略」這種鬼狀態。
購物車 session 的規格,一句話就講完了:只存意圖,金額每次重算。