《互联网产品的客户端架构·内容、社交与实时互动》11.2 审核态的内容 UI:占位、隐藏与申诉

审核中的内容最怕先闪正文再被替换。本节用五态状态机描述 draft、pending、review、published、hidden 的可见性与占位,把正文挂载与状态判断绑定,给出 8 条视图投影测试,并复盘一次审核态卡片先渲染正文、随后被占位覆盖的闪烁事故。

审核态不是「加载中」

内容从草稿到发布,中间有一段用户看不见、却真实存在的状态。端上如果只把它当成一个 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,绕过重新审核。
  • 端上用状态机推导权限,而不读服务端返回的能力字段。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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