線稿索引所有 spec最後更新 2026-07-02
specs/lottery-integration.md

抽籤整合

一句話:熱門梯次(如國家公園山屋床位)走抽籤分配。抽籤演算法太複雜,決議留在 Google Sheets + Apps Script,不寫進 ERP;ERP 負責抽籤地基(allocation_mode/lottery_state)+ 線控手動裁決;GS 出入向整合走 agent-driven CLIcontrol lottery-orders / lottery-resolve),webhook 模型為棄用備選。

  • 模組 gate:受「抽籤模組」平台授權(見 模組化module_lottery 已落地);未開通時梯次不出現 allocation_mode=lottery、所有手動裁決 write action fail-closed、本整合與下游轉備案整組不啟用。

1. 目標 / 問題

部分梯次的名額不是先到先得,而是抽籤決定(permit / 山屋床位)。抽籤規則多變且複雜(0617 會議決議不寫死進 ERP)。本 spec 定義抽籤子系統整體範圍——分已落地整合路徑 / 職責邊界

已落地(本批,13-task SDD + 候補/配額 enforcement 批)

  • 抽籤地基:departures.allocation_mode(fcfs/lottery)+ orders.lottery_state(applied/won/lost)
  • module_lottery gate(control_db 欄 + proxy header + helper + write action fail-closed)
  • 線控手動裁決:線控逐單標中籤(won)/ 未中籤(lost)三出口全通(won→確認、lost+備案→failover、lost+無備案→取消+退款 draft)
  • 候補名單 / 遞補(§3.5):orders.waitlist_position + 席滿改建候補單(不佔 booked_count、有應收 charge/schedule 但無 intent)+ race-safe 迴圈遞補(promoteWaitlistInTx,前位釋出即依序補滿剩餘空位、head-of-line 避讓)。
  • 人頭使用次數配額(§3.6):travelers.id_number_bidx(blind-index)+ 建單 tx 內 checkQuotaInTxCOUNT(DISTINCT departure) + advisory-lock 序列化併發、超限整 tx rollback);受限行程強制填身分證。

整合路徑 / 職責邊界(已定)

  • GS 出入向整合 → 改走 agent-driven CLI(無需 webhook):tenant CLI 已具 control lottery-orders <depId>(讀應募名單,回 orderId/orderNumber/lotteryState)+ control lottery-resolve <orderId> --outcome won|lost(接同一 resolveLotteryOrder)。agent 讀 GS 抽籤結果 → 逐單敲 CLI 完成裁決,省下出向 push / 入向 webhook / HMAC 簽驗 / lottery_rounds 整套 infra(這些列為「要更高自動化才需要」的選項,非預設路徑)。重複保護resolveLotteryOrder 對主單 SELECT … FOR UPDATE 串行化,所有已裁決終態重送皆回 already_resolved(CLI/route 表為 409,agent 視為 skip)——won 重送(won 路徑 lottery_state='applied' guard)、已取消 / 已轉入備案(有 child)重送、以及已 lost 待人工安置單重送 lostfailover.ts §2 的 lottery_state='lost' guard 擋下,不會重進 cascade)。建議 agent 仍只對 lottery_state='applied' 的單裁決;待安置單的後續處置走 cancelLotteryLostOrder 或線控 forcePlaceToTarget,不走再裁決。
  • 外部 permit 釋回的偵測與遞補決策 → 歸 GS 側抽籤 agent(2026-07-02 定案,ERP 無剩餘工程項):ERP 內部釋位路徑已同 tx 自動遞補(promoteWaitlistInTx);「外部 permit 釋回」的偵測本質是抽籤流程的訊號(盯台灣山林悠遊網),與抽籤邏輯同屬 GS 側——agent 偵測到釋回後經 CLI lottery-resolve 把候補者裁 won 即完成遞補。ERP 不為輪詢外部網站長 infra(避免把外部改版風險綁進平台維運)。

不做:抽籤演算法本身(留 GS);外部 permit 釋回偵測(歸 GS 側 agent,見上);下游備案細節見 backup-itinerary-failover

2. 資料模型

2.1 梯次分配模式

  • departures.allocation_mode'fcfs'(先到先得,預設,現行行為)/ 'lottery'(抽籤制)。已落地欄位。

    lottery 梯次的訂單是應募,名額(外部 permit / 山屋床位)由抽籤決定;但下單即扣本梯次的 ERP 席booked_count +1,與一般梯次一致、庫存即時反映)。抽籤搶的是外部稀缺資源、非 ERP 席次(見 §3.3 與 §5)。

2.2 訂單抽籤狀態

訂單在抽籤梯次下,於 booking_state 之外多一個抽籤軸(派生顯示用,真相在下表):

  • orders.lottery_state'applied'(已應募)→ 'won'(中籤)/ 'lost'(未中籤)。fcfs 訂單恆 null。已落地欄位,由線控手動裁決或 GS 側 agent 經 CLI lottery-resolve 寫入(接同一 resolveLotteryOrder)。
  • orders.waitlist_position:候補序位(nullable int)。NULL = 一般在席訂單;非 NULL = 候補單(不佔 booked_count)。已落地欄位(見 §3.5)。
  • travelers.id_number_bidx:身分證 blind-index(deterministic HMAC,見 §3.6 與 blind-index.ts),供人頭配額等值計次;密文 id_number_enc 無法聚合的補充鍵。已落地欄位 + index。
  • order_backup_choices:旅客應募時自選的有序多備案清單(同 trip、可混 FCFS/抽籤;取代舊單欄 backup_departure_id)。見 backup-itinerary-failover §2。

2.3 抽籤輪 lottery_rounds(棄用備選)

lottery_rounds 表未實作,不再列未來批——agent-driven CLI 路徑(§3.2)已取代 webhook 模型;僅在未來需要更高自動化(免 agent 的機器對機器整合)時重啟評估。

欄位語意說明
departure_id哪個梯次。
statuscollecting(收件中)→ submitted(已推給 GS)→ resolved(結果已回)。
submitted_at / resolved_at推送 / 回收時間。
external_refGS 端對應這輪的識別(回呼比對用)。

3. 邏輯 / 存取層

3.1 線控手動裁決(已落地)

packages/core/src/lottery/resolve.tsresolveLotteryOrderdb: ScopedDb):

  • 前置SELECT … FOR UPDATE 鎖主單,條件轉換 applied → won/lost(防並發重複裁決);受 module_lottery gate。
  • 三出口
lottery_state動作
won 中籤booking_state='confirmed';enqueue 催尾款信;確保 balance schedule/intent 可用。
lost + 有可落腳備案advanceToNextBackupbackup-itinerary-failover §3,同 tx 沿 order_backup_choices 鏈式 cascade + credit 法)。允許鏈式自動抽籤:鏈由客人有限排序的清單界定,每跳遇抽籤目標停在 applied 等該輪結果(非無限自動)。
lost + 清單走完取消主單 + 回溯鏈首建 createRefund draft 退款,交線控 approve。

3.2 GS 出入向:agent-driven CLI(現行路徑)

出向撈名單 = CLI control lottery-orders <depId>/api/v1/departures/:depId/lottery-orders,回 orderId/orderNumber/lotteryState);入向回寫 = CLI control lottery-resolve <orderId> --outcome won|lost(接同一 resolveLotteryOrder,含三出口與全終態冪等)。認證走 OAuth device flow + bearer,權限與 module gate 與後台同閘。只撈不決:ERP 不知道、不在意抽籤怎麼算。

3.3 webhook 出入向(棄用備選)

舊模型(push.ts 推名單 + 入站 HMAC 驗簽 webhook + lottery_rounds 狀態機)未實作、由 §3.2 取代;僅在需要免 agent 的機器對機器自動化時重啟評估,屆時仍接同一 resolveLotteryOrder

3.4 下游觸發

結果觸發
won 中籤確認訂單 + enqueue 催尾款信 + 啟用尾款 payment_schedules
lost 未中籤備案自動轉單:沿 order_backup_choices 鏈式 cascade(FCFS 落腳 / 抽籤重應募 + carry forward);清單走完→取消 + 退款 draft。

下游動作由 resolveLotteryOrder 串起,不散落在 CLI / UI handler 內(任何新入口一律接同一服務)。

3.5 候補名單與遞補(已落地)

熱門梯次(玉山)席滿後仍可收候補;前位釋出即依序遞補。受 trips.waitlist_enabledtrips §2.1)gate。實作 waitlist.ts + orders/repo.ts

  • 候補建單:建單 tx 內走一般 race-safe 席次 CAS(booked_count + N <= capacity);失敗(售罄)且行程 waitlist_enabled → 改建候補單:waitlist_position = COALESCE(MAX,0)+1不扣 booked_count、有完整 charge lines + payment_schedules(應收),但不鑄 payment intent(未遞補前不可付款)。party_size > capacity 的單一律拒(候補也裝不下)。
  • 遞補promoteWaitlistInTx 迴圈版):任何釋位路徑(棄單回收 / ATM 逾期 / 取消 / 抽籤轉單)在同一 tx 釋回席次後呼叫,把剩餘空位盡量塞滿——依 (waitlist_position, created_at, id) 序遍歷候補、對每個走與一般建單同一條 race-safe CAS 扣席、遞補第一個裝得下剩餘空位者(不 fit 的跳過 → 避 head-of-line block)。命中即清 waitlist_position、鑄付款 intent(轉可付款)、重算範本人頭成本、enqueue「已遞補上車」通知(沿用 order_confirmed 模板、payUrl 走 canonical origin)。候補單以 FOR UPDATE OF o SKIP LOCKED 鎖定,併發遞補互不重補。席次不變式與建單一致:booked_count 恆 ≤ capacity
  • 外部 permit 釋回的偵測 → 歸 GS 側抽籤 agent(偵測後經 CLI lottery-resolvewon 即觸發遞補,見 §1);ERP 側無待辦。

3.6 人頭使用次數限制(已落地)

國家公園 permit 同一身分證計次(restricted_quota_enabled + quota_per_person_limit + quota_window):語意 = 「同一身分證在這條 permit 路線(= 同一 trip)、每 quota_window(如 'annual')最多參加 quota_per_person_limit不同梯次」(奇萊南華 / 嘉明湖 / 北大武等)。實作 quota.ts

  • blind-index 補聚合缺travelers.id_number 是 KMS envelope 加密 PII,每列密文不同無法 GROUP BY。故另存 id_number_bidx = HMAC-SHA256(perTenantKey(dbName), normalize(idNumber))blind-index.ts)——deterministic 可等值聚合,key 由平台祕密 HKDF 派生綁 dbName、不落 DB(備份/replica 外洩不可反推、不可跨租戶移植計次)。
  • enforcement:建單 tx 內、旅客(含 id_number_bidx)insert 之後呼叫 checkQuotaInTx——COUNT(DISTINCT tb.departure_id) > limitbooking_state <> 'cancelled'、視窗述詞作用在 departure_date);超限 → 呼叫端回 quota_exceeded 讓整 tx rollback(席次一併回滾)。TOCTOU 硬化:COUNT 前對每個 bidx 取 pg_advisory_xact_lock(hashtext(bidx))(去重排序依序取避死結),序列化同證號的併發報名。
  • 受限行程強制身分證restricted_quota_enabled 的行程建單時,缺 id_number_bidx 的旅客 → 回 quota_id_required(否則無證者可繞過計次);候補單仍計入配額(permit 稀缺,寧可保守擋在報名端)。空 bidx 陣列(一般行程無強制)→ 直接放行。

4. UI 與權限

  • 團控(/admin/control:梯次可標 allocation_mode='lottery';抽籤梯次顯示應募訂單列 + 各筆 lottery_state badge + 逐單「中籤 / 未中籤」action;裁決走線控或 GS 側 agent(CLI,§3.2)。
  • 訂單詳情:顯示 lottery_state badge + 主備譜系。
  • 權限:裁決走 trip.manage / order.update;所有 write action 受 module_lottery gate fail-closed;CLI 走 device-flow bearer 同閘(入站 webhook 若重啟才需要免登入驗簽)。

5. 開放議題

  • 席次模型(已定)lottery 梯次下單(應募)即扣本梯次 ERP 席booked_count +1)—— ERP 席只反映「誰報了這梯」,抽籤搶的是外部 permit / 山屋床位。備案梯次不預扣席;未中籤 failover 當下才扣備案席;取消主梯 → 釋回主梯 ERP 席。
  • 誰主動(已定,2026-07-02):GS 側 agent 主動——經 CLI 撈名單 / 回寫結果 / 偵測外部 permit 釋回後裁遞補;ERP 不 push、不輪詢外部系統。
  • 金流:本批假設應募即收訂金(否則無物可結轉);確切金額策略(全額 / 訂金 / 0)待租戶設定。
  • 驗簽密鑰(棄用備選):僅 webhook 模型重啟才需要(per-tenant 或 per-departure,存 tenant_settings);現行 CLI 路徑用 device-flow bearer,無此議題。
  • 配額計次口徑(已定,保守):候補單 / 未中籤待安置單皆計入配額(permit 稀缺,寧可過度限制擋在報名端);quota_window 目前僅實作 'annual'(同西元年),其餘值 fall back 全時間累計。滑動視窗(近 N 個月)等更細口徑列後續。
  • 多輪重抽:同梯次多輪抽籤仍留後續。若未來支援,需要的不只是放行重裁,而是把已 lost 單顯式 re-open 回 applied 的路徑(目前 lost 是終態,見 §3.1 guard)。

Changelog

版本日期變更Commit
12026-06-20初版(草案):ERP↔GS 抽籤整合契約、allocation_mode / lottery_state / lottery_rounds、出向推名單 + 入向驗簽收結果 + 下游觸發。抽籤邏輯不進 ERP。
22026-06-20定案席次模型:下單(應募)即扣主梯次 ERP 席、備案梯次不預扣(failover 才扣);釐清抽籤搶的是外部 permit/山屋床位非 ERP 席。
32026-06-23D5:玉山搶候補 / 候補名單遞補標記為未來批(GS 整合批落地);新增 §3.5 人頭使用次數限制(未來批)。
42026-06-30落地後更新(13-task SDD):§1 重寫拆「已落地批」vs「未來批」;§2.2 標 allocation_mode/lottery_state 為已落地欄位、lottery_rounds 標未來批;§3 重組為線控手動裁決(§3.1 resolveLotteryOrder 三出口,已落地)+ 出入向 GS(§3.2/§3.3,未來批)+ 下游觸發(§3.4)+ 候補/配額(§3.5/§3.6,未來批);§4 UI 補 module gate fail-closed;§5 開放議題更新。
52026-06-30多備案鏈式 cascade(客人側):§2.2 單欄 backup_departure_idorder_backup_choices 有序多備案;§3.1 / §3.4 lost 出口改 advanceToNextBackup 鏈式 cascade,解除「禁止鏈式自動抽籤」——鏈由客人有限排序的 order_backup_choices 界定、每跳遇抽籤目標停在 applied 等該輪結果(非無限自動);清單走完才取消 + 回溯鏈首退款。
62026-07-01狀態校正(Code Is Truth):§1 未來批改寫——GS 出入向整合改走 agent-driven CLIcontrol lottery-orders + lottery-resolve 已備,免 webhook/簽驗/lottery_rounds infra);§3.5/§3.6 標題改「設定欄已落 / 邏輯未做」並澄清 waitlist_enabledrestricted_quota_enabled/quota_per_person_limit/quota_window 僅設定欄 + 後台表單落地、enforcement/遞補邏輯零實作(候補缺 orders.waitlist_position + race-safe 遞補;配額卡 id_number KMS 密文無法聚合計次,需 blind-index)。
72026-07-01advanceToNextBackup lost guard 缺口(codex 對抗式覆核揪出):守衛段補 lottery_state='lost'already_resolved,堵住「有備案但當時售罄→exhausted 待安置→備案出缺→重送 lost」會繞過 resolve.ts exhausted guard 重進 cascade 的路徑(初次裁決主單為 applied,不受影響)。§1 重複保護回填為全終態冪等、§5 移除該開放議題。回歸測試 lottery-resolve.test.ts(備案售罄→出缺→重送 lost 回 already_resolved + 0 child)。
82026-07-02候補 / 人頭配額 enforcement 落地(Code Is Truth):§1 兩項由「未來批」移入「已落地批」;§2.2 新增 orders.waitlist_position / travelers.id_number_bidx 已落地欄位;§3.5 重寫為候補建單(席滿改建候補單、不佔 booked_count、有應收無 intent)+ promoteWaitlistInTx race-safe 迴圈遞補(head-of-line 避讓、SKIP LOCKED 併發安全);§3.6 重寫為 blind-index(HKDF per-tenant、不落 DB)+ checkQuotaInTxCOUNT(DISTINCT departure) + advisory-lock TOCTOU 硬化 + 受限行程強制身分證 quota_id_required);§5 開放議題更新配額口徑(候補計入、僅 annual)。實作 packages/core/src/{orders/quota.ts,orders/waitlist.ts,pii/blind-index.ts,orders/repo.ts},migration 0037。經 self + codex 兩輪對抗式覆核揪出並修 6+3 bug(候補付款繞過 / 配額 TOCTOU / 無證繞過 / failover bidx / 遞補 head-of-line / 多席遞補 / 旅客編輯增訂 bidx 同步 / 結團 audit 冪等)。
92026-07-02GS 整合職責邊界定案:外部 permit 釋回的偵測與遞補決策歸 GS 側抽籤 agent(偵測後經 CLI lottery-resolvewon,ERP 內部釋位遞補本已自動)——ERP 側無剩餘工程項,不為輪詢外部網站長 infra。webhook 出入向(push.ts/入站驗簽/lottery_rounds)由「未來批」改標棄用備選(§2.3/§3.3,agent-driven CLI 取代,需免 agent 的 M2M 自動化才重啟);§3.2 改寫為現行 CLI 路徑;§5「誰主動」定案 GS 側主動、驗簽密鑰改棄用備選;移除 §1 過時的「玉山搶候補(未來批)」註(候補 v8 已落地)。