预检不是审核
用户在输入框里敲下最后一句、点发布之前,客户端能做的事情很有限,但很重要:告诉他「这段文字大概率会被拦」或者「这个长度发不出去」,让他在离开编辑页之前就改。这是早反馈,不是审核。 真正的裁决在服务端:模型、策略、人工复核、账号历史、图片与视频,都不在端上。
本节只讨论发布前的本地预检:检查哪些项、结果怎么分类、命中高风险词时如何仍然给出申诉入口。它不讨论服务端审核流程,不讨论内容安全模型,也不重复第 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:占位、隐藏与申诉 。
常见错误与边界
- 把预检通过当成发布成功,端上提前展示「已发布」。
- 用单字黑名单,正常词被大面积误伤。
- 命中术语直接禁用提交,不给任何申诉路径。
- 提示只说「内容不合规」,用户不知道改哪里。
- 归一化时删除标点和空格,把无关字符拼成敏感词。
- 端上维护大而全的词库,更新追不上变体,误伤率失控。
- 规则没有版本号,申诉时无法还原当时的判断依据。
- 服务端返回审核态后,端上仍用预检结果覆盖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。