“内容违规”四个字之后发生了什么
「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。
一位用户写了一篇旅行攻略,文末附上了自己常用的酒店预订链接。发布后收到提示:“内容违规。”没有规则编号,没有说明,也没有说多久能处理。他删掉链接重发,仍然被拒;把整段文字改成图片,仍然被拒;最后换了一个账号,还是被拒。
客服查到的只有一条审核记录: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,审核记录无法关联。运营想统计“某条规则到底误杀了多少篇”,只能靠文本相似度事后聚类,成本高且不准。
修复方案分四层:
- 失败响应带上
rule_code、fixable、allowed_actions和deadline_at,前端按字段渲染,不再拼一句通用文案。 - 同一用户对同一内容哈希的重复提交,在服务端直接返回上一次的审核结果,不重新进入队列。
- 申诉入口放到失败提示旁边,申诉单绑定原内容 ID 和审核记录 ID,状态变化可追溯。
- 人审队列按内容哈希去重,近似重复样本合并展示,只处理一次。
上线后,重复送审占比从 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 计算和审计记录同时失真。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。