《互联网产品的服务端架构·内容、社交与实时互动》2.3 审核失败的用户体验与申诉

审核失败时只回一句「内容违规」,用户会反复提交同一内容,客服也无法解释。文章把失败提示拆成规则、可修改性和时限三个问题,给出申诉状态机与超时边界,并用 Go 实现 pending 到 accepted、rejected、expired 的合法迁移,复盘一次重复送审放大审核成本的故障。

“内容违规”四个字之后发生了什么

「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。

一位用户写了一篇旅行攻略,文末附上了自己常用的酒店预订链接。发布后收到提示:“内容违规。”没有规则编号,没有说明,也没有说多久能处理。他删掉链接重发,仍然被拒;把整段文字改成图片,仍然被拒;最后换了一个账号,还是被拒。

客服查到的只有一条审核记录:reject,原因码 LINK_POLICY。但原因码没有对用户展示,客服培训材料里也没有这个码的解释。用户反复提交同一内容,每次都被重新送审,审核队列里出现五份近似重复的样本,人审同学逐条看完,结论都一样。

这个故障的成本不是多审了几条内容,而是用户不知道“改什么才有用”。审核失败的处理,本质上是一个信息设计问题:系统必须告诉用户哪条规则、能不能改、多久出结果。三者缺一,用户就只能靠猜,猜错就重复提交。

失败提示必须回答三个问题

把一次审核失败拆成三个字段,产品和服务端就有了共同的契约:

问题服务端字段用户看到的文案缺失后的后果
哪条规则rule_code、rule_version“外链导购不符合社区规范”用户不知道从哪改
能不能改fixable、allowed_actions“删除外链后可重新提交”用户反复提交同一内容
多久出结果deadline_at、state“申诉预计 24 小时内处理”用户不断刷新或找客服

rule_code 不等于内部错误码。内部错误码可以很细,但对用户展示的应该是可理解、可行动的规则分类。rule_version 用于复盘:同一规则升级后,旧内容的判定可能需要重跑,没有版本号就无法解释“为什么上周能发,这周不能发”。

fixable 是一个布尔值,但真正有用的是 allowed_actions:删除某段内容、替换外链、补全资质、直接申诉。只告诉用户“不可修改”,等于让他放弃;只告诉用户“可修改”却不说什么能改,等于让他继续猜。

时限必须绑定状态机,而不是写在文案里。deadline_at 由进入 pending 的时间加上 TTL 计算,状态迁移时更新;文案从状态和截止时间生成,避免运营改了一句文案、后端却还在用旧时限。

申诉状态机只有四个状态:

  • pending:已提交,等待处理或超时。
  • accepted:申诉通过,内容恢复。
  • rejected:申诉驳回,原结论维持。
  • expired:超过时限仍未处理。

accepted、rejected、expired 都是终态。终态不能回退到 pending,否则同一张申诉单会被反复处理,审核记录和用户看到的进度都会错乱。需要重新申诉时,应该创建新的申诉单,而不是复活旧单。

失败场景:重复送审放大审核成本

「回声」第一版的审核失败接口只返回 {"ok": false, "message": "内容违规"}。上线三个月后,数据如下(案例假设值):

  • 日均拒绝 1.2 万条,其中 35% 在 24 小时内被同一用户重新提交。
  • 平均每条被拒内容提交 4.1 次,最多的一条提交了 27 次。
  • 审核队列里约 28% 的样本是近似重复内容,人审重复劳动。
  • 申诉入口藏在帮助中心第三层,日均申诉 90 条,占拒绝量的 0.75%。
  • 客服关于“为什么发不出去”的工单占内容类工单的 41%。

重复送审还带来一个隐蔽问题:每次提交都会生成新的内容 ID,审核记录无法关联。运营想统计“某条规则到底误杀了多少篇”,只能靠文本相似度事后聚类,成本高且不准。

修复方案分四层:

  1. 失败响应带上 rule_code、fixable、allowed_actions 和 deadline_at,前端按字段渲染,不再拼一句通用文案。
  2. 同一用户对同一内容哈希的重复提交,在服务端直接返回上一次的审核结果,不重新进入队列。
  3. 申诉入口放到失败提示旁边,申诉单绑定原内容 ID 和审核记录 ID,状态变化可追溯。
  4. 人审队列按内容哈希去重,近似重复样本合并展示,只处理一次。

上线后,重复送审占比从 35% 降到 9%,人审样本里近似重复从 28% 降到 6%,客服内容类工单下降 37%(案例假设值)。规则本身没有变,变的只是让用户知道该改什么、以及不要为同一份内容反复排队。

用状态机管住申诉时限

下面的代码来自本书配套的 webapp-lab。Advance 只允许从 pending 推进,终态一律拒绝;expired 必须真的超过 TTL 才能进入,避免调用方提前把单子关掉。时间倒退直接报错,而不是把 At 改小后继续。

// file: internal/moderate/appeal.go
// tier: L1
// verify: go test ./internal/moderate/

package moderate

import (
	"fmt"
	"strings"
)

// AppealState 是申诉单的状态。
type AppealState string

const (
	// AppealPending 表示申诉已提交,等待处理或超时。
	AppealPending AppealState = "pending"
	// AppealAccepted 表示申诉通过,内容恢复。
	AppealAccepted AppealState = "accepted"
	// AppealRejected 表示申诉被驳回,原审核结论维持。
	AppealRejected AppealState = "rejected"
	// AppealExpired 表示超过时限仍未处理。
	AppealExpired AppealState = "expired"
)

// Appeal 是一张申诉单。At 是进入当前状态的时间,单位秒。
type Appeal struct {
	ID        string
	ContentID string
	State     AppealState
	At        int64
}

// Advance 把申诉单从 pending 推进到下一个状态。
//
// 只有 pending 可以推进:accepted、rejected、expired 都是终态,不能再改。
// now 早于 At 说明调用方传入了倒退的时间,直接报错而不是悄悄接受。
// ttlSeconds 是申诉处理时限;只有 now 已经到达 At+ttlSeconds 时,才允许推进到 expired。
// 超时不会自动发生:调用方仍需显式传入 AppealExpired,这样到期扫描和人工处理可以走同一条路径。
func Advance(a Appeal, next AppealState, now int64, ttlSeconds int) (Appeal, error) {
	if strings.TrimSpace(a.ID) == "" {
		return a, fmt.Errorf("moderate: appeal id is required")
	}
	if strings.TrimSpace(a.ContentID) == "" {
		return a, fmt.Errorf("moderate: appeal %s content id is required", a.ID)
	}
	if ttlSeconds <= 0 {
		return a, fmt.Errorf("moderate: appeal %s ttl %ds must be positive", a.ID, ttlSeconds)
	}
	if now < a.At {
		return a, fmt.Errorf("moderate: appeal %s time moves backwards: now %d before %d", a.ID, now, a.At)
	}
	if !validAppealState(a.State) {
		return a, fmt.Errorf("moderate: appeal %s has unknown state %q", a.ID, a.State)
	}
	if !validAppealState(next) {
		return a, fmt.Errorf("moderate: appeal %s has unknown next state %q", a.ID, next)
	}
	if a.State != AppealPending {
		return a, fmt.Errorf("moderate: appeal %s is terminal in state %q", a.ID, a.State)
	}

	switch next {
	case AppealAccepted, AppealRejected:
		a.State = next
		a.At = now
		return a, nil
	case AppealExpired:
		if now-a.At < int64(ttlSeconds) {
			return a, fmt.Errorf("moderate: appeal %s not expired: %ds elapsed, ttl %ds", a.ID, now-a.At, ttlSeconds)
		}
		a.State = AppealExpired
		a.At = now
		return a, nil
	default:
		return a, fmt.Errorf("moderate: appeal %s cannot advance from pending to %q", a.ID, next)
	}
}

func validAppealState(s AppealState) bool {
	switch s {
	case AppealPending, AppealAccepted, AppealRejected, AppealExpired:
		return true
	default:
		return false
	}
}

超时不会自动发生。到期扫描任务和人工处理走同一个 Advance,只是传入的 next 不同;这样“系统判定超时”和“人审给出结论”共享一套状态约束,不会出现扫描任务把已处理的单子改成 expired 的情况。

测试:终态、超时与时间倒退

测试把每个合法迁移和每个非法迁移都固定下来。尤其是终态遍历:任何终态推进到任何状态都必须失败,并且返回的申诉单不能被修改。

// file: internal/moderate/appeal_test.go
// tier: L1
// verify: go test ./internal/moderate/

package moderate

import (
	"strings"
	"testing"
)

func pendingAppeal() Appeal {
	return Appeal{ID: "ap-1", ContentID: "post-9", State: AppealPending, At: 1_000}
}

func TestAdvanceAcceptsPendingAppeal(t *testing.T) {
	got, err := Advance(pendingAppeal(), AppealAccepted, 1_030, 3_600)
	if err != nil {
		t.Fatal(err)
	}
	if got.State != AppealAccepted {
		t.Fatalf("state = %q, want accepted", got.State)
	}
	if got.At != 1_030 {
		t.Fatalf("at = %d, want 1030", got.At)
	}
}

func TestAdvanceRejectsPendingAppeal(t *testing.T) {
	got, err := Advance(pendingAppeal(), AppealRejected, 1_030, 3_600)
	if err != nil {
		t.Fatal(err)
	}
	if got.State != AppealRejected {
		t.Fatalf("state = %q, want rejected", got.State)
	}
}

func TestAdvanceExpiresAtTTLBoundary(t *testing.T) {
	got, err := Advance(pendingAppeal(), AppealExpired, 4_600, 3_600)
	if err != nil {
		t.Fatal(err)
	}
	if got.State != AppealExpired {
		t.Fatalf("state = %q, want expired", got.State)
	}
	if got.At != 4_600 {
		t.Fatalf("at = %d, want 4600", got.At)
	}
}

func TestAdvanceRejectsEarlyExpiry(t *testing.T) {
	got, err := Advance(pendingAppeal(), AppealExpired, 4_599, 3_600)
	if err == nil {
		t.Fatal("want error for expiry before ttl")
	}
	if !strings.Contains(err.Error(), "not expired") {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != pendingAppeal() {
		t.Fatalf("early expiry changed appeal: %+v", got)
	}
}

func TestAdvanceRefusesToChangeTerminalState(t *testing.T) {
	terminal := []AppealState{AppealAccepted, AppealRejected, AppealExpired}
	for _, state := range terminal {
		t.Run(string(state), func(t *testing.T) {
			a := Appeal{ID: "ap-2", ContentID: "post-9", State: state, At: 1_000}
			for _, next := range []AppealState{AppealPending, AppealAccepted, AppealRejected, AppealExpired} {
				got, err := Advance(a, next, 2_000, 3_600)
				if err == nil {
					t.Fatalf("terminal %q advanced to %q", state, next)
				}
				if !strings.Contains(err.Error(), "terminal") {
					t.Fatalf("unexpected error: %v", err)
				}
				if got != a {
					t.Fatalf("terminal appeal changed: %+v", got)
				}
			}
		})
	}
}

func TestAdvanceRejectsBackwardsTime(t *testing.T) {
	got, err := Advance(pendingAppeal(), AppealAccepted, 999, 3_600)
	if err == nil {
		t.Fatal("want error for backwards time")
	}
	if !strings.Contains(err.Error(), "backwards") {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != pendingAppeal() {
		t.Fatalf("backwards time changed appeal: %+v", got)
	}
}

func TestAdvanceValidatesInput(t *testing.T) {
	cases := []struct {
		name string
		ap   Appeal
		next AppealState
		now  int64
		ttl  int
		want string
	}{
		{
			name: "missing-id",
			ap:   Appeal{ContentID: "post-9", State: AppealPending, At: 1_000},
			next: AppealAccepted, now: 1_030, ttl: 3_600,
			want: "appeal id is required",
		},
		{
			name: "missing-content-id",
			ap:   Appeal{ID: "ap-3", State: AppealPending, At: 1_000},
			next: AppealAccepted, now: 1_030, ttl: 3_600,
			want: "content id is required",
		},
		{
			name: "zero-ttl",
			ap:   pendingAppeal(),
			next: AppealAccepted, now: 1_030, ttl: 0,
			want: "must be positive",
		},
		{
			name: "unknown-current-state",
			ap:   Appeal{ID: "ap-4", ContentID: "post-9", State: AppealState("lost"), At: 1_000},
			next: AppealAccepted, now: 1_030, ttl: 3_600,
			want: "unknown state",
		},
		{
			name: "unknown-next-state",
			ap:   pendingAppeal(),
			next: AppealState("lost"), now: 1_030, ttl: 3_600,
			want: "unknown next state",
		},
		{
			name: "pending-to-pending",
			ap:   pendingAppeal(),
			next: AppealPending, now: 1_030, ttl: 3_600,
			want: "cannot advance",
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, err := Advance(c.ap, c.next, c.now, c.ttl); err == nil {
				t.Fatal("want validation error")
			} else if !strings.Contains(err.Error(), c.want) {
				t.Fatalf("error %q does not contain %q", err, c.want)
			}
		})
	}
}

TestAdvanceExpiresAtTTLBoundary 使用 now == At+ttl,明确超时是闭区间:恰好到期就允许进入 expired。如果产品要求“超过”而不是“达到”,边界应该改成 now > At+ttl,但代码、测试和文案必须同时改。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/moderate/ -run 'TestAdvance' -count=1 -v

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

=== RUN   TestAdvanceAcceptsPendingAppeal
--- PASS: TestAdvanceAcceptsPendingAppeal (0.00s)
=== RUN   TestAdvanceRejectsPendingAppeal
--- PASS: TestAdvanceRejectsPendingAppeal (0.00s)
=== RUN   TestAdvanceExpiresAtTTLBoundary
--- PASS: TestAdvanceExpiresAtTTLBoundary (0.00s)
=== RUN   TestAdvanceRejectsEarlyExpiry
--- PASS: TestAdvanceRejectsEarlyExpiry (0.00s)
=== RUN   TestAdvanceRefusesToChangeTerminalState
=== RUN   TestAdvanceRefusesToChangeTerminalState/accepted
=== RUN   TestAdvanceRefusesToChangeTerminalState/rejected
=== RUN   TestAdvanceRefusesToChangeTerminalState/expired
--- PASS: TestAdvanceRefusesToChangeTerminalState (0.00s)
    --- PASS: TestAdvanceRefusesToChangeTerminalState/accepted (0.00s)
    --- PASS: TestAdvanceRefusesToChangeTerminalState/rejected (0.00s)
    --- PASS: TestAdvanceRefusesToChangeTerminalState/expired (0.00s)
=== RUN   TestAdvanceRejectsBackwardsTime
--- PASS: TestAdvanceRejectsBackwardsTime (0.00s)
=== RUN   TestAdvanceValidatesInput
=== RUN   TestAdvanceValidatesInput/missing-id
=== RUN   TestAdvanceValidatesInput/missing-content-id
=== RUN   TestAdvanceValidatesInput/zero-ttl
=== RUN   TestAdvanceValidatesInput/unknown-current-state
=== RUN   TestAdvanceValidatesInput/unknown-next-state
=== RUN   TestAdvanceValidatesInput/pending-to-pending
--- PASS: TestAdvanceValidatesInput (0.00s)
    --- PASS: TestAdvanceValidatesInput/missing-id (0.00s)
    --- PASS: TestAdvanceValidatesInput/missing-content-id (0.00s)
    --- PASS: TestAdvanceValidatesInput/zero-ttl (0.00s)
    --- PASS: TestAdvanceValidatesInput/unknown-current-state (0.00s)
    --- PASS: TestAdvanceValidatesInput/unknown-next-state (0.00s)
    --- PASS: TestAdvanceValidatesInput/pending-to-pending (0.00s)
PASS
ok  	webapp/internal/moderate	0.357s

测试全绿只能证明状态迁移符合当前约定,不能证明提示文案足够清楚。文案和字段是否真的让用户知道“改什么”,还需要可用性测试和客服工单数据来验证。

小结

审核失败不是一次简单的布尔返回,而是用户与系统之间的一次解释。告诉用户哪条规则、能不能改、多久出结果,能显著减少重复提交和客服工单;这三件事必须由服务端字段支撑,而不是靠前端拼文案。

申诉状态机负责的是时限和终态:pending 可以走向 accepted、rejected 或 expired,终态不可回退,超时需要真的到达 TTL,时间倒退必须报错。把这些约束写进代码和测试,才能避免重复送审和状态错乱。

阅读导航:上一节:2.2 机审、人审与复审 · 下一节:3.1 拉模式:读时聚合 。

常见错误与边界

  • 只返回“内容违规”。没有规则、可修改性和时限,用户只能猜,猜错就重复提交。
  • 把内部错误码直接暴露给用户。错误码可以很细,但用户需要的是可理解、可行动的规则分类。
  • 申诉入口藏得太深。入口离失败提示越远,重复提交和客服工单越多。
  • 允许终态回退。复活旧申诉单会让审核记录和用户进度错乱,需要重新申诉时应创建新单。
  • 超时由文案承诺、由定时任务随意改状态。时限必须来自状态机和 deadline_at,到期扫描也要走同一套迁移校验。
  • 重复提交不关联原内容。每次生成新 ID,导致审核记录无法统计,也无法复用上一次结论。
  • 提前把申诉单标记为过期。未到 TTL 就 expired 等于剥夺用户的处理窗口,测试必须覆盖这个边界。
  • 忽略时间倒退。时钟回拨或调用方传错时间时静默接受,会让 TTL 计算和审计记录同时失真。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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