一句話:旅客在訂單頁上傳護照影像,系統 OCR 抽取 MRZ 欄位自動填入 traveler 資料,影像走 KMS 加密與短期保留;受 模組化 的「海外」開關 gate,護照欄位沿用既有
travelers.passport_no/passport_expiry_date。畫面(UI = wireframe):
前台上傳·後台審閱
CHIA-WEI 打成 CHIAWEI 或 CHA-WEI)2027/01/01 vs 01/01/2027)「我希望可以新增護照上傳功能,並且可以抓取護照上的資訊,像護照號碼、姓名、效期,自動填好就好,不要再叫客人打字了。」
DOCUMENT_TEXT_DETECTION,可解析整張影像並回傳結構化文字座標。travelers table✅ 已拍板:沿用既有
travelers.passport_no_enc(code 為真相 —passport_no/id_number已以 envelope encryption 落 DB 為 jsonbEncryptedField,見 §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'),
}
加密基建為現成:
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已是 jsonbEncryptedField。本功能的所有 PII 欄位直接複用同一套 envelope,不需新前置工作。
| 資料 | 儲存位置 | 加密方式 | 備註 |
|---|---|---|---|
| 護照影像 blob | R2 / GCS bucket | Client AES-256-GCM + KMS-wrapped DEK (envelope encryption) | DEK 永不落地 plaintext |
| OCR raw payload (jsonb) | DB column | envelope encryption(複用 crypto/ 的 EncryptedField) | 含完整 MRZ 文字 |
| MRZ 結構化敏感欄位(姓名/號碼/效期/生日) | DB columns | envelope encryption(jsonb EncryptedField,比照 passportNoEnc) | 經 encryptField 進出;顯示用 mask,比對 / 送票時 decryptField |
| 中文姓名 | DB column | 同上 | OCR 不可靠,多半要人工 |
| 低敏感欄位(國籍 / 性別 / 發照日 / 發照國) | DB columns | 明文 | 非個資高風險,留作查詢 |
為何不用 PG column-level encryption (pgcrypto)?
packages/core/src/crypto/),直接複用比較乾淨。| 資料 | 保留期 | 法規依據 |
|---|---|---|
| 護照影像 blob | 出團日 + 180 天 後自動刪除 | 個資法第 11 條(特定目的消失即刪) |
| OCR raw payload | 同上 | 個資最小化原則 |
| MRZ 結構化欄位 | 訂單關閉後 5 年 | 商業會計法(憑證保存)· 觀光局團體旅遊紀錄 |
| audit log(誰看/改) | 5 年 | append-only hash chain |
實作:
passport_image_delete_after < NOW() → 刪 R2 物件 + 清空 image 相關 columns(保留 MRZ 文字欄位)traveler.passport_purge| 選項 | 優點 | 缺點 | 估算成本 |
|---|---|---|---|
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 Textract | accuracy 與 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 mrz 或 passport-mrz)。後期視台灣護照 accuracy:
/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. 跳回訂單頁,顯示「護照已上傳 ✓」
主入口:團控面板 /admin/control(與團控分房同樣的設計原則 — 線控的所有 per-departure / per-traveler 操作都從團控進入)
/admin/control 每個 departure 區塊:
N/M 待補 warning badge)/admin/travelers/[id]/passport 預覽頁次要入口:訂單詳情頁 /admin/orders/[id]
Admin 護照預覽頁 /admin/travelers/[id]/passport 可做:
重要 UX 假設: 線控以「梯次(departure)」為單位完成工作,不是「訂單」為單位。同一梯次裡可能有來自多個訂單的旅客(家庭購買、團體分單),線控要看整批人的護照狀態而非逐單查看。因此團控才是主入口。
SELECT * FROM travelers WHERE passport_image_delete_after < NOW() AND passport_image_object_key IS NOT NULLUPDATE travelers SET passport_image_object_key = NULL, ..._dek_wrapped = NULL, ..._iv = NULL, ocr_raw_payload_enc = NULLtraveler.passport_image_purgedPOST /api/passport/upload-initAuth: 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..."
}
PUT {uploadUrl} (R2 / GCS direct)Client uploads AES-GCM encrypted bytes directly to bucket (signed URL,server 不轉發 blob 省流量).
POST /api/passport/processAuth: order owner OR admin Input:
{
"orderId": "W-2026-1184",
"travelerId": "trv_xxx",
"objectKey": "passports/2026/05/W-2026-1184/trv_xxx.enc"
}
Server logic:
passport_no_enc、姓名 / 效期 / 生日)經 encryptField 存 EncryptedField,低敏感欄位明文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"]
}
POST /api/passport/confirmAuth: 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。
GET /api/admin/passport/[travelerId]/imageAuth: admin only (sales / op / admin)
Output: decrypted image bytes (streaming),short-lived response,no caching
Audit: traveler.passport_view 必寫,含 actor / IP / UA
DELETE /api/admin/passport/[travelerId]Auth: admin only
Effect: 立即刪 blob + 清欄位(保留結構化欄位除非另指定)
Audit: traveler.passport_delete (manual reason 必填)
| 角色 | 上傳 | 查看自己 | 查看他人 | 編輯欄位 | 刪除影像 | 看 audit |
|---|---|---|---|---|---|---|
| 客戶(訂購人) | ✓ 自訂單 | ✓ 自訂單旅客 | ✗ | ✓ 自己訂單未確認前 | ✗ | ✗ |
| sales | ✓ 所有訂單 | ✓ | ✓ | ✓ | ✗ | own actions |
| op (operations) | ✓ | ✓ | ✓ | ✓ | ✓ | own actions |
| admin | ✓ | ✓ | ✓ | ✓ | ✓ | all |
注意: 客戶不能在 ocr_confirmed_by_user_at 後修改欄位(避免送票後改名)。admin 可解鎖編輯(會留 audit)。
ICAO 9303 MRZ-2 line format(一般護照)兩行 88 字元等寬。
P<TWNCHEN<<CHIA-WEI<<<<<<<<<<<<<<<<<<<<<<<<<
3001234567TWN9001011M3001011<<<<<<<<<<<<<<00
| Pos | Length | Meaning | Example |
|---|---|---|---|
| 1 | 1 | Document type | P |
| 2 | 1 | (filler / sub-type) | < |
| 3-5 | 3 | Issuing country (ISO 3) | TWN |
| 6-44 | 39 | Name field: SURNAME<<GIVEN<NAMES filled with < | CHEN<<CHIA-WEI<<<<... |
| Pos | Length | Meaning |
|---|---|---|
| 1-9 | 9 | Passport number |
| 10 | 1 | check digit |
| 11-13 | 3 | Nationality |
| 14-19 | 6 | Birth date YYMMDD |
| 20 | 1 | check digit |
| 21 | 1 | Sex (M/F/X) |
| 22-27 | 6 | Expiry date YYMMDD |
| 28 | 1 | check digit |
| 29-42 | 14 | Personal number (optional) |
| 43 | 1 | check digit |
| 44 | 1 | composite check digit |
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 強制人工確認。
MRZ 只有 Latin。中文姓名印在護照上方人像旁,需用 Google Vision 整張回傳的 textAnnotations,根據座標(人像右側、英文姓名上方一行)挑出 2-4 個中文字。
accuracy 預估:85%,confidence < 0.8 一律標 warning,請使用者人工確認。
// 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 */),
};
}
packages/core/src/crypto/,現成)traveler.passport_view 每次都寫,含 actor / IP / UA / signed URL token id| Event key | When | Includes |
|---|---|---|
traveler.passport_upload | upload-init 成功 | actor, objectKey, mimeType, byteLength |
traveler.passport_ocr_attempted | process 開始 | provider, model |
traveler.passport_ocr_success | OCR 完成 | confidence, mrzValid, perFieldConfidence |
traveler.passport_ocr_failed | OCR 失敗 | error code, retry count |
traveler.passport_confirmed | 使用者按確認 | confirmer userId, edited fields list (不含值) |
traveler.passport_view | admin 看影像 | actor, signedUrlTokenId, IP, UA |
traveler.passport_field_edit | admin 改欄位 | field name, before/after (hashed) |
traveler.passport_delete | admin 手動刪 | reason (required) |
traveler.passport_image_purged | 180 天自動刪 | trigger=cron, deletedAt |
/passport/upload)/admin/travelers/[id]/passport)| 項目 | 描述 |
|---|---|
| Schema | 1 個 ALTER:travelers table 新增 20+ 欄位 |
| API | 5 個新 endpoint (upload-init, process, confirm, admin image, admin delete);另 PUT {uploadUrl} 為 R2 / GCS 直傳,非本系統 endpoint |
| Background | 1 個 daily cron (purge expired images) |
| Client pages | 1 個客戶端上傳頁 + 1 個 admin 預覽頁 |
| Server libs | lib/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 |
總計:約 1 - 2 weeks (1 工程師 full-time);KMS / envelope 基建現成,無前置整合成本。
| 區塊 | 工時 |
|---|---|
接既有 envelope 加密(複用 crypto/,含欄位 encrypt/decrypt 接線) | 0.5 天 |
| Schema migration + drizzle types | 0.5 天 |
| MRZ parser + tests | 1.5 天 |
| Google Vision API client + wrapper | 1 天 |
| Upload API (init / process / confirm) | 2 天 |
| 客戶端上傳頁(拍照 / 預覽 / 結果) | 2 天 |
| Admin 預覽 / 編輯頁 | 1.5 天 |
| 自動 purge cron | 0.5 天 |
| Audit log events + tests | 1 天 |
| PDPA 文案 / 法務確認 | 1 天(含等回應) |
| End-to-end QA(找 5+ 台灣護照樣本測試) | 1 天 |
目標上線: 2026 Q3(北海道四日行程上線前)
packages/core/src/crypto/,設計見 docs/superpowers/specs/2026-06-11-kms-pii-encryption-design.md)packages/core/src/audit/log.ts)| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-28 | 初版 | — |
| 2 | 2026-05-30 | 整理:對齊現行 code + 路徑搬移 | — |
| 3 | 2026-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。 | — |