線稿索引所有 spec最後更新 2030-03-15
specs/passport-ocr.md

護照上傳與 OCR

一句話:旅客在訂單頁上傳護照影像,系統 OCR 抽取 MRZ 欄位自動填入 traveler 資料,影像走 KMS 加密與短期保留;受 模組化 的「海外」開關 gate,護照欄位沿用既有 travelers.passport_no / passport_expiry_date

畫面(UI = wireframe):前台上傳 · 後台審閱

1. 問題與動機

1.1 現況痛點

  1. 手填易出錯:海外行程(日本、紐西蘭、巴塔哥尼亞)需提交護照英文姓名、護照號碼、效期等。打字常見錯誤:
    • 拼音少一個字母(CHIA-WEI 打成 CHIAWEICHA-WEI
    • 效期格式錯(2027/01/01 vs 01/01/2027
    • 大小寫混亂(航空公司送票會被拒)
  2. 業務反覆催收:旅客傳照片 → 業務手抄 → 旅客再次確認 → 出錯回頭改。一張護照平均 3 次往返。
  3. 資料散落:護照圖目前用 LINE / Email 傳,沒有統一存放,刪除政策無人負責。
  4. 海外行程比例會上升:經理希望 2026 H2 推出第一條海外線(北海道四日),預估會有 30+ 訂單需護照資料。

1.2 經理 feedback 原文

「我希望可以新增護照上傳功能,並且可以抓取護照上的資訊,像護照號碼、姓名、效期,自動填好就好,不要再叫客人打字了。」

1.3 為什麼可行

  • 護照 MRZ (Machine Readable Zone) 是 ICAO 9303 國際標準格式,OCR 友善(黑白等寬字、固定行數、checksum 防錯)。
  • Google Cloud Vision API 提供 DOCUMENT_TEXT_DETECTION,可解析整張影像並回傳結構化文字座標。
  • 我們本來就會 GCP-bound(deploy target),auth 與帳單一條龍。

2. 範圍與不在範圍

2.1 在範圍 (MVP)

  • 客戶端可從訂單頁上傳護照影像(手機拍 / 上傳)
  • admin (sales / op) 可代客戶上傳
  • 自動 OCR 抽取 MRZ 欄位 → 寫入 traveler
  • 人工確認 + 編輯(OCR 抽錯時可手動修)
  • 影像 client-side encrypt 上傳 + at-rest KMS 加密儲存
  • audit log 記錄上傳 / 讀取 / 刪除
  • 短期保留政策(出團後 6 個月自動刪除影像)

2.2 不在範圍

  • 自動驗證護照真偽(防偽 / 假護照偵測)— 此屬於 KYC 等級,需專業服務
  • 多種證件支援(駕照、健保卡、簽證)— 之後若需要再開
  • 護照即將過期主動提醒(< 6 個月)— 未來 admin feature 候選
  • 批次上傳(多人護照一次掃描)— 現階段一次一張
  • 跨訂單共用護照(會員 profile 級護照)— 未來候選
  • 自動翻譯英文姓名為中文(會錯,需人工)

3. 資料模型

3.1 擴充 travelers table

✅ 已拍板:沿用既有 travelers.passport_no_enc(code 為真相 — passport_no / id_number 已以 envelope encryption 落 DB 為 jsonb EncryptedField,見 §3.2),本 spec 不引入 passport_number —— OCR 抽取的護照號碼解密比對後寫回既有的已加密 passport_no_enc(經 encryptField,不寫 plaintext)。passport_birth_date 則保留為 OCR 抽取值,與旅客填寫的 travelers.birth_date 並存供比對(不一致 → 提醒人工確認)。

// drizzle schema · 新增以下欄位(schema/travelers.ts ALTER)
{
  // === 護照影像 ===
  passportImageObjectKey: text('passport_image_object_key'),
    // R2/GCS 物件 key,不是完整 URL
  passportImageKmsKeyId: text('passport_image_kms_key_id'),
    // 用哪個 KMS key 加密這份 DEK(key rotation 用)
  passportImageDekWrapped: text('passport_image_dek_wrapped'),
    // KMS-wrapped DEK(base64)
  passportImageIv: text('passport_image_iv'),
    // AES-GCM IV(base64)
  passportImageMimeType: text('passport_image_mime_type'),
    // 'image/jpeg' | 'image/png' | 'image/heic'
  passportImageUploadedAt: timestamp('passport_image_uploaded_at'),
  passportImageDeleteAfter: timestamp('passport_image_delete_after'),
    // 計算 trigger:出團日 + 180 天

  // === OCR 抽取的 PII 欄位 ===
  // 護照號碼:OCR 結果解密比對後寫回既有 travelers.passport_no_enc(不另開 passport_number 欄)。
  // 敏感者(姓名 / 效期 / 生日 / 號碼)一律走 envelope 加密 —— 比照 passportNoEnc 存 jsonb
  // EncryptedField(經 packages/core/src/crypto/ 的 encryptField),不存 plaintext。
  passportNameSurnameEnc: jsonb('passport_name_surname_enc').$type<EncryptedField>(),
    // MRZ 抽取的姓(CHEN),envelope-encrypted
  passportNameGivenEnc: jsonb('passport_name_given_enc').$type<EncryptedField>(),
    // MRZ 抽取的名(CHIA-WEI),envelope-encrypted
  passportNameLatinFullEnc: jsonb('passport_name_latin_full_enc').$type<EncryptedField>(),
    // 完整 latin name (CHEN CHIA-WEI),用於航空送票,envelope-encrypted
  passportNameChineseEnc: jsonb('passport_name_chinese_enc').$type<EncryptedField>(),
    // 視覺 OCR 抽取的中文姓名(可信度低,需人工確認),envelope-encrypted
  passportIssueDate: date('passport_issue_date'),
    // 發照日:低敏感度,明文(非個資高風險欄)
  passportExpiryDateEnc: jsonb('passport_expiry_date_enc').$type<EncryptedField>(),
    // 效期:envelope-encrypted(與 passport_no 同等敏感)
  passportBirthDateEnc: jsonb('passport_birth_date_enc').$type<EncryptedField>(),
    // 注意:travelers 已有 birth_date,這裡是 OCR 抽取結果,需比對;envelope-encrypted
  passportNationality: text('passport_nationality'),
    // ISO 3-letter alpha-3 (TWN, JPN),低敏感度明文
  passportIssuingCountry: text('passport_issuing_country'),
  passportSex: text('passport_sex'),
    // 'M' | 'F' | 'X' (ICAO 9303 標準),低敏感度明文

  // === OCR meta ===
  ocrConfidence: real('ocr_confidence'),
    // 0-1 整體信心分數(取自 Google Vision 平均 confidence)
  ocrMrzChecksumValid: boolean('ocr_mrz_checksum_valid'),
    // MRZ checksum 是否通過(強訊號)
  ocrProcessedAt: timestamp('ocr_processed_at'),
  ocrProvider: text('ocr_provider'),
    // 'google_vision' | 'blinkid' | 'manual'
  ocrRawPayloadEnc: jsonb('ocr_raw_payload_enc').$type<EncryptedField>(),
    // envelope-encrypted 的完整 OCR 回應(debug / re-parse 用,不直接查詢)
  ocrConfirmedByUserAt: timestamp('ocr_confirmed_by_user_at'),
    // 旅客本人或 admin 確認過 OCR 結果
  ocrConfirmedByUserId: text('ocr_confirmed_by_user_id'),
}

3.2 加密策略

加密基建為現成packages/core/src/crypto/ 的 envelope(encryptField / decryptField,KEK 走同一把 KMS,AAD 綁 dbName:table:column:rowId:mask)已於 2026-06-11 全面落地,travelers.passport_no_enc / id_number_enc 已是 jsonb EncryptedField。本功能的所有 PII 欄位直接複用同一套 envelope,不需新前置工作。

資料儲存位置加密方式備註
護照影像 blobR2 / GCS bucketClient AES-256-GCM + KMS-wrapped DEK (envelope encryption)DEK 永不落地 plaintext
OCR raw payload (jsonb)DB columnenvelope encryption(複用 crypto/EncryptedField含完整 MRZ 文字
MRZ 結構化敏感欄位(姓名/號碼/效期/生日)DB columnsenvelope encryption(jsonb EncryptedField,比照 passportNoEncencryptField 進出;顯示用 mask,比對 / 送票時 decryptField
中文姓名DB column同上OCR 不可靠,多半要人工
低敏感欄位(國籍 / 性別 / 發照日 / 發照國)DB columns明文非個資高風險,留作查詢

為何不用 PG column-level encryption (pgcrypto)?

  • pgcrypto 把 key 放 DB 設定,不是真 KMS。
  • 我們既然要走 GCP,已有 Cloud KMS envelope 基建(packages/core/src/crypto/),直接複用比較乾淨。

3.3 保留政策

資料保留期法規依據
護照影像 blob出團日 + 180 天 後自動刪除個資法第 11 條(特定目的消失即刪)
OCR raw payload同上個資最小化原則
MRZ 結構化欄位訂單關閉後 5 年商業會計法(憑證保存)· 觀光局團體旅遊紀錄
audit log(誰看/改)5 年append-only hash chain

實作:

  • DB trigger 或 cron job 每日掃 passport_image_delete_after < NOW() → 刪 R2 物件 + 清空 image 相關 columns(保留 MRZ 文字欄位)
  • 寫 audit log: traveler.passport_purge

4. 第三方服務評估

選項優點缺點估算成本
Google Cloud Vision DOCUMENT_TEXT_DETECTION已 GCP-bound、auth 簡單、台灣護照樣本充足不專門做護照,需自寫 MRZ parser;中文 OCR 一般$1.5 / 1000 requests · 預估月 100 reqs → $0.15
Microblink BlinkID護照 SDK 專業、accuracy 95%+、内建 MRZ + visual 雙重抽取$$$ commercial、需聯絡業務報價、可能要月費$300-1000/月 起跳(粗估)
AWS Textractaccuracy 與 Google 同級我們不走 AWS,多一條 cloud bill類似 Google
Self-host Tesseract + tesseract4java/pkochan/passport-mrz-checker免費、不出國境(PII 不送第三方)accuracy ~85%、維護成本高、台灣護照樣本少0 (但工時成本)

建議方案: 現階段用 Google Cloud Vision + 自寫 MRZ parser(typescript port of mrzpassport-mrz)。後期視台灣護照 accuracy:

  • 若 > 92% → 維持 Google
  • 若 < 92% → 評估升級 BlinkID
  • 若 PDPA 顧問建議「PII 不出境」→ 評估 self-host Tesseract(但 accuracy 可能更糟)

5. 流程

5.1 客戶端流程 (/orders/[orderId]/travelers/[travelerId]/passport)

1. 旅客在訂單詳情頁點「上傳護照」按鈕
   ↓
2. 進入專屬上傳頁(mobile-friendly,公開站 layout)
   ↓
3. 顯示 PDPA 聲明 + 同意 checkbox
   ↓
4. 拍照 / 從相簿選 / 拖曳上傳
   ↓
5. Client preview + 提示「請確保 4 個邊角清楚 / 護照打開到資料頁」
   ↓
6. Client-side:
   - generate DEK (random 32 bytes)
   - AES-GCM encrypt blob
   - 呼叫 /api/passport/upload-init → 取得 KMS-wrapped DEK + upload URL
   - PUT 加密 blob 到 R2 / GCS
   ↓
7. POST /api/passport/process { orderId, travelerId, objectKey, dekWrapped, iv }
   ↓
8. Server:
   - decrypt blob in memory (透過 KMS unwrap DEK)
   - 呼叫 Google Vision DOCUMENT_TEXT_DETECTION
   - parse MRZ → checksum 驗證
   - 寫 traveler.passport_* 欄位
   - 寫 audit log
   - 回傳 { fields, confidence, mrzValid }
   ↓
9. 前端顯示抽取結果(每欄位旁邊有 confidence bar / lock icon)
   ↓
10. 旅客檢查 → 編輯錯誤欄位 → 點「確認送出」
    ↓
11. 寫 ocr_confirmed_by_user_at, ocr_confirmed_by_user_id
    ↓
12. 跳回訂單頁,顯示「護照已上傳 ✓」

5.2 Admin 流程

主入口:團控面板 /admin/control(與團控分房同樣的設計原則 — 線控的所有 per-departure / per-traveler 操作都從團控進入)

  • /admin/control 每個 departure 區塊:
    • dep-meta 顯示「護照:N/M 已上傳 [護照管理 →]」統計
    • quick-actions-bar 有「護照管理」按鈕(含 N/M 待補 warning badge)
    • traveler manifest 表新增「護照」欄,每位旅客顯示 ✓ / ✗ / ⚠️ 狀態 badge
    • 點任一旅客的護照 cell → /admin/travelers/[id]/passport 預覽頁

次要入口:訂單詳情頁 /admin/orders/[id]

  • 旅客名單區塊 → 若海外行程且未上傳,顯示「未上傳護照」warning badge
  • 主要為「從單一訂單追蹤」的場景;批次管理仍應由團控進入

Admin 護照預覽頁 /admin/travelers/[id]/passport 可做:

  • 代客戶上傳(用客戶在現場給的圖)
  • 手動編輯 MRZ 欄位(OCR 抽錯時)
  • 看完整影像(thumbnail → click → modal 全螢幕)
  • 重新跑 OCR(換 provider 或調整 region)
  • 手動刪除(緊急刪除,不等 180 天)

重要 UX 假設: 線控以「梯次(departure)」為單位完成工作,不是「訂單」為單位。同一梯次裡可能有來自多個訂單的旅客(家庭購買、團體分單),線控要看整批人的護照狀態而非逐單查看。因此團控才是主入口。

5.3 自動刪除流程(背景)

  • 每日 03:00 cron:
    • SELECT * FROM travelers WHERE passport_image_delete_after < NOW() AND passport_image_object_key IS NOT NULL
    • 對每筆:
      • 從 R2 / GCS 刪 blob
      • UPDATE travelers SET passport_image_object_key = NULL, ..._dek_wrapped = NULL, ..._iv = NULL, ocr_raw_payload_enc = NULL
      • 寫 audit log: traveler.passport_image_purged
    • 保留結構化欄位(號碼、姓名、效期)— 訂單會計憑證需要

6. API / Endpoints

6.1 POST /api/passport/upload-init

Auth: order owner OR admin (sales / op / admin role) Input:

{
  "orderId": "W-2026-1184",
  "travelerId": "trv_xxx",
  "mimeType": "image/jpeg",
  "byteLength": 1234567
}

Output:

{
  "objectKey": "passports/2026/05/W-2026-1184/trv_xxx.enc",
  "uploadUrl": "https://r2.cloudflarestorage.com/...?signed",
  "kmsKeyId": "projects/wildtw/locations/asia-east1/keyRings/main/cryptoKeys/passport-v1",
  "dekWrapped": "base64...",
  "iv": "base64..."
}

6.2 PUT {uploadUrl} (R2 / GCS direct)

Client uploads AES-GCM encrypted bytes directly to bucket (signed URL,server 不轉發 blob 省流量).

6.3 POST /api/passport/process

Auth: order owner OR admin Input:

{
  "orderId": "W-2026-1184",
  "travelerId": "trv_xxx",
  "objectKey": "passports/2026/05/W-2026-1184/trv_xxx.enc"
}

Server logic:

  1. 驗證 traveler 屬於 order,order 屬於 user
  2. 從 R2 / GCS 拉 encrypted blob
  3. KMS unwrap DEK → AES-GCM decrypt blob
  4. 送 Google Vision API
  5. parse MRZ + visual OCR for 中文姓名
  6. 寫 DB:敏感欄位(號碼回 passport_no_enc、姓名 / 效期 / 生日)經 encryptFieldEncryptedField,低敏感欄位明文
  7. 寫 audit log: traveler.passport_ocr_attempted, traveler.passport_ocr_success (or _failed)

Output:

{
  "fields": {
    "passportNumber": "300123456",
    "nameSurname": "CHEN",
    "nameGiven": "CHIA-WEI",
    "nameLatinFull": "CHEN CHIA-WEI",
    "nameChinese": "陳家偉",
    "nationality": "TWN",
    "issuingCountry": "TWN",
    "issueDate": "2020-03-15",
    "expiryDate": "2030-03-15",
    "birthDate": "1990-01-01",
    "sex": "M"
  },
  "ocr": {
    "confidence": 0.94,
    "mrzChecksumValid": true,
    "provider": "google_vision",
    "perFieldConfidence": {
      "passportNumber": 0.99,
      "nameLatinFull": 0.97,
      "nameChinese": 0.71,
      "expiryDate": 0.95
    }
  },
  "warnings": ["nameChinese confidence below 0.8 — please verify"]
}

6.4 POST /api/passport/confirm

Auth: order owner OR admin Input:

{
  "travelerId": "trv_xxx",
  "fields": { ... },  // 可能含人工編輯後的欄位
  "imageObjectKey": "passports/..."
}

Effect:ocr_confirmed_by_user_at, ocr_confirmed_by_user_id,更新欄位。Audit log: traveler.passport_confirmed

6.5 GET /api/admin/passport/[travelerId]/image

Auth: admin only (sales / op / admin) Output: decrypted image bytes (streaming),short-lived response,no caching Audit: traveler.passport_view 必寫,含 actor / IP / UA

6.6 DELETE /api/admin/passport/[travelerId]

Auth: admin only Effect: 立即刪 blob + 清欄位(保留結構化欄位除非另指定) Audit: traveler.passport_delete (manual reason 必填)

7. 角色權限

角色上傳查看自己查看他人編輯欄位刪除影像看 audit
客戶(訂購人)✓ 自訂單✓ 自訂單旅客✓ 自己訂單未確認前
sales✓ 所有訂單own actions
op (operations)own actions
adminall

注意: 客戶不能ocr_confirmed_by_user_at 後修改欄位(避免送票後改名)。admin 可解鎖編輯(會留 audit)。

8. OCR 解析(MRZ format)

ICAO 9303 MRZ-2 line format(一般護照)兩行 88 字元等寬。

8.1 範例(台灣護照)

P<TWNCHEN<<CHIA-WEI<<<<<<<<<<<<<<<<<<<<<<<<<
3001234567TWN9001011M3001011<<<<<<<<<<<<<<00

8.2 Line 1 (Position-based)

PosLengthMeaningExample
11Document typeP
21(filler / sub-type)<
3-53Issuing country (ISO 3)TWN
6-4439Name field: SURNAME<<GIVEN<NAMES filled with <CHEN<<CHIA-WEI<<<<...

8.3 Line 2

PosLengthMeaning
1-99Passport number
101check digit
11-133Nationality
14-196Birth date YYMMDD
201check digit
211Sex (M/F/X)
22-276Expiry date YYMMDD
281check digit
29-4214Personal number (optional)
431check digit
441composite check digit

8.4 Checksum 演算法

ICAO check digit = sum(char_value(i) * weight(i)) mod 10,weights cycle 7, 3, 1,char_value: 0-9 → 0-9, A-Z → 10-35, < → 0

如果任一 check digit fail → 標記 ocrMrzChecksumValid = false,UI 強制人工確認。

8.5 中文姓名抽取(visual OCR,非 MRZ)

MRZ 只有 Latin。中文姓名印在護照上方人像旁,需用 Google Vision 整張回傳的 textAnnotations,根據座標(人像右側、英文姓名上方一行)挑出 2-4 個中文字。

accuracy 預估:85%,confidence < 0.8 一律標 warning,請使用者人工確認。

8.6 Parser 偽碼

// lib/passport/mrz.ts
function parseMrz(line1: string, line2: string): MrzResult {
  if (line1.length !== 44 || line2.length !== 44) {
    return { valid: false, reason: 'wrong line length' };
  }
  const docType = line1[0]; // 'P'
  const issuingCountry = line1.slice(2, 5); // 'TWN'
  const [surname, given] = line1.slice(5).replace(/<+$/, '').split('<<'); // ['CHEN', 'CHIA-WEI']

  const passportNumber = line2.slice(0, 9).replace(/</g, '');
  const passNumCheck = line2[9];
  const nationality = line2.slice(10, 13);
  const birthRaw = line2.slice(13, 19); // YYMMDD
  const birthCheck = line2[19];
  const sex = line2[20]; // M / F / <
  const expiryRaw = line2.slice(21, 27);
  const expiryCheck = line2[27];
  const compositeCheck = line2[43];

  const checks = [
    icaoCheck(passportNumber) === passNumCheck,
    icaoCheck(birthRaw) === birthCheck,
    icaoCheck(expiryRaw) === expiryCheck,
    // composite 涉及多段 concat,省略
  ];
  const checksumValid = checks.every(Boolean);

  return {
    valid: true,
    checksumValid,
    docType,
    issuingCountry,
    nationality,
    surname,
    given: given.replace(/</g, ' ').trim(),
    passportNumber,
    sex: sex === '<' ? 'X' : sex,
    birthDate: yymmddToIso(birthRaw /* assume past */),
    expiryDate: yymmddToIso(expiryRaw /* assume future */),
  };
}

9. 安全與合規

9.1 PDPA / 個資法

  • 個資法第六條特種個資(病歷、犯罪、性生活、健保、基因)— 護照號碼屬於六條特種個資(國民身分證屬第六條,但護照號碼屬一般個資)。但因含照片(生物特徵),仍應視為敏感程度極高
  • 第八條告知義務:上傳前必須清楚說明:
    • 蒐集者:荒野旅人有限公司
    • 蒐集目的:海外行程辦證、訂機票、保險投保
    • 蒐集項目:護照影像、護照號碼、姓名、效期、生日、性別、國籍
    • 利用期間:訂單存續期 + 法定憑證保存年限(5 年);影像出團後 180 天刪除
    • 利用對象:本公司、合作航空公司、保險公司、目的地接待社
    • 利用地區:台灣、出國目的地、Google Cloud asia-east1 region
    • 當事人權利:查閱、補正、刪除(個資法第三條)
  • 第十一條:特定目的消失應主動刪除 → 即上述 180 天政策
  • 第二十七條:適當安全措施 → KMS 加密、access log、最小權限

9.2 技術層

  • 傳輸: TLS 1.3 only,HSTS preload
  • 儲存: R2 / GCS bucket private,object 加密;DB 敏感欄位走既有 application-layer envelope 加密(packages/core/src/crypto/,現成)
  • 存取: admin 看影像必過 short-lived signed URL(5 分鐘),不能瀏覽器書籤直接連
  • DEK 管理: KMS envelope encryption,KEK 不出 KMS HSM
  • Audit: traveler.passport_view 每次都寫,含 actor / IP / UA / signed URL token id
  • Network: R2 / GCS bucket 只接受 server signed PUT,client 不直接 list

9.3 Audit events 表

Event keyWhenIncludes
traveler.passport_uploadupload-init 成功actor, objectKey, mimeType, byteLength
traveler.passport_ocr_attemptedprocess 開始provider, model
traveler.passport_ocr_successOCR 完成confidence, mrzValid, perFieldConfidence
traveler.passport_ocr_failedOCR 失敗error code, retry count
traveler.passport_confirmed使用者按確認confirmer userId, edited fields list (不含值)
traveler.passport_viewadmin 看影像actor, signedUrlTokenId, IP, UA
traveler.passport_field_editadmin 改欄位field name, before/after (hashed)
traveler.passport_deleteadmin 手動刪reason (required)
traveler.passport_image_purged180 天自動刪trigger=cron, deletedAt

10. UI 規範要點

10.1 客戶端 (/passport/upload)

  • 公開站 Apple 風 layout(同 checkout)
  • mobile-first(多數客戶用手機拍)
  • 拍照 / 上傳 / 預覽 三段流程
  • OCR 結果顯示時每欄位旁有 confidence bar(綠 > 0.9 / 黃 0.7-0.9 / 紅 < 0.7)
  • 紅色欄位強制可編輯
  • 「重拍」與「確認送出」對等按鈕

10.2 Admin (/admin/travelers/[id]/passport)

  • shadcn admin layout
  • thumbnail + click 開 modal 全螢幕影像
  • 表單欄位都可編輯(已上傳影像作為視覺驗證來源)
  • audit log 嵌入頁面下方(誰看過、誰改過)
  • 危險動作:刪除影像 / 重跑 OCR 用 outline button + confirm dialog

11. 影響範圍

項目描述
Schema1 個 ALTER:travelers table 新增 20+ 欄位
API5 個新 endpoint (upload-init, process, confirm, admin image, admin delete);另 PUT {uploadUrl} 為 R2 / GCS 直傳,非本系統 endpoint
Background1 個 daily cron (purge expired images)
Client pages1 個客戶端上傳頁 + 1 個 admin 預覽頁
Server libslib/passport/mrz.ts, lib/storage/r2.ts (or gcs);PII 加密複用既有 packages/core/src/crypto/(envelope,不新建)
第三方Google Cloud Vision API key, GCS bucket (or Cloudflare R2);Cloud KMS 已在用(PII envelope 基建現成)
依賴KMS / envelope encryption 基建已落地packages/core/src/crypto/,2026-06-11),直接複用
Audit log新增 9 個 event keys

12. 預估工時

總計:約 1 - 2 weeks (1 工程師 full-time);KMS / envelope 基建現成,無前置整合成本。

區塊工時
接既有 envelope 加密(複用 crypto/,含欄位 encrypt/decrypt 接線)0.5 天
Schema migration + drizzle types0.5 天
MRZ parser + tests1.5 天
Google Vision API client + wrapper1 天
Upload API (init / process / confirm)2 天
客戶端上傳頁(拍照 / 預覽 / 結果)2 天
Admin 預覽 / 編輯頁1.5 天
自動 purge cron0.5 天
Audit log events + tests1 天
PDPA 文案 / 法務確認1 天(含等回應)
End-to-end QA(找 5+ 台灣護照樣本測試)1 天

目標上線: 2026 Q3(北海道四日行程上線前)

13. 未決事項

  1. BlinkID 升級時機? 看 Google Vision 在 100+ 台灣護照樣本的實測 accuracy。上線 1 個月後 review。
  2. 多人護照同時上傳的 batching? 現階段一次一個 traveler。若客戶反映痛,再加批次。
  3. 中文姓名抽取可靠性低(visual OCR,非 MRZ)。 解法:UI 強制人工確認中文姓名欄位(不採信 OCR 結果,只當預填參考)。
  4. 護照即將過期警示(< 6 個月)。 加在 admin/control 頁。海外行程通常要 6 個月以上效期。
  5. R2 vs GCS? GCP-bound 比較順(一個 cloud bill + 同區域 latency),預設 GCS。R2 cost 比較便宜但跨 cloud。
  6. 重跑 OCR 時的 audit 政策。 是否要在 audit log 記錄前後欄位差異?要求 admin 填理由?
  7. iOS HEIC 圖片支援。 Google Vision 不直接吃 HEIC,需 server-side 轉 JPEG(sharp lib),多一步處理。
  8. 客戶端 KMS unwrap 是否安全? 我們做的是「客戶端產 DEK,server unwrap 後 OCR」,DEK 不會在客戶端 wrap。流程要再 review。
  9. 若 OCR 完全失敗(拍歪、髒)— 是否要 fallback 純手填表單? 是,UI 要有「跳過 OCR 改手填」入口。
  10. 訂單轉移時護照資料的處理? 若客戶取消,passport 是否立刻刪?目前傾向「訂單取消後 30 天 + 出團日 180 天 取較早者」。

14. Linked

  • 系統設計總綱
  • KMS / PII envelope encryption 基建(已落地:packages/core/src/crypto/,設計見 docs/superpowers/specs/2026-06-11-kms-pii-encryption-design.md
  • 會員/稽核的 audit log infrastructure(已落地:packages/core/src/audit/log.ts

Changelog

版本日期變更Commit
12026-05-28初版
22026-05-30整理:對齊現行 code + 路徑搬移
32026-06-20對齊已落地 KMS/envelope PII 加密:KMS 由「前置依賴 / 待整合」改為現成基建(複用 packages/core/src/crypto/),MRZ 敏感欄位(姓名/號碼/效期/生日)改直接走 envelope EncryptedField(非先 plaintext 後加密),護照號碼寫回已加密 passport_no_enc;同步 §3.1 / §3.2 / §6.3 / §9.2 / §11 / §12 / §14。OCR 本身(Cloud Vision)維持 deferred。