《互联网产品的客户端架构·内容、社交与实时互动》11.3 举报链路与证据留存

举报不是把一段文字发给服务端。本节用内存队列和假时钟实现举报负载、离线排队、去重、退避与成功回执,把举报时的内容快照冻结成不可覆盖的证据,给出快照、去重与退避共 8 条测试,并复盘一次作者编辑后证据被悄悄改写、举报被驳回的真实申诉失败事故。

举报不是把一段文字发出去

用户点「举报」时,他期望的是平台看见了他看见的那一版内容。如果端上只发一个内容 id,等队列真正发出去时,作者可能已经编辑、删除,或者内容已经被折叠;服务端再按 id 去取,取到的已经不是举报人看到的证据。

本节只讨论举报在客户端这一侧:负载里带什么、离线时怎么排队、重复点击怎么去重、发送成功后状态怎么变、证据快照为什么必须冻结。它不讨论平台如何判定举报是否成立,也不讨论处罚策略;端上预检属于 11.1,审核态展示属于 11.2。举报是用户主动发起的一条证据链,不是审核流程的另一个入口。

负载里必须有什么

字段作用常见错误
contentId定位被举报内容只发这个,作者编辑后证据丢失
reason分类,进入不同队列用自由文本代替枚举,无法统计
detail用户补充说明不裁剪,超长请求被服务端拒绝
snapshotAt举报人看到内容的时间用发送时间,离线排队后失真
snapshotHash校验证据未被替换只存正文,无法证明完整性
evidence当时的内容块、作者、时间存引用,序列化时读到最新版本
createdAt客户端入队时间,用于排序与退避用服务端时间,离线时不存在

contentId 只能回答「举报的是哪条」,不能回答「举报人当时看到了什么」。作者改一个字,正文就变了;作者删掉内容,服务端甚至可能取不到。证据必须在点击举报的那一刻固化,而不是在发送的那一刻读取。

快照是冻结的深拷贝

takeSnapshot 做三件事:把内容块投影成只含可序列化字段的结构,按固定字段顺序计算 SHA-256,然后把快照、块数组和每个块都冻结。冻结不是仪式感,它保证后续任何代码想「顺手更新一下」都会立刻失败。

哈希覆盖 contentId、authorId、capturedAt 和块内容。capturedAt 进入哈希是有意的:同一段正文在不同时间被看到,就是两份不同的证据。服务端可以用哈希校验收到的证据与客户端声明一致,也可以在存储层做去重。

快照里不放举报人的身份。举报人信息由服务端根据登录态补全,客户端不重复携带;这样即使请求体被日志采集,也不会把举报人暴露在客户端可控的字段里。

离线队列:先收下,再送达

举报按钮不应该等网络。用户点下去,端上先生成一个本地举报单,状态是 queued,UI 立刻显示「已提交,正在发送」。真正的传输由队列在合适的时机完成。

队列有三个状态:queued、sent、failed。离线时 flush 不调用传输层,只返回 skipped;联网后按 createdAt 顺序发送。传输失败时保留当前条目,按尝试次数退避,并停止本轮,避免在网络已经断开的情况下继续放大请求。达到最大尝试次数后标记 failed,不再自动重试,但保留在内存里供 UI 展示。

真实客户端会把队列落到本地存储,进程重启后继续;本实验用内存队列加假时钟,验证的是状态流转与顺序,不是持久化格式。退避时间、最大尝试次数、去重窗口都应该由构造参数注入,测试才能在不等待真实时间的情况下推进。

去重:按内容,不按点击

重复点击举报按钮是最常见的重复来源。去重键用 contentId:同一内容在窗口内只保留一条举报单,第二次点击返回 duplicate 和已有 id,而不是新建一条。

为什么不用 contentId + reason?因为用户可能在第一次举报后补充理由,或者从「垃圾信息」改成「骚扰」。如果按理由去重,同一条内容会生成多张单,运营侧看到的是重复工单,而不是更完整的信息。端上只保留一条,理由以第一次为准;需要补充时走举报详情里的追加说明。

去重窗口从发送成功开始计算,而不是从入队开始。离线很久的举报单,发送成功后仍应在一段时间内阻止重复提交。窗口过期后允许再次举报,因为内容可能被恢复或再次出现。失败条目过了窗口也允许重新举报,否则一次网络故障会让用户永久无法举报同一内容。

一个可运行的举报队列

下面的实现属于 L2:有内存队列、假时钟与异步传输,但没有真实网络端口。

// file: src/moderation/report.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/moderation/report.test.ts
import { createHash } from "node:crypto";
import type { ContentBlock, ContentItem } from "../core/content-model.ts";

export type ReportReason =
  "spam" | "harassment" | "hate" | "sexual" | "violence" | "misinfo" | "other";

export type ReportStatus = "queued" | "failed" | "sent";

export interface SnapshotBlock {
  readonly type: ContentBlock["type"];
  readonly text: string | null;
  readonly src: string | null;
  readonly alt: string | null;
}

export interface ContentSnapshot {
  readonly contentId: string;
  readonly authorId: string;
  readonly capturedAt: number;
  readonly blocks: readonly SnapshotBlock[];
  readonly hash: string;
}

export interface ReportInput {
  contentId: string;
  reason: ReportReason;
  detail?: string;
  snapshot: ContentSnapshot;
}

export interface ReportPayload {
  readonly reportId: string;
  readonly contentId: string;
  readonly reason: ReportReason;
  readonly detail: string;
  readonly snapshotAt: number;
  readonly snapshotHash: string;
  readonly evidence: ContentSnapshot;
  readonly createdAt: number;
}

export interface ReportView {
  payload: ReportPayload;
  status: ReportStatus;
  attempts: number;
  nextAttemptAt: number;
  sentAt: number | null;
  receiptId: string | null;
}

export interface EnqueueResult {
  status: "queued" | "duplicate";
  id: string;
}

export interface FlushResult {
  sent: number;
  failed: number;
  skipped: number;
  remaining: number;
  offline: boolean;
}

export interface QueueOptions {
  dedupeWindowMs?: number;
  maxAttempts?: number;
  backoffMs?: number;
}

const REASONS: readonly ReportReason[] = [
  "spam", "harassment", "hate", "sexual", "violence", "misinfo", "other",
];

function assertReason(value: string): asserts value is ReportReason {
  if (!(REASONS as readonly string[]).includes(value)) {
    throw new Error(`未知的举报理由:${value}`);
  }
}

function hashParts(parts: {
  contentId: string;
  authorId: string;
  capturedAt: number;
  blocks: readonly SnapshotBlock[];
}): string {
  const body = JSON.stringify({
    contentId: parts.contentId,
    authorId: parts.authorId,
    capturedAt: parts.capturedAt,
    blocks: parts.blocks,
  });
  return createHash("sha256").update(body).digest("hex");
}

// 深拷贝成只含可序列化字段的快照,并冻结;之后作者怎么改都不影响证据。
export function takeSnapshot(item: ContentItem, capturedAt: number): ContentSnapshot {
  if (!Number.isFinite(capturedAt) || capturedAt < 0) {
    throw new Error("capturedAt 必须是非负时间戳");
  }
  const blocks = Object.freeze(
    item.blocks.map((block) =>
      Object.freeze({
        type: block.type,
        text: block.text ?? null,
        src: block.src ?? null,
        alt: block.alt ?? null,
      }),
    ),
  );
  const base = { contentId: item.id, authorId: item.authorId, capturedAt, blocks };
  return Object.freeze({ ...base, hash: hashParts(base) });
}

export class ReportQueue {
  #entries = new Map<string, ReportView>();
  #byContent = new Map<string, string>();
  #now: () => number;
  #seq = 0;
  #online = true;
  #dedupeWindowMs: number;
  #maxAttempts: number;
  #backoffMs: number;

  constructor(now: () => number = () => Date.now(), options: QueueOptions = {}) {
    this.#now = now;
    this.#dedupeWindowMs = options.dedupeWindowMs ?? 10 * 60_000;
    this.#maxAttempts = options.maxAttempts ?? 3;
    this.#backoffMs = options.backoffMs ?? 5_000;
    if (this.#dedupeWindowMs < 0) throw new Error("dedupeWindowMs 不能为负");
    if (!Number.isInteger(this.#maxAttempts) || this.#maxAttempts < 1) {
      throw new Error("maxAttempts 必须是正整数");
    }
    if (this.#backoffMs < 0) throw new Error("backoffMs 不能为负");
  }

  get online(): boolean {
    return this.#online;
  }

  setOnline(online: boolean): void {
    this.#online = online;
  }

  enqueue(input: ReportInput): EnqueueResult {
    assertReason(input.reason);
    if (input.snapshot.contentId !== input.contentId) {
      throw new Error("快照内容与举报内容不一致");
    }
    const now = this.#now();
    this.#prune(now);
    const existingId = this.#byContent.get(input.contentId);
    if (existingId !== undefined) return { status: "duplicate", id: existingId };
    const reportId = `r-${(this.#seq += 1)}`;
    const payload: ReportPayload = Object.freeze({
      reportId,
      contentId: input.contentId,
      reason: input.reason,
      detail: (input.detail ?? "").trim().slice(0, 500),
      snapshotAt: input.snapshot.capturedAt,
      snapshotHash: input.snapshot.hash,
      evidence: input.snapshot,
      createdAt: now,
    });
    this.#entries.set(reportId, {
      payload,
      status: "queued",
      attempts: 0,
      nextAttemptAt: now,
      sentAt: null,
      receiptId: null,
    });
    this.#byContent.set(input.contentId, reportId);
    return { status: "queued", id: reportId };
  }

  report(id: string): ReportView | null {
    const view = this.#entries.get(id);
    return view ? { ...view } : null;
  }

  async flush(
    transport: (payload: ReportPayload) => Promise<{ receiptId: string }>,
  ): Promise<FlushResult> {
    const result: FlushResult = {
      sent: 0,
      failed: 0,
      skipped: 0,
      remaining: 0,
      offline: !this.#online,
    };
    if (!this.#online) {
      result.skipped = this.#queuedEntries().length;
      result.remaining = result.skipped;
      return result;
    }
    this.#prune(this.#now());
    for (const entry of this.#queuedEntries()) {
      if (entry.nextAttemptAt > this.#now()) {
        result.skipped += 1;
        continue;
      }
      try {
        const receipt = await transport(entry.payload);
        entry.status = "sent";
        entry.sentAt = this.#now();
        entry.receiptId = receipt.receiptId;
        result.sent += 1;
      } catch {
        entry.attempts += 1;
        entry.nextAttemptAt = this.#now() + this.#backoffMs * entry.attempts;
        result.failed += 1;
        if (entry.attempts >= this.#maxAttempts) entry.status = "failed";
        // 网络失败时停止本轮,保留剩余条目,避免失败期间继续放大请求。
        break;
      }
    }
    result.remaining = this.#queuedEntries().length;
    return result;
  }

  #queuedEntries(): ReportView[] {
    return [...this.#entries.values()]
      .filter((entry) => entry.status === "queued")
      .sort(
        (a, b) =>
          a.payload.createdAt - b.payload.createdAt ||
          a.payload.reportId.localeCompare(b.payload.reportId),
      );
  }

  // 已发送条目过了去重窗口、失败条目过了窗口,都从内存移除,允许重新举报。
  #prune(now: number): void {
    for (const [id, entry] of this.#entries) {
      const sentExpired =
        entry.status === "sent" && entry.sentAt !== null && now - entry.sentAt >= this.#dedupeWindowMs;
      const failedExpired =
        entry.status === "failed" && now - entry.payload.createdAt >= this.#dedupeWindowMs;
      if (!sentExpired && !failedExpired) continue;
      this.#entries.delete(id);
      if (this.#byContent.get(entry.payload.contentId) === id) {
        this.#byContent.delete(entry.payload.contentId);
      }
    }
  }
}

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/moderation/report.test.ts
✔ 快照是冻结的深拷贝,作者改正文不会覆盖证据 (1.025292ms)
✔ 离线时只入队不调用传输层,恢复联网后仍能补发 (0.79575ms)
✔ 联网后按入队顺序发送并记录回执与发送时间 (0.243959ms)
✔ 同一内容在去重窗口内只保留一条,窗口外允许再次举报 (0.170583ms)
✔ 传输失败后保留队列并退避,本轮不再尝试后面的条目 (0.205708ms)
✔ 达到最大尝试次数后标记失败,窗口过后允许重新举报 (0.215041ms)
✔ 快照与举报内容不一致、理由非法时拒绝入队 (0.139708ms)
✔ 举报载荷带上快照时间、哈希与证据,长描述被裁剪 (0.150958ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 181.222209

八条用例覆盖快照冻结、离线入队、顺序发送、去重窗口、失败退避、最大尝试、负载校验与字段裁剪。它们没有覆盖真实的服务端回执与本地持久化,因为那两部分依赖接口契约和存储实现;但「作者改正文后快照不变」这条断言,已经能挡住最危险的一类证据污染。

失败复盘:作者改了正文,证据也跟着变了

「回声」的举报功能最初只发 contentId 和理由,端上把信息流里的条目对象直接塞进待发送队列。在线时问题不大:点击后立刻发送,服务端取到的通常还是同一版内容。离线时问题就暴露了。

一位用户举报了一条违规内容,当时地铁里没有信号,举报单进入队列。二十分钟后恢复网络,队列开始发送;这期间作者编辑了正文,把违规部分删掉,换成了正常内容。队列序列化时读取的是条目对象的当前值,于是服务端收到的证据是编辑后的版本。审核员按证据判断「不违规」,举报被驳回;举报人再次申诉时,端上已经拿不出当时的正文。

根因不是队列,而是队列持有的是活引用,不是快照。修复方式很直接:点击举报时立刻 takeSnapshot,把冻结后的证据放进负载;队列只持有这份快照,不再持有内容条目。序列化发生在发送时,但序列化的对象已经是不可变的。测试里加了一条:快照之后修改原条目的正文和块数组,断言快照内容与哈希都不变。

第二个问题是时间。早期用发送时间作为 snapshotAt,离线越久,证据时间越偏离用户实际看到内容的时间。修复后 capturedAt 在点击时记录,并使用服务端校正过的时间偏移;服务端另外记录 receivedAt,两者分开,不能用客户端时间计算审核 SLA。

成功之后:回执、状态与用户可见

发送成功后,队列保存服务端的 receiptId 与 sentAt,状态变为 sent。UI 展示「已提交」而不是「已处理」,因为举报是否成立还要经过审核;把 sent 说成「已处理」会让用户产生错误预期。

重复举报返回已有 id 时,UI 可以提示「你已经举报过这条内容」,并允许查看进度。达到最大尝试次数后,状态是 failed,UI 应该提供手动重试或反馈入口,而不是静默消失。失败条目仍然保留证据,直到用户放弃或窗口过期。

本地证据的保留时间应该由产品策略决定:至少保留到收到回执,必要时保留到申诉结束。不要把整段正文长期留在客户端日志里;哈希与 id 足够定位,完整证据应该只在举报单的生命周期内存在。

小结

举报链路的重点是证据,而不是请求。点击时冻结快照,负载带内容 id、理由、快照时间、哈希与证据;离线先入队,联网按顺序发送;失败退避并保留,成功后记录回执;去重按内容而不是按点击,窗口从发送成功开始计算。作者之后怎么改,都不应该改变举报人当时看到的证据。

到这里,第 11 章的三个边界就齐了:预检负责早反馈,审核态负责可见性,举报负责证据。下一节离开内容安全,进入搜索与发现,从输入联想的防抖与竞态开始。

阅读导航:上一节:11.2 审核态的内容 UI:占位、隐藏与申诉 · 下一节:12.1 输入联想:防抖、竞态与缓存 。

常见错误与边界

  • 举报只发内容 id,作者编辑或删除后证据丢失。
  • 队列持有内容条目的活引用,发送时才序列化,读到最新版本。
  • 快照没有冻结,后续代码「顺手更新」覆盖了证据。
  • 用发送时间作为快照时间,离线越久偏差越大。
  • 重复点击按次创建工单,运营侧收到大量重复举报。
  • 去重键带上理由,同一内容被拆成多张单。
  • 达到最大尝试次数后静默丢弃,用户以为举报成功。
  • 把 sent 展示成「已处理」,用户误以为平台已经处罚。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查