specs/travelers.md

旅客

一句話:travelers 是訂單明細層的旅客 PII(綁 order_id,非綁 user)——一個「人」可橫跨多筆訂單、出現為多筆 traveler,所以它不是會員/owner 的錨點。本檔是旅客實體的單一入口;欄位以 code 為準(packages/core/src/schema/business.ts 的 travelers),畫面以 wireframe 為準(§6)。

定位:domain/core spec(實體與邏輯)。功能切片(分房 / 驗證 / 護照 / 同意書)在各自 spec,§5 索引串接。狀態見 roadmap。

1. 實體模型

travelers(schema:packages/core/src/schema/business.ts):

  • 歸屬:order_id(FK → orders.id,onDelete: cascade)。綁訂單,不綁 user(帳號歸戶另有 user_id,見下)。
  • 核心 PII:full_name、birth_date、id_number(身分證,存 id_number_enc jsonb envelope 加密欄)、passport_no(護照,存 passport_no_enc jsonb envelope 加密欄)、phone、email。
  • 緊急聯絡:emergency_contact_name / emergency_contact_phone。
  • 旗標 / 關聯:is_primary(是否同訂購人)、room_id(FK → departure_rooms.id,分房,onDelete: set null)、created_at。
  • 帳號歸戶 user_id(nullable FK → user,onDelete: set null):這位旅客是哪個帳號的人。建單時 primary 旅客掛下單 user(公開結帳 = 登入者、K 單 = 客戶 user);非 primary 留 NULL(M2 person 目錄再對齊)。PDPA 匯出/匿名化以「order 歸屬 ∪ user_id 歸戶」聯集涵蓋(見 member-pdpa-self-service)。
  • 寫時派生 id_number_checksum_ok(nullable boolean):台灣身分證檢查碼於寫入時以同一份 normalize 後的值派生落欄——讀取端驗證零解密(不再為驗 checksum 逐列打 KMS)。NULL = 未填 / 非台灣格式。
  • 寫入 normalize 統一:id_number 寫入前統一 trim + 去空白 + 大寫(normalizeIdNumberInput),同一份 normalized 值餵密文(*_enc)、blind-index(id_number_bidx)、checksum 三者——密文與 bidx 不再各自正規化不一致。

與 orders / 訂購人 user 的關係:訂購人是 orders.user_id(帳號);travelers 是該訂單的實際出行旅客(可含或不含訂購人本人,is_primary 標記)。詳見 orders §1、member-ownership §「會員/旅客/customer 三者釐清」。

2. 不變式

  • order-scoped、非 owner 錨點:travelers 沒有帳號語意、會重複,不可拿來掛 CRM owner(owner 掛 member_profiles / user,見 member-ownership)。
  • PII 加密:id_number / passport_no 無明文欄,分別存 id_number_enc / passport_no_enc(jsonb EncryptedField)—— AES-256-GCM envelope(per-row 隨機 DEK,GCP Cloud KMS 管 KEK),AAD 綁 dbName:table:column:rowId:mask 防跨租戶/跨列/跨欄移植。讀取走持久化遮罩(零 KMS);全碼僅經 order-scoped reveal 解密 + traveler.decrypt audit(防 IDOR)。低敏感度欄(如 gender)解耦、可存明文。遮罩規則的理由見 ADR 0005,KEK provider 判定見 ADR 0006。
  • 可改窗口:出發前 7 天可改(用於入山證 + 投保名冊)。
  • cascade:訂單刪除連帶旅客(FK cascade);分房刪除時 room_id set null。

3. 存取層契約

寫入層有獨立 repo packages/core/src/travelers/repo.ts(addTraveler / updateTraveler)——加密、bidx、checksum 三者共用同一份 normalized 值在此收斂(SSOT)。讀取一律隨訂單帶出(first arg db: ScopedDb):

  • getAdminOrderDetail → AdminOrderDetail.travelers: AdminTravelerRow[](packages/core/src/orders/admin-repo.ts)。
  • getOrderById / getOrderByIdForUser → … & { travelers: TravelerInput[] }(packages/core/src/orders/repo.ts)。
  • 建單時隨 createOrderWithReservation 的 travelers[] 一併寫入(同 tx;primary 旅客帶 user_id 歸戶)。
  • 入山名冊:manifest.ts。分房讀寫:rooms/repo.ts(room_id 回寫)。

4. 權限

無獨立 traveler resource——讀寫經訂單權限:order.read(看)、order.update(新增/編輯旅客)。可見範圍同訂單(owner-id query 層)。

5. 擴充旅客的功能 spec(索引)

Spec擴充什麼
room-assignmentgender(硬約束分房,明文)、room_preference / roommate_pref、room_id 配房
traveler-validationaddress / validation_exempt、身分證檢查碼 / 緊急聯絡 / 必填 / 金額一致即時驗證
document-expirypassport_expiry_date + 效期警戒(海外)
passport-ocr護照 OCR、20+ 護照欄位、purge cron(海外,KMS 前置)
orders母實體(訂單外殼 + 建單雙插)
同意書(consent_signatures)每旅客/每訂單同意書簽署(append-only)

6. 畫面(UI = wireframe)

  • 旅客名單卡(在訂單詳情內)../apps/wireframes/app/admin/order-detail/page.tsx
  • 前台護照上傳(前台線稿已刪,待整批重寫)
  • 後台護照審閱 ../apps/wireframes/app/admin/passport-review/page.tsx
  • 同意書簽署(前台線稿已刪,待整批重寫)