specs/member-pdpa-self-service.md

會員自助 PDPA

一句話:前台 /member 旅客端的兩個 PDPA 合規自助功能 —— 資料可攜(下載我的資料)與終止/刪除權(終止會員 = 真正匿名化軟刪),後台會員詳情頁的匿名化動作改接同一支共用服務。

1. 目標 / 問題

  • 前台會員自助區 /member 原本只有訂單 / 個人資料 / 同意紀錄三頁,缺 PDPA 的兩項基本權利入口,屬上線前合規缺口(roadmap)。
  • 後台 members/[id] 原本的匿名化是純 stub(只寫稽核、不抹資料)。本 spec 落地真正的匿名化服務,前台自助與後台 ops 共用同一支,避免兩套行為分歧。

設計原則:真正抹除可識別 PII + 封鎖登入 + 撤銷憑證,但保留訂單外殼 / 金流 / 請款 / append-only 同意書與稽核紀錄(財務與法遵),達到「會員資料去識別化、但帳務與法遵紀錄完整」。

2. 資料模型

新增一欄(來源:packages/core/src/schema/business.ts,drizzle migration drizzle/business/migrations/0006_*.sql):

表欄位型別說明
member_profilesanonymized_attimestamptz nullable匿名化軟刪標記。非 null = 此會員已被匿名化(PII 已清、user 已 ban)。後台會員列表可據此顯示「已終止 / 匿名化」。

不在 user 表加業務欄位(沿用 member-ownership §2 結論:user 由 better-auth 主導,CRM/業務欄位掛租戶自有的 member_profiles)。登入封鎖沿用 better-auth admin plugin 既有的 user.banned / ban_reason,不新增欄位。

2.1 匿名化抹除矩陣(單一 transaction)

表動作細節
userUPDATEname='已終止會員'、email='anonymized+<userId>@deleted.invalid'(保證唯一、.invalid 為 RFC2606 保留 TLD)、image=NULL、email_verified=false、banned=true、ban_reason='pdpa_anonymized'
accountDELETE刪憑證列(密碼雜湊一併移除)
sessionDELETE撤銷全裝置 session
verificationDELETE以原 email(identifier)或 userId(value)刪驗證 / 重設 token(better-auth 不同流程的 identifier 前綴不一、value 常為 userId;務必在改 email 前抓原值)
member_profilesUPSERTPII 欄位清空、anonymized_at=now();無 row 者 insert 一筆只帶 marker
travelersUPDATE涵蓋「該會員名下訂單的旅客 ∪ user_id 歸戶到該會員的旅客」(跨到別人訂單也抹):抹除欄位由 schema 派生(見下),含 full_name='已匿名'、id_number/passport_no/birth_date/phone/email/emergency_contact_*/address/medical_notes/diet/gender/passport_expiry_date/room_preference/roommate_pref/id_number_bidx/id_number_checksum_ok/user_id → 清空
orders.notesUPDATE→NULL清空(可能含 ops 手記的客戶 PII)
outgoing_emailsUPDATE寄給原 email 的信件(大小寫不敏感比對):to_email 去識別化、subject='(已匿名)'、payload={"redacted":true}、error_message=NULL
orders / 金流分類帳 / refunds / payables保留訂單、收退款、出款核簽是法遵與對帳紀錄
consent_signatures保留(無法刪)append-only trigger;同意書是「曾取得同意」的法定證據,作保留例外
audit_log保留 + 追加append-only;追加 action='member.anonymize',metadata 記 { source, reason, scrubbedTravelers, activeOrderCount }

已知保留例外(誠實揭露):

  • consent_signatures 與 audit_log 的 ip_address 因 append-only 無法抹除 —— 設計取捨為「法定保留優先於完全抹除」,前台文案明示「依法須保留的紀錄不刪除,但會與您去連結」。
  • payables(請款核簽,含 payee / 銀行帳號 / 統編)屬出帳憑證與會計對帳軌跡,保留不抹。客戶退款資料源是 refunds;若退款 payee 欄位未來需更嚴格去識別化,另開合規議題。

user 列上鎖:匿名化在 transaction 內先 SELECT ... FOR UPDATE 鎖 user 列,序列化並行的匿名化嘗試。

2.1.1 抹除 / 匯出清單 = schema 派生(機制,非人工清單)

「哪些欄要抹 / 要匯出」不再靠人工維護清單漂移,改為 schema 全欄位強制二分,vitest(member-pdpa-column-coverage.test.ts,drizzle getTableColumns)斷言「scrub map ∪ retain 白名單 = travelers 全欄位、無交集」——新增欄位未歸類即測試紅燈:

  • 匿名化:TRAVELER_ANONYMIZE_SCRUB(欄 → 抹除值的 map,UPDATE SET 直接由它生成,清單即行為)∪ TRAVELER_ANONYMIZE_RETAIN_COLUMNS(結構欄白名單)。
  • 匯出:TRAVELER_EXPORT_COLUMNS ∪ TRAVELER_EXPORT_EXCLUDED_COLUMNS(排除 user_id / id_number_bidx / id_number_checksum_ok 等技術欄);member_climbing_records 同一套二分機制、一併入匯出。

2.1.2 user_id 歸戶聯集(union 路徑)

travelers.user_id(travelers)落地後,匯出與匿名化的旅客涵蓋範圍都是聯集:order 歸屬(orders.user_id = 本人)∪ user_id 歸戶(travelers.user_id = 本人)。匯出時「掛在別人訂單但歸戶到我」的旅客列獨立呈現為頂層 linkedTravelers;匿名化 UPDATE 的 WHERE 同樣走 order_id IN (本人訂單) OR user_id = 本人。exportSchemaVersion 升為 3(v3 = user_id 歸戶 → linkedTravelers)。

2.2 未結訂單守門

countActiveOrdersForUser:未結 = booking_state 尚未 completed/cancelled,或仍有 outstanding 應收 / 退款處理中;tour 看 tour_booking_lines → departures.return_date,lodging 看 lodging_booking_lines.check_out >= 今日 Asia/Taipei。

  • source='self' 且 count>0 → 回 HAS_ACTIVE_ORDERS,前台提示先聯絡客服。
  • source='admin' → 不擋(ops 判斷),但把 count 寫進 audit metadata。

3. 邏輯 / 存取層

共用服務 packages/core/src/members/self-service.ts(first arg 一律 db: ScopedDb):

函式行為回傳
buildMemberDataExport(db, userId)唯讀彙整 account / profile / orders(含 tour/lodging 明細 + 名下 travelers)/ linkedTravelers(user_id 歸戶聯集,§2.1.2)/ climbing records / consents / payments;欄位集由 schema 派生(§2.1.1)MemberDataExport(可序列化,exportSchemaVersion: 3)
countActiveOrdersForUser(db, userId)算未結訂單數(守門用,亦對外給測試)number
anonymizeMember(db, { userId, actorUserId, reason?, source })上述抹除矩陣,單一 transaction,result-object。存在性檢查、customer-only 守門與未結訂單守門皆在 transaction 內(throw sentinel → 回滾 → 轉 result)。只能匿名化一般會員——staff 判定為白名單反轉(members/staff-roles.ts 的 isStaffRole):只有 role 為 null / customer /(不存在於角色目錄的未知值)算客戶;內建非 customer 角色(含 guide)與租戶自訂角色(存在於 roles 表)一律視為 staff,回 NOT_A_MEMBER、由後台人員管理負責。會員列表過濾共用同一 helper 的 CUSTOMER_ONLY SQL 片段。{ ok:true; scrubbedTravelers; activeOrderCount } | { ok:false; reason:'NOT_FOUND' | 'NOT_A_MEMBER' | 'HAS_ACTIVE_ORDERS'; activeOrderCount? }
  • 匯出 route:GET /api/member/export(src/app/api/member/export/route.ts)。route handler 不經 member layout 登入閘,故自行 getCurrentSession(無則 401)→ buildMemberDataExport → 回 JSON 附件(content-disposition: attachment; filename="wildtw-my-data-YYYY-MM-DD.json"、cache-control: no-store)→ 寫 audit_log member.data_export。
  • 前台終止 action:terminateMyAccountAction(confirmEmail)(src/app/(public)/member/privacy/actions.ts)。requireSession → 驗 confirmEmail 與 session email 相符(後端防線)→ anonymizeMember(source:'self')。
  • 後台:anonymizeMemberAction(src/app/(admin)/admin/members/[id]/actions.ts)改接 anonymizeMember(source:'admin'),gate 維持 member.update,移除原 audit-only stub。

4. UI 與權限

  • 新分頁 /member/privacy「資料與隱私」(src/app/(public)/member/privacy/page.tsx,requireSession);登錄進 MemberSideNav TABS。
    • DataExportCard:說明 + <a href="/api/member/export"> 樣式化按鈕觸發下載(無 client state)。
    • TerminateAccountCard(client):destructive 卡,列出後果;<Input> 要求輸入自己的 email,相符才啟用「終止我的會員資格」按鈕;成功後 router.push('/?account=terminated')(session 已刪 = 登出)。用公開站 design system(src/components/ui/*,無 modal,inline 確認)。
  • 後台 AnonymizeButton 文案改為真正匿名化(不再是先前「僅紀錄稽核」的 stub)。
  • 權限:前台自助走 requireSession + owner-id(會員只能匿名化自己)—— 不新增 permission。後台沿用既有 member.update。

5. 開放議題

項目狀態
匿名化是否需密碼重新驗證現階段否(輸入 email 確認詞句即可);高敏感租戶可後續加 better-auth 驗密
consent_signatures / audit_log 的 ip_address 保留已定:append-only 法定保留優先,列為已知例外
終止後是否寄確認信未做(email 已去識別化,寄信無意義);若要寄「已終止」通知須在抹除前先寄
後台 admin 匿名化是否也該擋未結訂單已定:不擋(ops 判斷),僅記 count
未結訂單守門的殘留 TOCTOU守門已移進 transaction + FOR UPDATE 鎖 user 列,但 orders 插入不鎖 user 列,理論上仍有極窄並行窗(使用者一邊下單一邊終止)。可接受;徹底解需 checkout 端拒絕 banned user(後續)
getSession cookie 快取是否影響 fail-closed已驗證不影響:本專案 buildAuth 一律帶 database adapter,better-auth 的 session.cookieCache 預設未啟用(預設啟用只在無 DB adapter 時),故刪 session 列後 getCurrentSession 一律查 DB、即時失效