CodeGym /课程 /ChatGPT Apps /Audit & lifecycle: audit logs, data retention, 按请求删除,...

Audit & lifecycle: audit logs, data retention, 按请求删除, continuity/backups

ChatGPT Apps
第 15 级 , 课程 4
可用

1. 为什么在 ChatGPT App 中需要 audit & lifecycle

在做原型时,用户就是你自己,数据库是本地 SQLite,而“事故”的修复就是 git reset --hard,一切看起来轻松惬意、其乐融融。

但一旦你的 GiftGenius(或其他 ChatGPT 应用 (App))迎来真实用户,尤其涉及支付和 PII,问题就会突然出现:

  • 客户安全同事会问:“谁能看到我们的订单,谁改动过它们?”
  • 法务会问:“你们数据保存多久?如何履行‘删除我的数据’请求?”
  • 线上现实会问:“如果开发在生产把表给删了怎么办?”

本讲我们将拆解四个核心板块:

  1. Audit 日志——用于安全与审计的独立日志层。
  2. Data retention——不同类型数据的生命周期与实现方式。
  3. 按用户请求删除——技术上实现“被遗忘权”
  4. Business continuity & backups——如何在故障中不丢脸、不丢数据。

我们会尽量将示例绑定到我们的教学 App(假想的 GiftGenius,基于 Next.js + Apps SDK + MCP)。

2. Audit 日志:谁、做了什么、何时、结果如何

审计日志与普通日志有何不同

普通的 application 日志是给开发看的友好信息。里面有 stack trace、debug 信息、奇怪的变量值、用于调试的 console.log("这里绝不应该是 null")。 它们保留时间不长,读者是工程师。

Audit 日志是另一个世界。其主要受众是安全团队、审计师,有时是法务。他们不需要“第 55 行出现 NullPointer”这种信息,而需要一条记录: “用户 X 在某个时间更改了组织 Y 的支付设置,结果——成功”。审计记录通常保存更久(以年计),并在调查时作为证据。

关键区别:

特征 Application Logs Audit Logs
目标 调试、诊断 安全、合规、调查
受众 开发、SRE 安全、法务,有时是监管方
数据构成 技术细节、stack trace 谁/做了什么/何时/对哪个资源/结果如何
保存期限 数周–1 个月 以年计(常见 ≥ 1 年)
对日志的操作 可删除/覆盖 最好 append‑only,不用 UPDATE/DELETE

OWASP 等指南特别强调:审计日志最好存放在独立的存储或表中,而不要和应用的普通日志混在一起。

在 ChatGPT 应用中应记录什么

对商业场景的 ChatGPT 应用,合理的审计最小集合包括:

  • 认证事件:登录、登出、登录尝试;
  • 对关键数据的操作:创建/更新/删除用户档案、订单、支付设置;
  • 管理动作:角色变更、租户设置修改;
  • MCP/Agents 的敏感工具调用:create_ordercharge_customercancel_subscription 等等。

一个好直觉是:凡是在事故中你会问“是谁通过什么做的?”的问题,都应该进入审计。

审计事件的结构

一个好用的心智模型:每条记录就是“谁 / 做了什么 / 对什么 / 在什么上下文 / 结果如何”。 通常把它表述为 whoactionresourcecontextoutcome 这几个字段。

针对我们的 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;
  };
}

注意,事件中不要包含完整 e‑mail、卡号等我们在前几讲中说过应尽量掩码或避免记录的 PII。

在哪里以及如何存储审计日志

对存储的最低要求:

  • 使用独立的表,甚至独立于普通日志的数据库,降低误删/覆盖的风险;
  • 尽量采用 append‑only:技术上可以仅仅是“我们从不对该表做 UPDATE/DELETE”的策略,并给数据库角色只授予 INSERTSELECT
  • 限制访问:不需要让所有工程师都能读取完整审计日志。

如果你通过 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(Node 环境,比如在 MCP 服务器或 API 路由里):

// 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 等法规强调“仅在处理目的需要的最短期限内保存”。 也就是说,不能“以备不时之需”无限期保存个人数据。需要为每类数据设定明确的保存期限和删除/匿名化流程。

再者,云存储是要花钱的。 巨大的日志表与聊天历史增长迅速,一年后你会发现服务账单的一半都是“昨日垃圾”。

不同数据——不同期限

行业经验与公开指南大致给出如下图景:

数据类型 典型保留期限
调试日志、技术指标 1 到 12 个月
Audit 日志 ≥ 12 个月,有时 2–5 年
订单、支付、发票 3–7 年(会计/税务要求)
会话、临时令牌 数小时–数天
原始聊天/请求 数周到数月,或干脆不存
匿名化聚合(分析) 更久,因为不含 PII

重点提示:这不是法律意见,而是工程参考。对真实产品,你需要与法务确认期限,但从技术上你需要能实现不同的 TTL。

如何在代码中实现 retention

最常见的模式:表上有 created_atexpires_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 表中的一条记录, 还要沿着数据痕迹逐一清理:订单、会话、令牌、行为日志、CRM、支付系统等。

但也有法律要求必须保留的内容:比如财务交易。因此这里的法律复杂度往往高于技术复杂度。

需要清理哪些内容

以我们的 GiftGenius 为例,最小集合包括:

  • 用户档案(姓名、e‑mail、设置);
  • 会话、refresh 令牌、与 OAuth 提供商的关联;
  • 订单(若无需“个性化”保留,或可匿名化);
  • 日志与审计记录中包含的 PII(例如以明文出现的 e‑mail)。

与此同时,为报告用途保留的去标识化数据仍可存在——订单金额、交易次数、按国家的聚合等。

删除算法示例

场景流程:

  1. (已登录的)用户点击“删除我的账号”。
  2. 服务器接收带有其 userId 的请求。
  3. 服务器:
    • 删除/匿名化依赖记录(订单、会话、集成关系);
    • 清理用户档案中的 PII;
    • 写入审计日志:“已处理数据删除请求”。

为简洁起见,下面只演示两张表。真实产品会在此基础上扩展更多实体(集成、第三方服务等)。

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),并让事务更严谨。 但原则已经明确:集中处理,并记录审计事件。

与备份的关系

“那备份里的数据怎么办?”常常引发很多问题。即使你已从生产库删除用户,其数据仍可能留在夜间快照中。 为避免变成“实际上从不真正删除”,有两种思路:

  1. 备份本身有有限保留期(如 30–90 天),期满后连同其中的数据一起消失。保留期届满后,主库与归档都不再包含该用户。
  2. 若你从备份中恢复系统,你维护一个“已删除”ID 的清单,恢复后再次执行删除/匿名化脚本。

大公司有时采用 crypto‑shredding:用独立密钥加密用户 PII,请求删除时销毁该密钥。 即使在日志、备份中存在加密副本,没有密钥也毫无意义。很酷,但对初创团队来说有点“黑科技”。

重要的 UX 要点

记住,删除不仅仅是 SQL。用户通常期望:

  • 明确的请求入口(按钮、表单、e‑mail);
  • 合理的处理时限(实践中通常不超过 30 天);
  • 关于成功与否的通知,或有理有据的拒绝(例如部分数据依法必须保留)。

从技术侧,你已经就绪:能完成清理、记录审计并不在备份中无限期保留无谓的数据。

5. Business continuity & 备份

现在假设上述一切都运转良好……直到发生致命的 DROP TABLE orders、 云宕机或区域故障。我们需要机制以在合理时间内恢复服务,并不丢失关键数据。

RTO 和 RPO——定义你痛点的两个字母

两项灾备基础指标:

  • RTO(Recovery Time Objective)——你能容忍的不可用时长。 例如 RTO = 1 小时,意味着发生严重故障后,最晚需在 1 小时内把系统拉起。
  • RPO(Recovery Point Objective)——你能容忍的数据时间损失。 若 RPO = 10 分钟,表示恢复时最多可丢失最近 10 分钟的数据,但不能更多。

产品越关键(银行、交易系统),两个指标越接近于 0。 对教学用的 GiftGenius,RTO 可 ~ 数小时,RPO 可 ~ 15–60 分钟,但也需要实际方案支撑。

你的技术栈中可能发生的糟心事

在 Vercel + 云数据库 + 外部 API 的 ChatGPT 应用场景下,常见风险包括:

  • OpenAI API 不可用:你的 App 在 tool‑call 时返回错误。
  • Vercel(或其他)服务故障:小部件无法访问你的后端。
  • 数据库损坏或被误删(例如 DROP TABLE)。
  • 掌管基础设施的账号被破坏或被攻陷

对此需要综合采用备份复制以及应用在故障时的合理降级行为。

备份策略

现代云托管的 Postgres/其他数据库通常至少提供三种能力:

  1. 全量备份 + 增量备份。
    每日做一次完整快照,期间保存增量变更。恢复时回滚到指定快照并重放变更日志。
  2. Point‑in‑Time Recovery(PITR)。
    数据库写入事务日志(WAL),允许恢复到任意时间点(例如“恢复到 14:03:00,在我们删表之前”)。
  3. 跨区域复制。
    在另一区域/云保留被动或主动副本。主区域故障时可切换到副本,仅丢失尚未同步的数据。

以我们的规模,通常启用数据库供应商的 PITR,再配合定期的异地备份即可。

简单示例:为本地/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 无休止重试(如断路器等模式我们会在韧性模块详细讨论)。

示例:考虑外部错误的 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 中优雅地展示该错误。

恢复校验:没有恢复的备份只是一个文件

经典反模式:每天做备份,大家都很放心……直到发现根本无法恢复(格式变了、密钥丢了、空间不足)。

最低限度的计划:

  • 定期(例如每月一次)用备份拉起一个 staging 环境;
  • 跑通基本场景:登录、创建订单、App 工作流;
  • 确认恢复时间与数据损失符合你的 RTO/RPO。

不必在本讲展开一门“DevOps 宗教课”,你只需记住:备份流程是 App 架构的一部分, 而不是“云厂商会帮我们搞定”的事情。

6. 可视化:数据与事件的生命周期

为避免只停留在文字,我们画两个简图。

用户数据生命周期

flowchart TD
  A["数据创建<br/>(注册、下单)"] --> B["存储与使用<br/>(prod DB)"]
  B --> C["归档/聚合<br/>(匿名化指标)"]
  B --> D[删除请求]
  D --> E[删除/匿名化<br/>于 prod DB]
  E --> F["备份保留期到期<br/>(retention)"]
    

核心思想:数据生命周期不止于生产库——它还延续到备份中。

危险操作的审计流程

sequenceDiagram
  participant User as 用户
  participant ChatGPT as ChatGPT
  participant App as 你的 backend/MCP
  participant DB as 数据库
  participant Audit as 审计存储

  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:混用审计日志和普通日志。
当所有消息都进入同一个 logs 索引,半年后谁也分不清 “用户修改了管理员角色”和“我们又遇到 null reference”。审计日志应是结构化的业务级事件 (见审计事件结构一节),并放到访问受限的独立存储中。

错误 2:在审计/调试日志中记录 PII。
完整 e‑mail、电话、收货地址、卡号后四位等信息常常意外出现在日志中。 这既增加泄露风险,也违背隐私建议。应记录标识符与掩码值来代替。

错误 3:没有 retention 策略——“永远都存”。
在 MVP 阶段看起来“也没啥”,但一年后表会膨胀到可怕的规模,任一分析查询 都像在对自己的数据库发起 DDoS。更糟糕的是,这违反了现代数据法中的最小化原则。 必须为不同数据类型设计最小 TTL,并将清理自动化。

错误 4:“按请求删除” == DELETE FROM users.
如果你只是删了用户行,却把他的 PII 留在订单、会话与日志中,本质上等于没删。 正确做法是以事务遍历所有关联实体,该删的删,该匿名化的匿名化。 同时别忘了把删除行为本身写入审计日志。

错误 5:删除数据时忽视备份。
在生产删除了用户——很好,但他的数据可能还在旧快照里活上一年。你从快照恢复时它们又“复活”, 这会让你违背自己在隐私政策中的承诺。要么限制备份的保留期,要么在恢复后有重复应用删除操作的流程。

错误 6:“我们开了备份,就万事大吉”,却没人尝试恢复。
从未做过恢复演练的备份,只是一个昂贵文件。没有定期恢复校验,你既不知道实际的 RTO/RPO, 也不知道你的 DR 方案是否有效。最低要求——按清单定期用备份拉起一个 staging 做恢复演练。

错误 7:文档与现实不一致。
你的隐私政策写着日志保留 30 天、按请求删除数据,但代码里实际上永久保留。 ChatGPT 商店、企业客户与审计人员很容易通过“请给出 retention 表”、 “演示删除某个用户”之类的问题发现问题。最好先做,再写。

1
调查/小测验
安全第 15 级,课程 4
不可用
安全
安全与合规
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION