這張訂房表單一開始假設一個 operator 只有一館。後來一位有兩館的業主開始用,兩個假設同時破了:以為每館接受的聯絡管道都一樣,以及以為把所有房源的 POST 並行送出沒有副作用。
這是〈When the person entering bookings is 70 years old〉那套訂房系統的資料登入面。那篇談的是誰在用這張表單、為什麼以日曆為主入口。這篇談兩個更窄的決定:表單怎麼依房源變形,以及一個只有在業主一次選超過一館時才浮出來的競態 bug。
為什麼會有分館的管道設定
表單提供一份固定的聯絡管道清單——LINE、電話、WhatsApp、各家 OTA——和訂房類型(包棟、散客單間)。但不是每一館的訂房方式都一樣。有一館可能從不上 Airbnb、另一館可能只做包棟。一張對每一館都把所有選項列出來的表單,逼業主每次登記時都要在腦中過濾一遍,而這正是讓一位七十歲長者放棄使用的那種摩擦。
所以每一館多兩個陣列欄位——allowed_channels 和 allowed_booking_types——預設給全集,可以在設定頁編輯。設定頁把每個選項做成可切換的按鈕:啟用的管道顯示綠色加勾,停用的變灰並加刪除線。業主在記錄表單選某一館時,只會出現那一館啟用的選項。如果某一館只有一種訂房類型,就自動選好。正確登入資料的邊際成本壓到趨近於零,而業主完全不用碰程式——這一切都是後台的自助設定。
多房源的情況,選項取聯集:選了兩館,就看到任一館允許的管道和類型。這是對的預設——業主是把同一筆訂房同時登到兩館,取交集會太嚴。
那個生出兩個日曆的競態
原本的多房源送出,用 Promise.all 把所有房源的 POST 一次打出去。快、直覺,但對一個全新的 operator 來說是錯的。
原因在這裡。任何一館的 POST 第一次為某個 operator 跑時,server 會 find-or-create 這個 operator 的共用 Google 日曆,並把日曆 ID 寫回資料庫。第二支 POST 本來應該讀到那個存好的 ID 並重用。但在 Promise.all 底下,兩支 POST 在任何一支寫入日曆 ID 之前就都跑了——於是兩支都找不到既有日曆、兩支都建一個,業主最後拿到兩個「訂房日誌」日曆。之後每一筆訂房隨機落進其中一個。
解法是:只在第一次送出時依序執行——第一支 POST 建好並存下日曆 ID,後面的就能安全地並行。程式裡的註解就是寫這句話,因為下一個讀到這段的人,會忍不住把這個迴圈「優化」回 Promise.all,把 bug 又種回去。
這種失敗,在任何單房源的測試裡都不會出現。它需要兩館、一個全新的 operator、一次送出——這個狀態大概只存在於一個 operator 在系統裡的頭三十秒,之後再也不會出現。
衝突檢查,還有把原因攤開講
同一批工作加了包棟衝突防護:同一館不能有兩筆日期重疊的包棟。重疊條件是標準的 checkin < newCheckout AND checkout > newCheckin,新增和編輯時都跑,編輯時排除自己那一列。衝突回 409,並把衝突的日期寫清楚。
最後這點很關鍵。表單以前把所有失敗都吞成一句通用的「儲存失敗,請再試一次」。現在改成攤開 body.message ?? body.error,所以 409 衝突會告訴業主,是「哪幾天」已經有包棟。對一個非技術使用者來說,「再試一次」和「這幾天已經包棟了」之間的差別,就是放棄和把它修好之間的差別。
這些改動都不大。但它們決定了一個真正經營兩館的業主,能不能在開始用的同一週,順順地把訂房跑過這個工具,而不是一頭撞牆。