审核通过之后,作者又改了一版
「回声」的审核后台有一个“通过”按钮。作者提交内容后状态变成 reviewing,审核员看完点通过,状态变成 published。第一版实现只有这三个状态,代码也简单:提交时写 reviewing,通过时写 published。上线两个月后,一条内容在审核中被作者改了三处,审核员看到的仍是旧版本,点通过之后,线上出现的是新版本。
这不是一个“加个锁”就能解决的问题。它暴露的是状态机缺少两个维度:谁有权限发起这次变更,以及变更针对的是哪个版本。状态只描述内容走到哪一步,不描述“谁在什么时候、基于哪一版”做的决定。把这两件事补进去,状态机才能同时承担流程控制与审计。
本节把四个状态、四条合法路径和两条审批分支写成可测试的 Go 代码,并讨论三个容易含糊的边界:审核中再次编辑、删除是软删还是硬删、以及终态为什么必须真的是终态。
把“谁能改”写进状态机
状态机的输入不只是 from 和 to,还应该包含 actor。没有 actor 的变更在审计时只剩结果,没有责任人;更现实的问题是,作者和审核员能发起的变更本来就不一样。作者可以提交审核、可以删除自己的内容;审核员可以通过或驳回;运营可以下线已发布内容。把这些权限写进转移表,接口层就不需要再复制一遍 if-else。
| 当前状态 | 目标状态 | 允许的发起人 | 含义 | 版本要求 |
|---|---|---|---|---|
| draft | reviewing | 作者 | 提交审核 | 提交时的版本 |
| reviewing | published | 审核员 | 审核通过 | 审核看到的版本 |
| reviewing | draft | 审核员 | 驳回修改 | 审核看到的版本 |
| published | deleted | 作者或运营 | 下线删除 | 当前线上版本 |
| 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 的组合会迅速产生非法状态。
- 把权限判断全部塞进状态机。状态机负责路径,角色到动作的映射仍应由权限系统提供。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。