一句話:前台
/member旅客端的兩個 PDPA 合規自助功能 —— 資料可攜(下載我的資料)與終止/刪除權(終止會員 = 真正匿名化軟刪),後台會員詳情頁的匿名化動作改接同一支共用服務。
/member 原本只有訂單 / 個人資料 / 同意紀錄三頁,缺 PDPA 的兩項基本權利入口,屬上線前合規缺口(roadmap)。members/[id] 原本的匿名化是純 stub(只寫稽核、不抹資料)。本 spec 落地真正的匿名化服務,前台自助與後台 ops 共用同一支,避免兩套行為分歧。設計原則:真正抹除可識別 PII + 封鎖登入 + 撤銷憑證,但保留訂單外殼 / 金流 / 請款 / append-only 同意書與稽核紀錄(財務與法遵),達到「會員資料去識別化、但帳務與法遵紀錄完整」。
新增一欄(來源:packages/core/src/schema/business.ts,drizzle migration drizzle/business/migrations/0006_*.sql):
| 表 | 欄位 | 型別 | 說明 |
|---|---|---|---|
member_profiles | anonymized_at | timestamptz nullable | 匿名化軟刪標記。非 null = 此會員已被匿名化(PII 已清、user 已 ban)。後台會員列表可據此顯示「已終止 / 匿名化」。 |
不在
user表加業務欄位(沿用 member-ownership §2 結論:user由 better-auth 主導,CRM/業務欄位掛租戶自有的member_profiles)。登入封鎖沿用 better-auth admin plugin 既有的user.banned/ban_reason,不新增欄位。
| 表 | 動作 | 細節 |
|---|---|---|
user | UPDATE | name='已終止會員'、email='anonymized+<userId>@deleted.invalid'(保證唯一、.invalid 為 RFC2606 保留 TLD)、image=NULL、email_verified=false、banned=true、ban_reason='pdpa_anonymized' |
account | DELETE | 刪憑證列(密碼雜湊一併移除) |
session | DELETE | 撤銷全裝置 session |
verification | DELETE | 以原 email(identifier)或 userId(value)刪驗證 / 重設 token(better-auth 不同流程的 identifier 前綴不一、value 常為 userId;務必在改 email 前抓原值) |
member_profiles | UPSERT | PII 欄位清空、anonymized_at=now();無 row 者 insert 一筆只帶 marker |
travelers | UPDATE | 該會員名下訂單的旅客:full_name='已匿名'、id_number/passport_no/birth_date/phone/email/emergency_contact_* → NULL |
orders.notes | UPDATE→NULL | 清空(可能含 ops 手記的客戶 PII) |
outgoing_emails | UPDATE | 寄給原 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 列,序列化並行的匿名化嘗試。
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。共用服務 packages/core/src/members/self-service.ts(first arg 一律 db: ScopedDb):
| 函式 | 行為 | 回傳 |
|---|---|---|
buildMemberDataExport(db, userId) | 唯讀彙整 account / profile / orders(含 tour/lodging 明細 + 名下 travelers)/ consents / payments | MemberDataExport(可序列化,exportSchemaVersion: 1) |
countActiveOrdersForUser(db, userId) | 算未結訂單數(守門用,亦對外給測試) | number |
anonymizeMember(db, { userId, actorUserId, reason?, source }) | 上述抹除矩陣,單一 transaction,result-object。存在性檢查、customer-only 守門與未結訂單守門皆在 transaction 內(throw sentinel → 回滾 → 轉 result)。只能匿名化一般會員(role 為 null/customer);staff 帳號回 NOT_A_MEMBER,由後台人員管理負責。 | { ok:true; scrubbedTravelers; activeOrderCount } | { ok:false; reason:'NOT_FOUND' | 'NOT_A_MEMBER' | 'HAS_ACTIVE_ORDERS'; activeOrderCount? } |
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。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。/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。| 項目 | 狀態 |
|---|---|
| 匿名化是否需密碼重新驗證 | 現階段否(輸入 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、即時失效 |
| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-06-03 | 初版:前台 /member/privacy 資料匯出 + 終止會員(真正匿名化),後台 stub 改接共用 service | — |