举报不是把一段文字发出去
用户点「举报」时,他期望的是平台看见了他看见的那一版内容。如果端上只发一个内容 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展示成「已处理」,用户误以为平台已经处罚。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。