《互联网产品的服务端架构·内容、社交与实时互动》1.2 内容状态机:草稿、审核、发布、删除

从作者在审核中又改了一版内容出发,说明草稿、审核、发布、删除四个状态为什么必须把发起人、版本和终态写进状态机,拆解审核旧版本、发布新版本的事故链条,并用可测试的 Go 函数固定合法路径与驳回分支,同时说明审核中编辑、软删与硬删、终态恢复的取舍。

审核通过之后,作者又改了一版

「回声」的审核后台有一个“通过”按钮。作者提交内容后状态变成 reviewing,审核员看完点通过,状态变成 published。第一版实现只有这三个状态,代码也简单:提交时写 reviewing,通过时写 published。上线两个月后,一条内容在审核中被作者改了三处,审核员看到的仍是旧版本,点通过之后,线上出现的是新版本。

这不是一个“加个锁”就能解决的问题。它暴露的是状态机缺少两个维度:谁有权限发起这次变更,以及变更针对的是哪个版本。状态只描述内容走到哪一步,不描述“谁在什么时候、基于哪一版”做的决定。把这两件事补进去,状态机才能同时承担流程控制与审计。

本节把四个状态、四条合法路径和两条审批分支写成可测试的 Go 代码,并讨论三个容易含糊的边界:审核中再次编辑、删除是软删还是硬删、以及终态为什么必须真的是终态。

把“谁能改”写进状态机

状态机的输入不只是 from 和 to,还应该包含 actor。没有 actor 的变更在审计时只剩结果,没有责任人;更现实的问题是,作者和审核员能发起的变更本来就不一样。作者可以提交审核、可以删除自己的内容;审核员可以通过或驳回;运营可以下线已发布内容。把这些权限写进转移表,接口层就不需要再复制一遍 if-else。

当前状态目标状态允许的发起人含义版本要求
draftreviewing作者提交审核提交时的版本
reviewingpublished审核员审核通过审核看到的版本
reviewingdraft审核员驳回修改审核看到的版本
publisheddeleted作者或运营下线删除当前线上版本
deleted任意无终态不允许

表里最后两列比状态本身更重要。“审核通过”不是无条件通过,而是“通过我在审核队列里看到的那一版”;“删除”也不是把状态改掉就结束,还要决定数据保留多久、能否恢复、对外链接返回什么。状态机只负责说“这次变更是否合法”,版本和保留策略由它周围的字段承担。

不允许的路径同样要明确写出来。draft 不能直接到 published,否则审核形同虚设;reviewing 不能直接到 deleted,否则审核中的内容会绕过驳回记录;published 不能回到 draft,否则线上内容和草稿会共享同一个标识;deleted 不能再转到任何状态,否则“删除”只是一个可逆的标签,审计和合规都无从谈起。

审核中再次编辑怎么办

最直接的办法是禁止编辑:内容进入 reviewing 后,作者只能撤回,不能修改。撤回会把状态改回 draft,作者改完再重新提交,审核队列里重新排一次。这个方案牺牲一点体验,但语义最清楚:审核员看到的版本就是最终发布的版本。

另一个办法是允许编辑,但每次编辑都生成新版本,并让审核任务绑定版本号。审核员通过的是版本 3,发布时如果当前版本已经是版本 4,系统应该拒绝这次通过,提示“内容已更新,请重新审核”。这需要内容表带上版本号,并在状态转移时做比较交换,而不是简单地覆盖状态字段。

无论选哪种,都不能出现“审核旧版本、发布新版本”。这不是状态机的路径问题,而是状态转移缺少前置条件。Allowed 只能判断 from→to 是否合法,版本比较要由调用方在同一事务里完成;两者缺一不可。

删除是软删还是硬删

软删把状态改成 deleted,保留行和文件,对外返回 404 或“已删除”;硬删直接移除数据,释放存储。内容产品通常先软删,保留一段可恢复窗口,再异步清理文件和索引。这样既能处理误删,也能满足“用户删除后一段时间内可申诉”的产品需求。

软删的代价是每个读路径都要过滤 deleted,漏掉一处就会让已删除内容重新出现;硬删的代价是不可恢复,且要处理缓存、CDN、搜索引擎快照和备份。状态机把 deleted 定义为终态,是在提醒调用方:不要把“恢复”做成一次普通的状态转移,恢复应该是独立的、带审计的管理操作。

一次新旧版本错位事故

案例假设:某天一位作者提交了一篇长文,进入 reviewing。审核员打开队列,看到的是版本 3。与此同时,作者发现一个事实错误,直接在编辑器里保存,系统把内容更新为版本 4,但状态仍然是 reviewing。审核员看完版本 3,点了通过。

发布服务收到“通过”请求,把状态改成 published,并读取当前内容渲染页面。它读到的是版本 4。于是线上出现了一篇从未被审核过的文章,而审核记录里写的是“版本 3 已通过”。如果版本 4 里恰好有违规内容,这次事故会同时触发内容安全和审计两个问题。

事故链条里有两个独立缺陷。第一,编辑接口没有检查状态,允许 reviewing 状态下直接覆盖内容。第二,通过接口没有比较版本,只改了状态。只修第一个,作者仍然可以通过“撤回再提交”制造竞态;只修第二个,审核员会被频繁提示“内容已更新”,体验很差。正确做法是两者都做:编辑时生成新版本,通过时比较版本并拒绝过期审核。

还有一个容易被忽略的后果:缓存。发布服务可能已经把版本 3 的渲染结果写进 CDN,状态改成 published 之后,用户看到的是缓存里的旧页面;等缓存过期,又变成版本 4。团队如果只盯数据库,会以为发布是原子的,实际上对外可见的内容在几分钟内可能来回切换。

用 Allowed 与 Next 固定路径

把状态机写成两个函数:Allowed 判断一次具体转移是否合法,Next 根据当前状态和审批结果给出下一步。前者适合放在接口层做准入,后者适合放在服务层驱动流程。两者共用同一组状态常量,避免接口层和服务层对“reviewing 之后是什么”产生不同理解。

下面这段代码来自本书配套的 webapp-lab。Allowed 先检查 actor 和状态是否已知,再拒绝终态和“跳过审核”,最后查转移表;Next 把审批分支收敛到一个布尔值,避免调用方自己拼状态。

// file: internal/content/state.go
// tier: L1
// verify: go test ./internal/content/

package content

import "fmt"

// State 是内容在审核流水线上的位置。
type State string

const (
	Draft     State = "draft"
	Reviewing State = "reviewing"
	Published State = "published"
	Deleted   State = "deleted"
)

// Transition 是一次状态变更请求。Actor 是发起人,
// 状态机必须记录“谁能改”,否则审计时只剩结果没有责任人。
type Transition struct {
	From  State
	To    State
	Actor string
}

// Allowed 判断一次状态变更是否合法。
// 合法路径只有:draft→reviewing、reviewing→published、
// reviewing→draft、published→deleted。Deleted 是终态。
func Allowed(t Transition) error {
	if t.Actor == "" {
		return fmt.Errorf("content: empty actor")
	}
	if !known(t.From) {
		return fmt.Errorf("content: unknown state %q", t.From)
	}
	if !known(t.To) {
		return fmt.Errorf("content: unknown state %q", t.To)
	}
	if t.From == Deleted {
		return fmt.Errorf("content: %s is terminal", Deleted)
	}
	if t.From == Draft && t.To == Published {
		return fmt.Errorf("content: draft cannot skip review")
	}
	for _, edge := range edges {
		if edge.from == t.From && edge.to == t.To {
			return nil
		}
	}
	return fmt.Errorf("content: transition %s -> %s is not allowed", t.From, t.To)
}

var edges = []struct{ from, to State }{
	{Draft, Reviewing},
	{Reviewing, Published},
	{Reviewing, Draft},
	{Published, Deleted},
}

func known(s State) bool {
	switch s {
	case Draft, Reviewing, Published, Deleted:
		return true
	}
	return false
}

// Next 返回审批动作之后的状态。approved 只在 reviewing 时有意义:
// 通过则发布,驳回则回到草稿。draft 提交后进入审核,published 只能删除。
func Next(from State, approved bool) (State, error) {
	switch from {
	case Draft:
		return Reviewing, nil
	case Reviewing:
		if approved {
			return Published, nil
		}
		return Draft, nil
	case Published:
		return Deleted, nil
	case Deleted:
		return "", fmt.Errorf("content: %s is terminal", Deleted)
	default:
		return "", fmt.Errorf("content: unknown state %q", from)
	}
}

代码里有几个刻意的选择。

  • Actor 为空直接报错。系统内部的自动任务也应该有明确身份,例如 system-scheduler,而不是空字符串。
  • 未知状态先于路径判断报错。否则一个拼错的状态会落入“路径不允许”,日志里看不出是拼写问题。
  • Deleted 在任何转移之前就被拒绝,保证终态不会被转移表绕过。
  • draft → published 单独给出“不能跳过审核”的错误,而不是笼统的“不允许”。错误信息应该告诉调用方下一步该做什么。
  • 转移表是数据而不是嵌套 switch。新增一条合法路径只需要加一行,评审时也更容易看出全貌。

Next 里 published 无条件返回 deleted,这是简化:真实系统可能还有“下线但保留”“仅作者可见”等状态。把 Next 限制在单步动作上,是为了让调用方明确“现在执行的是删除”,而不是让一个函数根据多个布尔值猜意图。

测试:非法路径比合法路径更重要

状态机的测试不能只测“能走通”。合法路径通常只有几条,非法路径才是线上事故的来源。下面的测试分成三组:四条合法转移、八条非法转移、以及 Next 的审批分支与终态。

// file: internal/content/state_test.go
// tier: L1
// verify: go test ./internal/content/

package content

import "testing"

func TestAllowedAcceptsLegalPaths(t *testing.T) {
	cases := []Transition{
		{From: Draft, To: Reviewing, Actor: "author-1"},
		{From: Reviewing, To: Published, Actor: "reviewer-1"},
		{From: Reviewing, To: Draft, Actor: "reviewer-1"},
		{From: Published, To: Deleted, Actor: "author-1"},
	}
	for _, c := range cases {
		if err := Allowed(c); err != nil {
			t.Fatalf("Allowed(%+v) = %v, want nil", c, err)
		}
	}
}

func TestAllowedRejectsIllegalPaths(t *testing.T) {
	cases := []struct {
		name string
		tr   Transition
	}{
		{"skip-review", Transition{From: Draft, To: Published, Actor: "author-1"}},
		{"draft-to-deleted", Transition{From: Draft, To: Deleted, Actor: "author-1"}},
		{"reviewing-to-deleted", Transition{From: Reviewing, To: Deleted, Actor: "reviewer-1"}},
		{"published-to-draft", Transition{From: Published, To: Draft, Actor: "author-1"}},
		{"same-state", Transition{From: Draft, To: Draft, Actor: "author-1"}},
		{"empty-actor", Transition{From: Draft, To: Reviewing}},
		{"unknown-from", Transition{From: State("archived"), To: Published, Actor: "reviewer-1"}},
		{"unknown-to", Transition{From: Reviewing, To: State("archived"), Actor: "reviewer-1"}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if err := Allowed(c.tr); err == nil {
				t.Fatalf("Allowed(%+v) = nil, want error", c.tr)
			}
		})
	}
}

func TestNextFollowsReviewDecision(t *testing.T) {
	cases := []struct {
		name     string
		from     State
		approved bool
		want     State
	}{
		{"draft-submits", Draft, false, Reviewing},
		{"review-approved", Reviewing, true, Published},
		{"review-rejected", Reviewing, false, Draft},
		{"published-deletes", Published, false, Deleted},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Next(c.from, c.approved)
			if err != nil {
				t.Fatal(err)
			}
			if got != c.want {
				t.Fatalf("Next(%s, %v) = %s, want %s", c.from, c.approved, got, c.want)
			}
		})
	}
}

func TestNextRejectsTerminalAndUnknown(t *testing.T) {
	if _, err := Next(Deleted, true); err == nil {
		t.Fatal("want error for deleted state")
	}
	if _, err := Next(State("archived"), true); err == nil {
		t.Fatal("want error for unknown state")
	}
}

skip-review 和 draft-to-deleted 是最值得保留的两个用例。前者防止有人为了“加快发布”把 draft 直接接到 published;后者防止“作者删除草稿”被误当成 published→deleted 的合法路径。如果产品确实需要作者删除草稿,应该显式加一条转移并补测试,而不是让路径在实现里悄悄存在。

empty-actor 覆盖审计要求:没有发起人的转移一律拒绝。unknown-from 和 unknown-to 覆盖状态拼写或旧客户端传入未知值的情况。same-state 看似无害,但重复提交同一状态会让审计日志出现大量噪声,也会让幂等判断变得困难,所以同样拒绝。

测试没有覆盖的部分也要说清楚:它不验证版本比较、不验证权限系统里的角色映射、不验证删除后的数据保留策略。这些属于状态机周围的约束,需要接口层和存储层一起保证。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/content/ -run 'TestAllowed|TestNext' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAllowedAcceptsLegalPaths
--- PASS: TestAllowedAcceptsLegalPaths (0.00s)
=== RUN   TestAllowedRejectsIllegalPaths
=== RUN   TestAllowedRejectsIllegalPaths/skip-review
=== RUN   TestAllowedRejectsIllegalPaths/draft-to-deleted
=== RUN   TestAllowedRejectsIllegalPaths/reviewing-to-deleted
=== RUN   TestAllowedRejectsIllegalPaths/published-to-draft
=== RUN   TestAllowedRejectsIllegalPaths/same-state
=== RUN   TestAllowedRejectsIllegalPaths/empty-actor
=== RUN   TestAllowedRejectsIllegalPaths/unknown-from
=== RUN   TestAllowedRejectsIllegalPaths/unknown-to
--- PASS: TestAllowedRejectsIllegalPaths (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/skip-review (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/draft-to-deleted (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/reviewing-to-deleted (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/published-to-draft (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/same-state (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/empty-actor (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/unknown-from (0.00s)
    --- PASS: TestAllowedRejectsIllegalPaths/unknown-to (0.00s)
=== RUN   TestNextFollowsReviewDecision
=== RUN   TestNextFollowsReviewDecision/draft-submits
=== RUN   TestNextFollowsReviewDecision/review-approved
=== RUN   TestNextFollowsReviewDecision/review-rejected
=== RUN   TestNextFollowsReviewDecision/published-deletes
--- PASS: TestNextFollowsReviewDecision (0.00s)
    --- PASS: TestNextFollowsReviewDecision/draft-submits (0.00s)
    --- PASS: TestNextFollowsReviewDecision/review-approved (0.00s)
    --- PASS: TestNextFollowsReviewDecision/review-rejected (0.00s)
    --- PASS: TestNextFollowsReviewDecision/published-deletes (0.00s)
=== RUN   TestNextRejectsTerminalAndUnknown
--- PASS: TestNextRejectsTerminalAndUnknown (0.00s)
PASS
ok  	webapp/internal/content	0.350s

每一行 PASS 对应一个边界。ok 后面的时间是本次运行耗时,不是性能承诺。接入真实服务时,还应该补一组集成测试,验证“编辑产生新版本”和“通过时版本不匹配返回冲突”这两条跨函数约束。

小结

内容状态机不只是四个状态和几条箭头。它要回答三个问题:谁可以发起这次变更,变更基于哪个版本,终态之后还能做什么。把 actor 写进转移、把版本比较放进事务、把终态定义成不可逆,才能避免“审核旧版本、发布新版本”这类事故。

Allowed 负责单次转移的合法性,Next 负责审批分支的下一步。两者共用状态常量,把合法路径、非法路径和终态都变成可测试的代码。它们不替代权限系统和版本控制,但能让流程层不再各自解释规则。

下一节换一个与流程无关、但与成本强相关的问题:内容存到哪里,以及什么时候应该把它从热存储搬到冷存储。

阅读导航:上一节:1.1 文本、图片与视频的元数据 · 下一节:1.3 内容存储的冷热分层 。

常见错误与边界

  • 只存状态不存 actor。出了问题只能看到结果,无法追责,也无法区分作者操作和运营操作。
  • 允许 draft 直接 published。审核队列还在,但发布链路已经绕过了它。
  • 审核中允许覆盖内容却不升版本。审核员看到的内容和最终发布的内容可能不是同一份。
  • 通过时不比较版本。状态转移成功,但审核结论已经过期。
  • 把 deleted 当成可逆标签。恢复如果只是改状态,审计记录会失去意义。
  • 软删后读路径漏过滤。详情、列表、搜索和缓存任一入口漏掉,已删除内容都会重新出现。
  • 用多个布尔字段代替状态。is_published、is_deleted、is_reviewing 的组合会迅速产生非法状态。
  • 把权限判断全部塞进状态机。状态机负责路径,角色到动作的映射仍应由权限系统提供。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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