审核态不是「加载中」
内容从草稿到发布,中间有一段用户看不见、却真实存在的状态。端上如果只把它当成一个 loading,最常见的后果是:正文先被渲染出来,过一会儿再被占位覆盖。用户看到了不该看到的版本,截图已经发出去了。
本节只讨论审核态在客户端怎么呈现:五种状态各自对谁可见、列表里是保留占位还是直接移除、正文什么时候挂载、拒绝后申诉入口放在哪里。它不讨论审核员如何做判断,也不讨论服务端如何存储状态;举报链路属于 11.3。第 9 章讨论的是「这条内容该给谁看」的受众可见性,这里讨论的是「内容处于生命周期的哪一段」,两者不是同一张表。
五个状态,而不是三个
核心内容模型里只有 published、review、hidden 三个值,足够表达服务端对外的最小契约;但端上要决定 UI,三个值不够。我们在客户端补出五个状态:
| 状态 | 含义 | 谁能看到正文 |
|---|---|---|
draft | 本地草稿,尚未提交 | 只有作者,且只在编辑器 |
pending | 已提交,等待自动或人工领取 | 审核员;作者与访客看占位 |
review | 审核员正在处理 | 审核员;作者与访客看占位 |
published | 已通过并发布 | 所有人 |
hidden | 被拒绝或发布后下架 | 审核员;作者看申诉入口 |
pending 与 review 的差别不在可见性,而在运营侧:前者还没人认领,后者已经有审核员。端上把两者都映射为核心模型的 review,但保留细分状态,用于展示「预计还要多久」和决定是否允许撤回。
可见性、列表存在感、正文挂载是三件事
很多「闪正文」的 bug,来自把这三件事写成了一个判断。
visibilityFor(state, viewer) 回答「这个状态下,这类查看者能看到什么」:full、placeholder、author-only 或 gone。listPresenceFor(state, viewer) 回答「信息流里要不要保留一个位置」:include、placeholder 或 exclude。shouldMountBody(state, viewer) 回答「正文组件现在能不能挂载」。
三者的默认关系是:只有 full 和 author-only 才挂载正文;placeholder 只挂占位,不挂正文;exclude 连占位都不出现。把这条关系写成函数,而不是散落在模板里的 v-if,测试才能直接断言「pending 状态下作者端不挂载正文」。
draft 是一个例外:作者在编辑器里需要看到正文,所以 visibilityFor 返回 author-only,shouldMountBody 返回真;但它在信息流里的 listPresence 是 exclude。同一个状态在不同 surface 上有不同投影,这不是不一致,而是 surface 的职责不同。
状态机,而不是一层层 if
审核态有明确的动作与权限:作者提交、撤回、编辑、申诉;审核员领取、通过、拒绝、恢复;系统可以在发布后下架。把这些写成嵌套 if,很快就会漏掉「作者能不能直接通过」「hidden 能不能再次申诉」这类边界。
更稳的做法是一张迁移表:每个动作声明允许的来源状态与执行者。非法迁移不改变状态,只返回原因;合法迁移写入一条历史记录。历史记录不存时间戳(那是 L2 的事),但它保留了 action、actor、from、to,足够复盘「这条内容是怎么走到 hidden 的」。
两个决定值得单独说明。第一,published 的编辑动作回到 pending,而不是原地更新。新正文没有经过审核,不能直接对访客可见;保守做法是立即切到占位,代价是作者修改已发布内容时会出现短暂的空窗。第二,hidden 的申诉回到 pending,而不是直接恢复 published;申诉只是重新排队,不是自动翻案。
占位也要分语气
访客看到的是骨架屏:「内容审核中,通过后会出现在这里」。作者看到的是通知:「正在审核,审核通过前正文不会出现在列表里」。被拒绝后,作者看到的是带申诉按钮的通知,且不再展示作者信息与正文;访客则完全看不到这条内容。
三种语气对应三种信息权利:访客不需要知道审核细节,作者需要知道下一步能做什么,审核员需要看到完整正文。把拒绝原因直接展示给访客,既没必要,也可能泄露举报人或策略信息。
一个可运行的状态机
下面的实现属于 L1:不访问网络,不读时钟,只做状态迁移与视图投影。
// file: src/moderation/review-state.ts
// tier: L1
// verify: node --test test/moderation/review-state.test.ts
import type { ContentState } from "../core/content-model.ts";
export type ReviewState = "draft" | "pending" | "review" | "published" | "hidden";
export type Viewer = "author" | "visitor" | "moderator";
export type Actor = "author" | "moderator" | "system";
export type Visibility = "author-only" | "placeholder" | "full" | "gone";
export type ListPresence = "include" | "placeholder" | "exclude";
export type ReviewAction =
| "submit"
| "withdraw"
| "claim"
| "approve"
| "reject"
| "takedown"
| "appeal"
| "edit"
| "restore";
export interface TransitionRecord {
action: ReviewAction;
actor: Actor;
from: ReviewState;
to: ReviewState;
reason: string;
}
export interface TransitionFailure {
ok: false;
state: ReviewState;
reason: string;
}
export interface TransitionSuccess {
ok: true;
state: ReviewState;
record: TransitionRecord;
}
export type TransitionResult = TransitionSuccess | TransitionFailure;
interface TransitionRule {
from: readonly ReviewState[];
to: ReviewState;
actors: readonly Actor[];
reason: string;
}
const TRANSITIONS: Record<ReviewAction, TransitionRule> = {
submit: { from: ["draft"], to: "pending", actors: ["author"], reason: "作者提交" },
withdraw: { from: ["pending"], to: "draft", actors: ["author"], reason: "作者撤回" },
claim: { from: ["pending"], to: "review", actors: ["moderator"], reason: "审核员领取" },
approve: { from: ["pending", "review"], to: "published", actors: ["moderator"], reason: "审核通过" },
reject: { from: ["pending", "review"], to: "hidden", actors: ["moderator"], reason: "审核拒绝" },
takedown: { from: ["published"], to: "hidden", actors: ["moderator", "system"], reason: "发布后下架" },
appeal: { from: ["hidden"], to: "pending", actors: ["author"], reason: "作者申诉" },
edit: { from: ["published"], to: "pending", actors: ["author"], reason: "已发布内容修改后重审" },
restore: { from: ["hidden"], to: "published", actors: ["moderator"], reason: "复核恢复" },
};
const STATES: readonly ReviewState[] = ["draft", "pending", "review", "published", "hidden"];
export function isReviewState(value: string): value is ReviewState {
return (STATES as readonly string[]).includes(value);
}
export class ReviewMachine {
#state: ReviewState;
#history: TransitionRecord[] = [];
constructor(initial: ReviewState = "draft") {
if (!isReviewState(initial)) throw new Error(`未知的审核状态:${String(initial)}`);
this.#state = initial;
}
get state(): ReviewState {
return this.#state;
}
get history(): readonly TransitionRecord[] {
return [...this.#history];
}
can(action: ReviewAction, actor: Actor): boolean {
const rule = TRANSITIONS[action];
return rule.from.includes(this.#state) && rule.actors.includes(actor);
}
tryApply(action: ReviewAction, actor: Actor): TransitionResult {
const rule = TRANSITIONS[action];
if (!rule.from.includes(this.#state)) {
return { ok: false, state: this.#state, reason: `${action} 不能从 ${this.#state} 发起` };
}
if (!rule.actors.includes(actor)) {
return { ok: false, state: this.#state, reason: `${actor} 无权执行 ${action}` };
}
const record: TransitionRecord = {
action,
actor,
from: this.#state,
to: rule.to,
reason: rule.reason,
};
this.#state = rule.to;
this.#history.push(record);
return { ok: true, state: this.#state, record };
}
apply(action: ReviewAction, actor: Actor): TransitionRecord {
const result = this.tryApply(action, actor);
if (!result.ok) throw new Error(result.reason);
return result.record;
}
}
export function nextActions(state: ReviewState, actor: Actor): ReviewAction[] {
return (Object.keys(TRANSITIONS) as ReviewAction[]).filter((action) => {
const rule = TRANSITIONS[action];
return rule.from.includes(state) && rule.actors.includes(actor);
});
}
export function visibilityFor(state: ReviewState, viewer: Viewer): Visibility {
if (viewer === "moderator") return state === "draft" ? "gone" : "full";
if (state === "published") return "full";
if (state === "draft") return viewer === "author" ? "author-only" : "gone";
if (state === "hidden") return "gone";
return "placeholder";
}
export function listPresenceFor(state: ReviewState, viewer: Viewer): ListPresence {
if (viewer === "moderator") return state === "draft" ? "exclude" : "include";
if (state === "published") return "include";
if (state === "pending" || state === "review") return "placeholder";
return "exclude";
}
// 正文只在确定可见时挂载;审核态先挂载再替换,正是「闪正文」的来源。
export function shouldMountBody(state: ReviewState, viewer: Viewer): boolean {
const visibility = visibilityFor(state, viewer);
return visibility === "full" || visibility === "author-only";
}
export interface PlaceholderSpec {
kind: "none" | "skeleton" | "notice";
title: string;
body: string;
showAuthor: boolean;
appealable: boolean;
}
const NO_PLACEHOLDER: PlaceholderSpec = {
kind: "none",
title: "",
body: "",
showAuthor: true,
appealable: false,
};
export function placeholderFor(state: ReviewState, viewer: Viewer): PlaceholderSpec {
if (visibilityFor(state, viewer) === "full") return NO_PLACEHOLDER;
if (state === "draft" && viewer === "author") {
return {
kind: "notice",
title: "草稿",
body: "还没有提交,只有你能看到",
showAuthor: true,
appealable: false,
};
}
if (state === "pending" || state === "review") {
if (viewer === "author") {
return {
kind: "notice",
title: "正在审核",
body: "审核通过前,正文不会出现在列表里",
showAuthor: true,
appealable: false,
};
}
return {
kind: "skeleton",
title: "内容审核中",
body: "通过后会出现在这里",
showAuthor: true,
appealable: false,
};
}
if (state === "hidden" && viewer === "author") {
return {
kind: "notice",
title: "内容未通过审核",
body: "你可以提交申诉,由另一位审核员复核",
showAuthor: false,
appealable: true,
};
}
return NO_PLACEHOLDER;
}
export function canAppeal(state: ReviewState, viewer: Viewer): boolean {
return state === "hidden" && viewer === "author";
}
export function toCoreState(state: ReviewState): ContentState {
if (state === "published") return "published";
if (state === "hidden") return "hidden";
return "review";
}
测试与真实输出
本机运行结果如下:
$ node --test test/moderation/review-state.test.ts
✔ 草稿只有作者能进编辑器,访客与列表都看不到 (0.532625ms)
✔ 提交后进入 pending,作者端也不提前挂载正文 (0.117208ms)
✔ 审核员领取后能读正文,访客仍停留在占位 (0.0795ms)
✔ 审核通过后所有人可见,核心状态映射覆盖五种状态 (0.422542ms)
✔ 越权操作不改变状态,历史也不留半条记录 (0.398125ms)
✔ 拒绝后作者看到申诉入口,访客彻底看不到 (0.104875ms)
✔ 申诉回到 pending,重复申诉与状态机守卫同时生效 (0.124ms)
✔ 已发布内容修改后立即回到 pending,访客不能先看到新正文 (0.081417ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 187.157208
八条用例覆盖草稿、提交、领取、通过、越权、拒绝、申诉与已发布编辑。它们没有覆盖真实的路由与组件生命周期,因为「首帧是否闪」最终要在浏览器里看;但「pending 时 shouldMountBody 为假」这条断言,能把最容易回归的那一步钉住。
失败复盘:正文闪了一下才变成占位
「回声」的发布流程曾经这样写:用户点发布后,端上先把草稿对象插进信息流,正文照常渲染;服务端返回 pending 后,再用一个 effect 把卡片替换成「审核中」占位。网络快的时候,闪烁只有一帧;弱网时,正文会在列表里停留两三秒。
事故发生在一次活动期间。用户发布带品牌名的内容,端上先显示了完整正文,随后变成占位;有人截图发到群里,说「这个内容已经发出来了」,实际上它还在审核。更糟的是,另一位用户在同一台设备上看到了缓存里的正文,刷新后才消失。
根因有三个。第一,本地回声用了草稿对象,state 还是 published,渲染层看不到「正在审核」。第二,正文挂载判断用的是「有没有 blocks」,而不是「状态是否允许」。第三,状态替换放在 effect 里,首帧已经提交,再怎么快也会闪。
修复也分三步。本地回声一开始就标记为 pending,占位与正文由同一个状态投影决定;shouldMountBody 成为渲染前的硬条件,pending 下正文组件根本不创建;服务端返回 review 或 hidden 时只更新状态,不重新挂载正文。测试里加了一条「提交后作者端也不提前挂载正文」,防止以后有人为了「体验更顺」把正文提前塞回去。
与服务端状态的边界
端上可以乐观地显示 pending,因为「已提交、等待审核」是用户动作的直接结果;但不能乐观地显示 published,因为通过与否只有服务端知道。服务端推送或轮询返回状态变化时,端上以服务端状态为准,把本地的 pending 收敛到 hidden、published 或 review,不保留本地的旧正文。
核心模型的 ContentState 仍然是服务端契约:pending 与 review 都映射为 review,hidden 映射为 hidden。端上细分状态只存在于视图层,不写回接口,也不参与权限判断。权限判断永远看服务端返回的能力字段,而不是端上状态机推导出来的结果。
小结
审核态在端上不是 loading,而是五种状态、三种视图投影和一张带权限的迁移表。可见性、列表存在感与正文挂载要分开计算;正文只在确定可见时挂载;拒绝后作者有申诉入口,访客什么也看不到;已发布内容修改后回到 pending,而不是原地覆盖。
状态决定了「给用户看什么」,但用户看到问题之后怎么把证据交给平台?下一节讨论举报链路与证据留存。
阅读导航:上一节:11.1 端上预检:能拦什么、不能拦什么 · 下一节:11.3 举报链路与证据留存 。
常见错误与边界
- 把审核态当成 loading,正文先渲染再替换。
- 用「有没有 blocks」判断能否渲染,而不是用状态。
- pending 与 review 在端上合并成一个值,运营侧无法区分是否已认领。
- 作者在信息流里看到自己的待审正文,误以为已经发布。
- 拒绝原因直接展示给访客,泄露策略或举报信息。
- 已发布内容编辑后原地覆盖,未经审核的新正文直接可见。
- 申诉直接把 hidden 改成 published,绕过重新审核。
- 端上用状态机推导权限,而不读服务端返回的能力字段。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。