《互联网产品的客户端架构·内容、社交与实时互动》11.1 端上预检:能拦什么、不能拦什么

端上预检只能做早反馈,不能替代审核。本节用纯函数实现长度、格式、控制字符与少量高风险词的本地提示,区分硬阻断、可申诉复核与轻提示三类结果,给出带允许短语、零宽归一化和规则版本的 8 条测试,并复盘一次单字黑名单误拦「黄金周」正常游记的事故。

预检不是审核

用户在输入框里敲下最后一句、点发布之前,客户端能做的事情很有限,但很重要:告诉他「这段文字大概率会被拦」或者「这个长度发不出去」,让他在离开编辑页之前就改。这是早反馈,不是审核。 真正的裁决在服务端:模型、策略、人工复核、账号历史、图片与视频,都不在端上。

本节只讨论发布前的本地预检:检查哪些项、结果怎么分类、命中高风险词时如何仍然给出申诉入口。它不讨论服务端审核流程,不讨论内容安全模型,也不重复第 3 章的 HTML 清洗(那是渲染安全,不是发布准入),更不涉及 11.3 的举报链路。端上预检的目标只有一个:减少「写完才被拒」的挫败感,而不是替代任何一道审核。

预检通过不代表可以发布

这句话要写进产品文案,也要写进接口命名。预检返回的不是 published,而是 canSubmit:能不能把这份草稿送到服务端。服务端仍可能因为图片、账号状态、重复内容或更完整的策略拒绝它。

把两者混为一谈,会带来两种相反的故障。第一种是过度自信:端上说「一切正常」,用户以为已经发布,实际上服务端还在审。第二种是过度拦截:端上黑名单命中一个词,直接禁止提交,用户没有申诉入口,正常内容被卡死。后一种更常见,也更难解释。

哪些检查适合放在端上

检查项端上能否确定结果类型
空内容能必须修改
长度上限能,按字素计数必须修改
控制字符能必须修改
链接数量能,输入框已经结构化必须修改
少量高风险短语只能给出可疑信号可提交复核
语义、讽刺、图片内容不能不做
版权、隐私、事实性不能不做

这张表决定了端上预检的边界:确定性的结构问题可以硬阻断;需要语义判断的问题只能提示,不能下结论。 端上不适合维护大而全的词库,因为词库越大,误伤越多,解释成本越高,更新还追不上变体。

三类结果:fix、review、hint

fix 表示这份草稿现在不能提交,用户必须改。空内容、超长、控制字符、链接超限都属于这一类。

review 表示端上发现了需要人工确认的信号,但允许提交,并且必须同时给出申诉路径。高风险短语属于这一类。注意它不是「拒绝」,而是「转人工」;用户点提交后,内容进入审核队列,而不是被静默丢弃。

hint 表示建议,不影响提交。比如正文只有两个字,端上可以提示「太短的内容更容易被误解」,但用户坚持发布时不应该被拦住。

对应到结果对象,passed 表示没有 fix 或 review 级问题,canSubmit 表示没有 fix 级问题。两者不是同一个值:一条只有 hint 的草稿 passed 为真;一条命中术语的草稿 passed 为假,但 canSubmit 仍为真。这个区分让 UI 能同时表达「看起来没问题」和「可以提交,但会进审核」。

黑名单的三个工程约束

第一,术语至少两个字符。单字黑名单是误伤的主要来源:「黄」会命中「黄金周」「黄桃」「黄果树」,「杀」会命中「杀菌」「杀手游戏」。构造规则时直接拒绝单字术语,比上线后再加白名单便宜得多。

第二,允许短语只遮住自身区间。像「枪支管理法」这样的讨论语境,应该把整个短语遮掉再匹配,而不是把术语从词库里删掉;否则「出售枪支」也会被放过。遮罩用等长占位符,不改变其他位置的下标。

第三,归一化只做 NFKC、大小写和零宽字符清理,不删除空格与标点。删除标点会把「黄。金」拼成「黄金」,制造新的误伤;而零宽字符清理是必要的,否则 加微\u200b信 可以轻松绕过。

还有一条不写在代码里但同样重要:端上规则必须带版本号。用户申诉时,运营需要知道当时端上用的是哪一版规则;规则更新也应该先影子运行,只记录命中、不改变提交行为,确认误伤率后再启用。

一个可运行的预检实现

下面的 precheck 属于 L1:纯函数,不访问网络,不读时钟。规则由 createRules 构造并在构造时校验,避免把非法配置带到线上。

// file: src/moderation/precheck.ts
// tier: L1
// verify: node --test test/moderation/precheck.test.ts

export type PrecheckAction = "fix" | "review" | "hint";

export type PrecheckCode =
  | "empty"
  | "too-short"
  | "too-long"
  | "control-char"
  | "too-many-links"
  | "blocked-term";

export interface PrecheckIssue {
  code: PrecheckCode;
  action: PrecheckAction;
  message: string;
  detail?: string;
}

export interface PrecheckRules {
  minChars: number;
  maxChars: number;
  maxLinks: number;
  blockedTerms: readonly string[];
  allowPhrases: readonly string[];
  rulesVersion: string;
  appealPath: string;
}

export interface DraftInput {
  text: string;
  mediaCount?: number;
  links?: readonly string[];
}

export interface PrecheckResult {
  passed: boolean;
  canSubmit: boolean;
  requiresReview: boolean;
  appealPath: string | null;
  issues: PrecheckIssue[];
  rulesVersion: string;
}

const ZERO_WIDTH = /[\u200b-\u200f\u202a-\u202e\u2060\ufeff]/g;
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/u;
const MAX_TERM_ISSUES = 3;
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });

// 按字素计数,避免一个家庭 emoji 被当成四五个字。
export function countGraphemes(text: string): number {
  let count = 0;
  for (const _ of segmenter.segment(text)) count += 1;
  return count;
}

// 只做 NFKC 与零宽字符清理;不删除空格和标点,避免把「黄。金」拼成「黄金」。
export function normalizeForMatch(text: string): string {
  return text.normalize("NFKC").toLowerCase().replace(ZERO_WIDTH, "");
}

export function createRules(input: {
  minChars: number;
  maxChars: number;
  maxLinks: number;
  blockedTerms: readonly string[];
  allowPhrases?: readonly string[];
  rulesVersion: string;
  appealPath: string;
}): PrecheckRules {
  if (!Number.isInteger(input.minChars) || input.minChars < 0) {
    throw new Error("minChars 必须是非负整数");
  }
  if (!Number.isInteger(input.maxChars) || input.maxChars <= input.minChars) {
    throw new Error("maxChars 必须是大于 minChars 的整数");
  }
  if (!Number.isInteger(input.maxLinks) || input.maxLinks < 0) {
    throw new Error("maxLinks 必须是非负整数");
  }
  const blockedTerms = input.blockedTerms.map(normalizeForMatch);
  for (const term of blockedTerms) {
    if (countGraphemes(term) < 2) {
      throw new Error(`本地术语必须是至少两个字符的短语:${term}`);
    }
    if (CONTROL.test(term)) throw new Error(`本地术语不能包含控制字符:${term}`);
  }
  const allowPhrases = (input.allowPhrases ?? []).map(normalizeForMatch).filter(Boolean);
  if (!input.rulesVersion.trim()) throw new Error("rulesVersion 不能为空");
  if (!input.appealPath.startsWith("/")) throw new Error("appealPath 必须是站内路径");
  return Object.freeze({
    minChars: input.minChars,
    maxChars: input.maxChars,
    maxLinks: input.maxLinks,
    blockedTerms: Object.freeze(blockedTerms),
    allowPhrases: Object.freeze(allowPhrases),
    rulesVersion: input.rulesVersion,
    appealPath: input.appealPath,
  });
}

// 允许短语先被遮掉,再在剩余文本里找术语;只遮短语本身,不影响其他位置。
function maskAllowed(text: string, allowPhrases: readonly string[]): string {
  let masked = text;
  for (const phrase of allowPhrases) {
    if (!phrase) continue;
    masked = masked.split(phrase).join("□".repeat(countGraphemes(phrase)));
  }
  return masked;
}

export function precheck(draft: DraftInput, rules: PrecheckRules): PrecheckResult {
  const text = draft.text ?? "";
  const mediaCount = draft.mediaCount ?? 0;
  const links = draft.links ?? [];
  const trimmed = text.trim();
  const issues: PrecheckIssue[] = [];

  if (trimmed === "" && mediaCount === 0 && links.length === 0) {
    issues.push({
      code: "empty",
      action: "fix",
      message: "内容为空,写点什么再提交",
    });
  }

  const chars = countGraphemes(trimmed);
  if (trimmed !== "" && chars < rules.minChars) {
    issues.push({
      code: "too-short",
      action: "hint",
      message: `只有 ${chars} 个字,太短的内容更容易被误解`,
      detail: `${chars}/${rules.minChars}`,
    });
  }
  if (chars > rules.maxChars) {
    issues.push({
      code: "too-long",
      action: "fix",
      message: `正文最多 ${rules.maxChars} 个字,当前 ${chars} 个`,
      detail: `${chars}/${rules.maxChars}`,
    });
  }
  if (CONTROL.test(text)) {
    issues.push({
      code: "control-char",
      action: "fix",
      message: "正文包含不可见的控制字符,请清除后重试",
    });
  }
  if (links.length > rules.maxLinks) {
    issues.push({
      code: "too-many-links",
      action: "fix",
      message: `最多放 ${rules.maxLinks} 个链接,当前 ${links.length} 个`,
      detail: `${links.length}/${rules.maxLinks}`,
    });
  }

  const normalized = normalizeForMatch(trimmed);
  const masked = maskAllowed(normalized, rules.allowPhrases);
  const hits: string[] = [];
  for (const term of rules.blockedTerms) {
    if (masked.includes(term) && !hits.includes(term)) hits.push(term);
  }
  for (const term of hits.slice(0, MAX_TERM_ISSUES)) {
    issues.push({
      code: "blocked-term",
      action: "review",
      message: "这段文字包含需要人工确认的词,可以直接提交复核",
      detail: term,
    });
  }

  const blocking = issues.some((issue) => issue.action !== "hint");
  const requiresReview = issues.some((issue) => issue.action === "review");
  return {
    passed: !blocking,
    canSubmit: !issues.some((issue) => issue.action === "fix"),
    requiresReview,
    appealPath: requiresReview ? rules.appealPath : null,
    issues,
    rulesVersion: rules.rulesVersion,
  };
}

测试与真实输出

本机运行结果如下:

$ node --test test/moderation/precheck.test.ts
✔ 空内容不可提交,也没有申诉入口 (1.135708ms)
✔ 长度按字素计数,家庭 emoji 只算一个字 (0.140583ms)
✔ 单字术语与非法规则在构造时就被拒绝 (0.282375ms)
✔ 命中术语只要求复核,不阻止提交并给出申诉路径 (0.13775ms)
✔ 允许短语只遮住自身区间,不放过区间外的命中 (0.124417ms)
✔ 零宽字符不能绕过匹配,NFKC 统一全角与大小写 (0.083ms)
✔ 控制字符与过多链接都是硬阻断,不提供申诉 (0.099084ms)
✔ 过短只是提示,仍然算通过并可提交 (0.086333ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 198.315458

八条用例覆盖了空内容、字素计数、非法规则、术语命中、允许短语、零宽绕过、硬阻断与轻提示。它们没有覆盖真实的输入框与提交流程,因为「用户是否理解提示」只能在界面里验证。

失败复盘:一个「黄」字拦住了黄金周

「回声」早期的本地预检有一份从旧项目抄来的词库,里面有一个单字「黄」。上线第一周,运营收到一类投诉:用户写国庆出游,正文里有「黄金周」,点发布没有任何反应,也没有提示具体原因。用户反复删改,最后放弃。

排查时发现三个问题叠在一起。第一,单字「黄」命中了「黄金周」「黄果树」「黄桃」等正常词。第二,命中后的处理是硬阻断,提交按钮直接禁用,没有申诉入口。第三,提示文案只写「内容不合规」,用户不知道哪个词出了问题。

修复分四步。词库改为只接受至少两个字符的短语;「黄金周」这类固定搭配进入允许短语;术语命中从「阻断」降级为「可提交复核」,同时返回申诉路径;提示里显示命中的词本身,而不是一句笼统的「不合规」。上线前先用影子模式跑了一周,只记录命中、不改变提交,确认误伤率后才启用。

这个事故的根因不是词库不够准,而是把「本地提示」当成了「最终裁决」。端上预检没有能力做最终裁决,就不应该拥有最终裁决的权力。

与提交、审核链路的接口

编辑器拿到 PrecheckResult 后,按 fix、review、hint 分别处理:有 fix 时禁用提交并定位到对应字段;有 review 时保留提交按钮,在按钮旁说明「提交后需要人工确认」,并把 appealPath 存进草稿元数据;只有 hint 时正常提交,提示可以折叠。

提交成功后,服务端返回的审核状态才是权威。端上预检的 passed 不能覆盖服务端返回的 pending 或 hidden;反过来,服务端也不应该因为端上预检通过就跳过审核。两边是串联关系,不是互相替代。

小结

端上预检的职责是早反馈:结构问题硬阻断,高风险信号转复核,轻提示不拦人。术语至少两个字符,允许短语只遮自身区间,归一化不删标点,规则带版本号。最重要的是,预检通过只代表「可以提交」,不代表「可以发布」。

预检把内容送进审核队列之后,用户接下来看到什么?下一节讨论审核态在列表与详情里的占位、隐藏与申诉入口。

阅读导航:上一节:10.3 推送降级:没有推送权限时怎么补 · 下一节:11.2 审核态的内容 UI:占位、隐藏与申诉 。

常见错误与边界

  • 把预检通过当成发布成功,端上提前展示「已发布」。
  • 用单字黑名单,正常词被大面积误伤。
  • 命中术语直接禁用提交,不给任何申诉路径。
  • 提示只说「内容不合规」,用户不知道改哪里。
  • 归一化时删除标点和空格,把无关字符拼成敏感词。
  • 端上维护大而全的词库,更新追不上变体,误伤率失控。
  • 规则没有版本号,申诉时无法还原当时的判断依据。
  • 服务端返回审核态后,端上仍用预检结果覆盖。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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