1. 為什麼在 ChatGPT App 中需要 audit & lifecycle
當你寫原型時,用戶是你自己,資料庫是本機 SQLite,而「事故」用 git reset --hard 就能治好,一切看起來既可愛又單純。
但一旦你的 GiftGenius(或其他 ChatGPT 應用(App))迎來真正的使用者,尤其牽涉付款與 PII,立刻會冒出:
- 客戶的資安團隊會問:「誰能看到我們的訂單?誰改過它們?」;
- 法務會問:「你們資料保存多久?如何執行『刪除我的資料』的請求?」;
- 生產環境的現實會問:「如果工程師在 prod 掉了資料表會怎樣?」。
在本講座我們會拆成四個支柱:
- Audit 日誌 — 用於安全與稽核的獨立日誌層。
- Data retention — 各類資料的生命週期與落地方式。
- 依使用者請求刪除 — 技術上落實「被遺忘權」。
- Business continuity & backups — 如何撐過故障,不丟臉也不丟資料。
所有範例會盡量對齊我們的教學用 App(以 Next.js + Apps SDK + MCP 的 GiftGenius 為例)。
2. Audit 日誌:誰、做了什麼、何時,以及結果如何
Audit 日誌與一般應用日誌有何不同
一般 application 日誌是寫給工程師看的友善訊息。裡面有 stack trace、debug 資訊、奇怪的變數值、除錯用的 console.log("這裡絕對不應該是 null")。 它們保存不久,供工程人員閱讀。
Audit 日誌是另一個世界。它的主要受眾是資安人員、稽核、偶爾還有法務。他們不需要「第 55 行出現 NullPointer」這種訊息,而需要的是 「使用者 X 在某時修改了租戶 Y 的付款設定,結果——成功」這樣的記錄。Audit 記錄通常保存更久(以年計),在調查時可視為證據。
關鍵差異:
| 特性 | Application Logs | Audit Logs |
|---|---|---|
| 目的 | 偵錯、診斷 | 安全、法遵、調查 |
| 受眾 | 開發者、SRE | 資安人員、法務、偶爾還有監管單位 |
| 資料內容 | 技術細節、stack trace | 誰/做了什麼/何時/對哪個資源/結果如何 |
| 保存期限 | 數週至一個月 | 數年(常見為 ≥ 1 年) |
| 對日誌的操作 | 可以刪除/覆寫 | 最好為 append‑only,不允許 UPDATE/DELETE |
OWASP 等指南特別強調:Audit 日誌最好放在獨立的儲存體或資料表,不要與一般應用日誌混在一起。
在 ChatGPT 應用的情境中要記錄什麼
對於 ChatGPT 應用,尤其有商務功能時,稽核的最低限度包括:
- 身分驗證事件:登入、登出、登入嘗試;
- 關鍵資料的操作:建立/更新/刪除個人檔案、訂單、付款設定;
- 管理性操作:變更角色、修改租戶設定;
- 對敏感 MCP/Agents 工具的呼叫:create_order、charge_customer、cancel_subscription 等等。
一個好直覺:凡是在事件時你會想問「誰做的、透過什麼做的?」的東西,都應該進稽核。
稽核事件的結構
方便的心智模型:每條記錄 = 「誰 / 做了什麼 / 針對什麼 / 在什麼背景 / 結果如何」。 常見結構為 who、 action、 resource、 context、 outcome。
以我們的 GiftGenius 為例,用 TypeScript 描述介面:
// lib/audit.ts
export type AuditAction =
| "auth.login"
| "auth.logout"
| "order.create"
| "order.cancel"
| "account.delete"
| "giftidea.generate";
export interface AuditEvent {
eventId: string; // uuid
timestamp: string; // ISO
actor: {
userId: string | null; // 在登入前可能為 null
tenantId?: string | null;
ip?: string | null;
client: "chatgpt-app" | "admin-panel" | string;
};
action: AuditAction;
resource?: {
type: string; // "order", "user", ...
id?: string;
};
context?: {
mcpTool?: string;
requestId?: string;
};
outcome: {
status: "success" | "failure";
reason?: string | null;
};
}
請注意,事件中不應包含完整的電子郵件、卡號等 PII。 根據前面課程,我們已學會在沒有必要時進行遮罩並避免記錄。
在哪裡以及如何儲存 Audit 日誌
對儲存層的最低需求:
- 將其與一般日誌分開為獨立資料表或甚至獨立資料庫,降低不小心覆蓋/刪除的風險;
- 盡量採用 append‑only:技術上可用政策「我們從不對此表做 UPDATE/DELETE」,並配置僅有 INSERT 與 SELECT 權限的資料庫角色;
- 存取受限:並非所有工程師都應擁有讀取完整 audit 的權限。
若你透過 Prisma/Drizzle 使用 PostgreSQL,模型可能如下(簡化範例):
CREATE TABLE audit_events (
event_id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
actor_user_id text,
actor_tenant_id text,
actor_ip inet,
action text NOT NULL,
resource_type text,
resource_id text,
context_mcp_tool text,
context_request_id text,
outcome_status text NOT NULL,
outcome_reason text
);
Schema 可依你的需求調整,但關鍵是結構化。 將 JSON 垃圾塞成一行,將來你會自己咒罵自己。
在我們的 App 中實作稽核
在 Next.js 應用中寫個小 helper(例如於 MCP 伺服器或 API 路由的 Node 環境):
// lib/audit.ts
import { randomUUID } from "crypto";
import { db } from "./db"; // 你的資料庫客戶端
export async function logAudit(event: Omit<AuditEvent, "eventId" | "timestamp">) {
const full: AuditEvent = {
...event,
eventId: randomUUID(),
timestamp: new Date().toISOString(),
};
// 真實環境中—透過佇列/背景處理,這裡先直接插入
await db.insertInto("audit_events").values({
event_id: full.eventId,
created_at: full.timestamp,
actor_user_id: full.actor.userId,
action: full.action,
outcome_status: full.outcome.status,
outcome_reason: full.outcome.reason ?? null,
// ...其餘欄位
});
}
現在把呼叫加入建立訂單的處理器中(假設這是 MCP 工具或伺服端 endpoint):
// app/api/orders/route.ts
export async function POST(req: Request) {
const user = await requireUser(req); // 來自驗證模組
const body = await req.json();
const order = await createOrderInDb(user, body);
await logAudit({
actor: { userId: user.id, client: "chatgpt-app" },
action: "order.create",
resource: { type: "order", id: order.id },
context: { mcpTool: "create_order_tool" },
outcome: { status: "success" },
});
return Response.json(order);
}
對於高風險操作亦然——取消訂單、變更付款資料、刪除帳號。
我們已有獨立的結構化稽核層——很好。下一個自然問題是: 要保存多久這些事件(以及其他使用者資料),期限到了又該怎麼處理?
3. Data retention:你的資料能活多久
為什麼不能把所有東西永遠留著
工程師的「以防萬一」本能,在使用者資料的情境下非常危險。
首先,資料越多、留越久,外洩時後果越嚴重: 油桶越大,火災越可怕。許多資料保護指南把資料稱作「有毒資產」:要有價值地保存,但同時盡量減少容量與保存時間。
其次,法規如 GDPR/CCPA 引入「僅保存到達成處理目的之必要期限」的原則。 也就是說,不能為了「以備不時之需」而無限期保存個資。對每種資料都應有明確的保存期限與刪除或匿名化程序。
第三,雲端儲存是要花錢的。 大型日誌表與聊天歷史成長很快,一年後你會驚覺,供應商帳單的一半是「昨天的垃圾」。
不同資料—不同期限
公司的實務與公開指南大致呈現如下:
| 資料類型 | 典型保存期限 |
|---|---|
| Debug 日誌、技術度量 | 1 至 12 個月 |
| Audit 日誌 | ≥ 12 個月,有時 2–5 年 |
| 訂單、付款、發票 | 3–7 年(依會計/稅務要求) |
| Session、臨時 token | 數小時至數天 |
| 原始聊天 / 請求 | 幾週到幾個月,或乾脆不保存 |
| 匿名彙總(分析) | 更久,因為已無 PII |
重點:這不是法律建議,而是工程參考。真正的產品要與法務協調期限, 但在技術上你已該準備好實作不同的 TTL。
如何在程式中實作留存策略
最常見的模式:資料表有 created_at 或 expires_at, 由週期性程序刪除或匿名化過舊的資料。
範例:清理超過 90 天的一般日誌。
// scripts/cleanup-logs.ts
import { db } from "../lib/db";
async function cleanup() {
await db
.deleteFrom("app_logs")
.where("created_at", "<", new Date(Date.now() - 90 * 24 * 60 * 60 * 1000));
console.log("Old logs removed");
}
cleanup().catch(console.error);
此腳本可用 cron、GitHub Actions,或雲端排程器定期執行。
對於 PII,常以匿名化替代刪除。比如,將超過 N 年的訂單移除與特定使用者的連結:
UPDATE orders
SET user_id = NULL
WHERE created_at < now() - interval '3 years';
這樣會保留金額、商品與其他「會計」資訊,但去除與個人的關聯。
別忘了,備份也應有自己的保存期限。 備份的頻率與保存時長我們會在備份章節另談,但理念相同: 連封存檔也不能無限期保留,否則「被遺忘權」會變成口號。
4. 依使用者請求刪除:把「被遺忘權」落實到程式
來源是什麼
歐盟 GDPR(與類似法規)引入所謂的「被遺忘權」:使用者可以要求刪除其個人資料, 公司則必須在不合理延誤內完成。
對開發者而言,遲早會收到「刪除我所有資料」的請求 (或你自己做了「Delete my data」按鈕),屆時不只要刪掉 users 資料表中的那筆記錄, 還要沿著所有足跡清理:訂單、Session、token、操作日誌、CRM、金流等等。
但也有法律要求你必須保存某些資料:例如財務交易。 因此這裡的法律複雜度往往高於技術。
究竟要清哪些
以 GiftGenius 為例,最低清單:
- 使用者檔案(姓名、電子郵件、設定);
- Session、refresh token、與 OAuth 供應商的關聯;
- 訂單(若不需要「可識別個人」的形態,或可做匿名化);
- 包含 PII 的日誌與 audit 記錄(例如未遮罩的 e‑mail)。
同時會保留對報表重要、但已去識別的資料——訂單金額、交易數量、各國彙總等。
刪除流程範例
情境流程:
- 使用者(已登入)按下「刪除我的帳號」。
- 伺服器收到帶有 userId 的請求。
- 伺服器:
- 刪除/匿名化相依記錄(訂單、Session、整合);
- 清理個人檔案中的 PII;
- 在 audit 日誌寫入「處理資料刪除請求」。
為簡化,我們只示範幾個資料表。實際產品會在這個核心上擴充更多實體 (整合、第三方服務等)。
Next.js 的服務端程式(精簡示例):
// app/api/delete-me/route.ts
import { db } from "@/lib/db";
import { logAudit } from "@/lib/audit";
export async function POST(req: Request) {
const user = await requireUser(req);
await db.transaction(async (tx) => {
await tx.deleteFrom("sessions").where("user_id", "=", user.id);
await tx.deleteFrom("orders").where("user_id", "=", user.id);
await tx.updateTable("users")
.set({
is_deleted: true,
name: null,
email: null,
})
.where("id", "=", user.id);
await logAudit({
actor: { userId: user.id, client: "chatgpt-app" },
action: "account.delete",
outcome: { status: "success" },
});
});
return new Response(null, { status: 204 });
}
在真實世界你會加入外部 API 呼叫(例如 Stripe——解除綁定 customer), 也會將交易處理做得更完整。但原則已在:集中處理,並有 audit 記錄。
與備份的相互影響
「備份怎麼辦」這一段有很多眉角。即使你已從生產資料庫刪除了使用者, 他/她的資料仍可能殘留在夜間快照中。為了避免「實際上永遠不會真的刪除」的狀況,有兩種做法:
- 備份本身只活一定時間(例如 30–90 天),到期後連同資料一起消失。當 retention 到期,無論主資料庫或封存都不再包含該使用者。
- 若你確實從備份還原系統,必須持有「已刪除」ID 的登錄,並在還原後再次執行刪除/匿名化腳本。
大型公司有時會採用 crypto‑shredding:使用者的 PII 以獨立金鑰加密,收到刪除請求時銷毀金鑰。 即使某處還有加密過的副本(在日誌、備份裡),沒有金鑰也只是垃圾。很酷,但對新創而言有點像火箭科學。
重要的 UX 重點
記住,刪除不只是 SQL。使用者期望:
- 清楚的申請方式(按鈕、表單、電子郵件);
- 合理的處理時程(實務上通常在 30 天內);
- 成功通知或具體拒絕理由(例如部分資料依法必須保存)。
從技術面你已備妥:你會清理、會記錄、也不會在備份裡保存不該保存的東西。
5. Business continuity & 備份
現在想像上述一切都運作完美……直到發生致命的 DROP TABLE orders、 雲端故障或整個區域掛掉。我們需要機制來在合理時間內恢復服務,且不遺失關鍵資料。
RTO 與 RPO — 決定痛苦程度的兩個指標
災難復原的兩個基本參數:
- RTO(Recovery Time Objective) — 你可以接受的停機時間。 例如,如果 RTO = 1 小時,代表嚴重故障後你必須在最多一小時內讓系統恢復。
- RPO(Recovery Point Objective) — 你可以接受的資料時間損失。 若 RPO = 10 分鐘,代表恢復時最多只能遺失最近 10 分鐘的資料。
產品越關鍵(銀行、交易系統),兩者越接近 0。 對教學用的 GiftGenius,RTO 可容許數小時、RPO 約 15–60 分鐘,但仍需確實落地。
你的技術棧會出哪些差錯
在 Vercel + 雲端資料庫 + 外部 API 的 ChatGPT 應用情境下,常見問題包括:
- OpenAI API 無法連線:你的 App 在 tool‑calls 上回錯誤。
- Vercel(或其他)出故障:小工具(widget)無法連到你的 backend。
- 資料庫損毀或資料被誤刪(例如 DROP TABLE)。
- 控制基礎設施的帳號被入侵或破壞。
你需要以備份、複寫,以及應用在故障時的合理行為之組合來應對。
備份策略
現代雲端 Postgres/其他資料庫通常至少提供三種選項:
- 完整備份 + 增量備份。
每日一次完整快照(snapshot),其間保存增量變更。還原流程:回復到指定 snapshot,再重播變更日誌。 - Point‑in‑Time Recovery(PITR)。
資料庫寫入交易日誌(WAL),允許回復到任意時間點(例如「14:03:00 的狀態,在我們 drop 表之前」)。 - 跨區域複寫。
在另一個區域/雲端維持被動或主動複本。當主要區域失效時,可切換到複本,只遺失尚未同步過去的資料。
對我們的規模而言,啟用供應商的 PITR 與週期性的異地(off‑site)備份通常已足夠。
簡單例子:本機/dev 資料庫的每日 dump
即便生產環境使用託管資料庫,對 staging/dev 有時仍想要簡單腳本:
# scripts/backup.sh
#!/usr/bin/env bash
set -e
DATE=$(date +%F)
pg_dump "$DATABASE_URL" > "backups/backup-$DATE.sql"
echo "Backup created: backups/backup-$DATE.sql"
可從 cron 或 GitHub Actions 執行。重點是——備份也要依保存期限刪除。
外部服務當機時 App 的行為
備份與 PITR 解的是「若一切全壞或資料毀損,怎麼辦」。但商業現實更常見的是部分故障—— 上游 API 掛了、網路被切、金流卡住。
當 OpenAI API 或金流當機時,最糟的策略是回 500 且附上無意義的 stack trace。理想上:
- 後端回傳結構化錯誤,例如 { error: "upstream_unavailable" };
- 前端小工具顯示對人友善的訊息:「服務暫時無法使用,請稍後再試」;
- 系統不要對掛掉的 API 無限重試(Circuit Breaker 等模式我們會在韌性模組中詳談)。
考量上游錯誤的 MCP 工具處理器範例:
// mcp/tools/createGiftIdea.ts
export async function createGiftIdea(args: Input): Promise<Output> {
try {
return await callOpenAiModel(args);
} catch (err) {
await logAudit({
actor: { userId: args.userId ?? null, client: "chatgpt-app" },
action: "giftidea.generate",
outcome: { status: "failure", reason: "openai_unavailable" },
});
throw new Error("UPSTREAM_UNAVAILABLE");
}
}
接著你在 MCP 與小工具之間的中介層即可在 UI 以合宜方式呈現此錯誤。
復原演練:沒有 restore 的備份,只是個檔案
典型反模式:每天做備份,大家都很安心……直到發現根本無法還原 (格式變了、金鑰遺失、空間不足)。
最低限度計畫:
- 定期(例如每月)用備份啟動一個 staging 環境;
- 走過基本情境:登入、建立訂單、App 的主要流程;
- 確認實際恢復時間與資料損失量符合你的 RTO/RPO。
不必把這堂課變成「DevOps 宗教」,你只需理解:備份流程是 App 架構的一部分, 而不是「雲端某個人會幫你弄好」的東西。
6. 視覺化:資料與事件的生命週期
為了不只停留在文字,我們畫兩個簡單示意圖。
使用者資料的生命週期
flowchart TD
A["資料建立<br/>(註冊、下單)"] --> B["儲存與使用<br/>(prod DB)"]
B --> C["封存/彙總<br/>(匿名指標)"]
B --> D[刪除請求]
D --> E[在 prod DB 進行刪除/匿名化]
E --> F["備份到期<br/>(retention)"]
重點:資料生命週期不在生產資料庫就結束——它也延伸到備份。
危險操作的稽核流程
sequenceDiagram
participant User as 使用者
participant ChatGPT as ChatGPT
participant App as 你的後端/MCP
participant DB as 資料庫
participant Audit as Audit 儲存
User->>ChatGPT: "取消訂單 #123"
ChatGPT->>App: callTool cancel_order
App->>DB: UPDATE orders SET status='canceled'
App->>Audit: INSERT audit_event {actor, action, resource, outcome}
App-->>ChatGPT: 操作結果
ChatGPT-->>User: 結果訊息
7. audit & lifecycle 的常見錯誤
錯誤 №1:把 Audit 日誌和一般日誌混在一起。
當所有訊息都進同一個 logs 索引,半年後誰也分不清 「使用者變更了管理員角色」和「我們又遇到 null reference」。Audit 應記錄結構化、貼近商務層的事件 (見稽核事件結構段落),並置於權限受限的獨立儲存。
錯誤 №2:在稽核與 debug 日誌中記錄 PII。
完整電子郵件、電話、配送地址、卡片末四碼——這些經常不小心出現在日誌裡。 它提高外洩風險,也違反隱私建議。請改記錄識別碼與遮罩後的值。
錯誤 №3:沒有留存政策——「全部永遠留著」。
在 MVP 階段看似無傷,但一年後你的資料表會長得巨大,任何分析查詢 都像在 DDoS 自己的資料庫。同時你也違反現代資料法規的最小化原則。 必須先想好各類資料的最小 TTL,並自動化清理。
錯誤 №4:「依請求刪除」 == DELETE FROM users.
若你只刪掉使用者那一列,但在訂單、Session 與日誌裡仍留著他的 PII,那等於沒刪。 正確做法是以交易方式清理所有關聯實體;無法刪除之處,則匿名化。 別忘了把刪除本身記為一條 audit 事件。
錯誤 №5:刪除資料時忽略備份。
在 prod 刪了使用者——很好,但他的資料還在舊快照裡活了一年。從中還原後又「復活」, 你就違反了給使用者與在 Privacy Policy 裡的承諾。要嘛限制備份的保存期, 要嘛在還原後有再次套用刪除的程序。
錯誤 №6:「我們有開備份就好」,但從未嘗試還原。
從未驗證可還原性的備份,只是昂貴的檔案。沒有定期的還原演練,你不知道實際的 RTO/RPO, 也不知道你的 DR 計畫是否可行。最低限度是——按清單定期用備份拉起一個 staging 做測試。
錯誤 №7:文件與實際不符。
你在 Privacy Policy 寫「日誌保存 30 天,依請求刪除資料」,但在程式裡一切永存。 ChatGPT 商店、企業客戶與稽核很快會問「請出示留存表」與 「示範刪除指定使用者」。最好先做到,再寫文件。
GO TO FULL VERSION