預約主檔,保存預約人、營區、住宿日期、金額與目前狀態。 * booking_selected_zones
預約所選營位明細與成交時的營位類型、平假日價格快照。 * booking_selected_rentals
預約加租裝備明細與商品、規格、價格及折扣快照。 * booking_status_history
預約狀態的異動歷程,可記錄操作後臺使用者與備註。
customers ─ 1:N ─ bookings ─ N:1 ─ campgrounds ├─ 1:N booking_selected_zones ─ N:1 ─ campground_zones ├─ 1:N booking_selected_rentals │ ├─ N:1 rental_listings │ └─ N:1 rental_sku_variants └─ 1:N booking_status_history ─ N:1 ─ admin_users(可為 NULL)
CASCADE 刪除;會員、營區、營位與租借主檔則為 RESTRICT,不能直接刪除仍被預約引用的資料。預約成立時:
id 預約識別碼,呼叫端必須提供。
customer_id 預約會員;參照 customers.id。
idx_bookings_customer_created (customer_id, created_at)
checkout_idempotency_key 建立 Booking Checkout 時由前端提供的冪等鍵。
同一會員不可重複使用相同 key 建立不同預約;
UNIQUE (customer_id, checkout_idempotency_key)。
checkout_request_hash 正規化建立請求的 SHA-256 指紋。 相同 key、相同指紋時回放原預約;相同 key、不同指紋時由 Service 拒絕。
campground_id 預約營區;參照 campgrounds.id。
idx_bookings_campground_dates (campground_id, check_in, check_out)
[check_in, check_out)。weekday_count 住宿期間平日數;不可為負。
holiday_count 住宿期間假日數;不可為負。
weekday_count + holiday_count 必須等於住宿天數。
final_amount 最終應付金額;必須等於 max(zone_total + rental_total - applied_discount, 0)。
payment_method ENUM)。
禁止 COD(ck_bookings_no_cod);必須線上 ECPay。payment_status 付款狀態(unpaid、paid、refunded)。 paid_at 實際付款時間,可空。 checkout_expires_at 待付款結帳逾時(通常 now+15 分鐘),可空。 應與租借保留帳釋放排程對齊。 idx_bookings_checkout_expiry(pending 且 expires 非空)
booking_status。
只允許 pending、confirmed、completed、cancelled。created_at 建立時間;呼叫端必須提供。
now();
沒有自動更新 Trigger。id 明細流水號;
GENERATED BY DEFAULT AS IDENTITY
booking_id 所屬預約 idx_booking_selected_zones_booking
zone_id 所選營位區 idx_booking_selected_zones_zone
id 明細流水號;GENERATED BY DEFAULT AS IDENTITY
booking_id 所屬預約 idx_booking_selected_rentals_booking
rental_listing_id 所選租借 listing;參照 rental_listings.id。 idx_booking_selected_rentals_listing
rental_sku_variant_id 所選租借 SKU 變體;參照 rental_sku_variants.id。 idx_booking_selected_rentals_variant
id 歷程流水號;
GENERATED BY DEFAULT AS IDENTITY
booking_id 所屬預約;
idx_booking_status_history_booking_time (booking_id, occurred_at)
booking_status。
與 bookings.status 共用 pending、confirmed、completed、cancelled 四種代碼。occurred_at 狀態發生時間
actor_id 操作的後臺使用者;可為 NULL idx_booking_status_history_actor
營區名稱、地區、營位類型、租借 SKU/名稱/規格及價格都保存為快照。後續主檔修改名稱、規格或價格時,不應改寫既有預約的交易內容。
Booking Checkout 以會員與 checkout_idempotency_key 作為唯一範圍。資料庫唯一約束負責阻止同一會員產生重複 key;Service 還必須比較 checkout_request_hash,判斷應回放原結果或回傳 IDEMPOTENCY_CONFLICT。不同會員可以使用相同 key。
POST /api/booking/check-availability 先依 booking_policies.id=1 驗證日期,再呼叫 get_zone_availability。API 的住宿區間是 [checkIn, checkOut),因此傳給資料庫函式的包含式結束日為 checkOut - 1 day。函式會扣除 zone_blocks 與政策表列出的 pending/confirmed 預約,公休日直接回傳 0;Service 再取每個 zone 在所有住宿晚上的最低剩餘量。這個查詢不新增 bookings,也不鎖位。
POST /api/booking/checkout/sessions 先鎖定會員與營區,再依 zone_id 固定排序悲觀鎖定營位。Service 在同一交易內重查可用量,依 calendar_dates 與資料庫價格計算平假日晚數及金額。
有租借時會解析 campground_rental_locations,固定排序鎖定 rental_sku_variant_stocks,扣除日期重疊的 active 保留,再建立 booking_selected_rentals 快照與 rental_stock_reservations。listing 的 discount 依 Schema 為 0.00~0.30 比率。任何租借不足都會回滾整筆交易。
最後建立 pending、unpaid 表頭與初始歷程。checkout_expires_at 固定為建立時間加 15 分鐘。
E-6 的主動取消與每分鐘排程都會先對 Booking 執行悲觀鎖,再確認狀態仍為 pending + unpaid。符合條件時,同一交易會把 Booking 改為 cancelled、active 租借保留改為 released 並填入 released_at,以及新增 cancelled 歷程。已取消資料不會重複寫歷程;營位沒有額外釋放帳,因 cancelled 不在政策占用狀態中,可用性查詢會自然恢復數量。
排程只掃描 checkout_expires_at <= now 的候選資料,鎖定後仍會重查狀態。若付款通知先取得相同 Booking 的資料庫鎖並完成付款,排程取得鎖後會略過,避免把已付款預約取消。
E-5 提供會員列表、詳情與 Checkout 快照讀取。列表 SQL 依登入會員的 customer_id 分頁;單筆 SQL 同時限制 bookings.id 與 customer_id。查不到與不屬於本人都回 404 NOT_FOUND,避免洩漏預約 ID 是否存在。
前台建立預約
booking/js/booking-checkout.js
組裝 buildBookingPayload() 後呼叫 BookingAPI.createBooking(payload)
↓
js/booking-api.js
讀取 data/commerce/camp-bookings.json 與 localStorage.mockBookings
↓
產生遞增 id、submittedAt、status 與 paymentStatus
↓
將整筆預約 DTO 寫入 localStorage.mockBookings
後台預約管理
admin/js/bookings.js
透過 AdminAPI.bookings.list 或 data/commerce/camp-bookings.json 載入預約
↓
與 localStorage.mockBookings overlay 合併
↓
顯示預約清單與明細;賣家備註目前透過 Mock API 更新
checkout_request_hash;相同內容回放,不同內容回 IDEMPOTENCY_CONFLICT。(id, rental_sku_variant_id) 的複合 UNIQUE 因 id 已是主鍵而不增加明細去重能力;若要防止同筆預約重複選同一 SKU,應另考慮 (booking_id, rental_sku_variant_id) UNIQUE。
後台使用 /api/admin/bookings 查詢預約;營位、租借與歷程只在詳情載入。hasRental 以 EXISTS 篩選,避免 N:M 明細造成錯誤分頁。
只有 paid pending 可確認,只有已到退房日的 paid confirmed 可完成。完成時 active 租借保留帳改為 fulfilled;Admin 不得將 unpaid 改為 paid,已付款取消與退款由線 D 負責。