违规评论在正文审核通过后存活了四十分钟
「回声」的审核链路最初只覆盖正文。作者发布文章时,标题、封面和正文先过机审,命中高风险再转人工;评论走的是另一条路:先写入数据库、立即展示,再由异步任务抽样送审。
一次运营事故暴露了缺口。一篇讲理财的文章下面,第一条评论带着引流二维码和辱骂内容。正文审核通过,评论也顺利发布;四十分钟后抽样任务才命中它并转人工。案例假设值:这段时间文章获得 20 万次曝光,评论被截图转发到外部社群,等运营删除时已经扩散。
更麻烦的是,删除动作本身也出了问题。运营把评论状态改成 deleted,但回复表里的子评论还在,按根评论查询时返回一棵「根没了、回复还挂着」的树;搜索索引没有同步,违规文本仍能被检索到;客户端缓存里还留着旧列表。
这不是审核模型不够准,而是审核被当成了正文的附属步骤。评论、回复、昵称和简介都是用户生成内容,都有发布入口,都需要独立的审核时机和处置动作。只补一个关键词库,解决不了「先发布后审核」的结构问题。
审核对象不止正文
把评论区拆开看,至少有五类内容会进入公开展示面。它们的风险、审核时机和处置动作不同,不能共用一条「正文审核」流水线。
| 对象 | 典型风险 | 审核时机 | 处置动作 |
|---|---|---|---|
| 正文 | 违法、侵权、虚假信息 | 发布前同步机审,高风险转人工 | 拒绝发布、下架、限流 |
| 根评论 | 引流、辱骂、谣言 | 发布前机审,低置信度进人工队列 | 拒绝、折叠、删除 |
| 回复 | 人身攻击、骚扰、隐私泄露 | 发布前机审,按作者信誉分级 | 折叠、删除、禁言 |
| 昵称与头像 | 冒充、色情、联系方式 | 修改时审核,历史内容回扫 | 重置、限制修改 |
| 简介与签名 | 站外引流、诈骗 | 修改时审核,定期回扫 | 清空、限制展示 |
审核时机有三种基本选择。发布前同步审核最安全,但会增加发布延迟,机审服务抖动时用户发不出去。发布后异步审核体验最好,但存在一个「违规内容已可见」的窗口。先发后审加折叠是折中:内容先进入待审状态,默认折叠,审核通过后再展开。
选择哪一种,取决于内容形态和风险等级。新闻、金融、医疗这类高风险正文适合发布前审核;普通社区的评论可以用「新账号先折叠、老账号先展示、命中模型再折叠」的分级策略。关键是把策略写进服务端,而不是依赖客户端先隐藏。
审核结果要覆盖「已发布」的内容
审核不是一次性的发布闸门。评论发布后仍可能被举报、被回扫命中,或者作者被整体拉黑。服务端需要支持对已发布内容追加处置,并且让处置在列表、详情、搜索和通知四个出口同时生效。
这也是折叠和删除必须分开的原因。删除是不可逆的内容消失,适合违法、严重侵权这类必须清除的内容;折叠是可逆的展示降级,适合低质、争议、待复核的内容。把两者混成一个 status 字段,运营想恢复一条误折叠的评论时,只能重新插入一条新记录,评论 ID、回复关系和通知都会断。
失败场景:先发布后审核的窗口
回到那四十分钟。评论写入时 status=published,异步任务每十分钟扫一批,命中后写 status=deleted。窗口期内,评论已经进入三个地方:文章详情的内存缓存、评论列表的读库、全文搜索索引。
运营删除后,三个地方的表现不一致。详情缓存要等五分钟过期;读库实时生效;搜索索引要等下一次重建。用户从不同入口看到不同结果,客诉里出现「删了还能搜到」。更严重的是,回复表没有级联处理,子回复仍然按根 ID 聚合,列表接口返回空根节点,前端渲染出一块空白。
如果当初用折叠而不是直接删除,处置会简单很多:先把根评论标记为 Folded=true,列表默认不展示正文、只显示「该评论已被折叠」;审核确认违规后再删除,并异步清理搜索和缓存。折叠是秒级的,删除可以是异步的,两个动作解耦。
另一个常见错误是把「拉黑作者」做成只过滤新评论。作者被拉黑后,历史评论仍然可见,用户仍能收到该作者的回复通知。正确做法是拉黑动作同时触发一次回扫,把该作者的非根评论整棵子树折叠;根评论保留但可以在作者维度做隐藏。
用 Fold 标记,而不是从列表里删掉
折叠规则如果散落在列表接口、详情接口和审核后台,三个地方很容易出现不同判断。把规则收口到 Fold,输入一棵已经建好的树,输出一棵标记了 Folded 的新树,节点一个不少。
下面的代码来自本书配套的 webapp-lab。FoldRule 用 MinLikes 和 MaxDepth 描述低质评论的折叠阈值,用 BlockedAuthors 描述拉黑作者;Fold 不修改入参,根评论永不折叠,被拉黑作者的非根评论连同整棵子树折叠;FoldedCount 只统计标记,不改变树形。
// file: internal/comment/fold.go
// tier: L1
// verify: go test ./internal/comment/
package comment
// FoldRule 描述折叠策略。MinLikes 是深层评论保留所需的点赞数;
// MaxDepth 是开始折叠的深度,根评论深度为 0,回复从 1 起算;
// BlockedAuthors 中的作者发布的非根评论连同整棵子树一起折叠。
type FoldRule struct {
MinLikes int
MaxDepth int
BlockedAuthors []string
}
// Fold 返回标记了 Folded 的新树,不修改入参,也不删除任何节点。
// 根评论永不折叠;被拉黑作者的非根评论整棵子树折叠;
// 深度达到 MaxDepth 且点赞数低于 MinLikes 的评论折叠。
func Fold(nodes []Node, r FoldRule) []Node {
blocked := make(map[string]struct{}, len(r.BlockedAuthors))
for _, author := range r.BlockedAuthors {
if author != "" {
blocked[author] = struct{}{}
}
}
depthFrom := r.MaxDepth
if depthFrom < 1 {
depthFrom = 1
}
out := make([]Node, len(nodes))
for i, node := range nodes {
out[i] = foldAt(node, 0, depthFrom, r.MinLikes, blocked, false)
}
return out
}
func foldAt(n Node, depth, depthFrom, minLikes int, blocked map[string]struct{}, force bool) Node {
folded := n.Folded || force
if depth > 0 {
if _, ok := blocked[n.AuthorID]; ok && n.AuthorID != "" {
folded = true
force = true
}
if depth >= depthFrom && n.Likes < minLikes {
folded = true
}
}
out := Node{Comment: n.Comment, Folded: folded, Children: make([]Node, len(n.Children))}
for i, child := range n.Children {
out.Children[i] = foldAt(child, depth+1, depthFrom, minLikes, blocked, force)
}
return out
}
// FoldedCount 统计整棵树中被折叠的节点数,包含子树的折叠节点。
func FoldedCount(nodes []Node) int {
count := 0
for _, node := range nodes {
if node.Folded {
count++
}
count += FoldedCount(node.Children)
}
return count
}
代码里有几个刻意的选择。
- 根评论永不折叠。根评论是整段讨论的入口,折叠它等于让后面的回复失去上下文;根评论违规应该走删除或下架,而不是折叠。
- 被拉黑作者折叠整棵子树。只折叠作者本人的那一条,回复者仍会看到自己回复的内容挂在一条被隐藏的评论下面,上下文是断的;整棵子树一起折叠,语义才一致。
- 低赞折叠从
MaxDepth层开始。浅层回复是讨论的主干,点赞少不代表低质;深层回复才是「楼中楼」噪声的主要来源。MaxDepth小于 1 时按 1 处理,避免把根评论卷进来。 Fold返回新树,不修改入参。同一棵树可能要按不同规则渲染多次,比如作者视角、审核视角、普通用户视角;原地修改会让后一次调用看到前一次的标记。- 已有的
Folded标记会被保留,并与新规则取并集。审核后台可能已经人工折叠过一条评论,列表渲染不能再把它展开。 - 折叠只标记,不删除。列表接口根据
Folded决定是否展示正文,详情接口可以返回「已折叠,点击展开」,审核后台仍然能看到原文。
Fold 是展示层规则,不是审核结论。它回答的是「这条评论默认怎么展示」,而不是「这条评论是否违规」。违规判定应该由审核服务产出,折叠服务只消费结论;两者混在一起,规则会越来越难解释。
折叠状态和审核状态要分开
评论至少有两种独立状态:展示状态和审核状态。展示状态决定列表默认怎么渲染,取值是正常或折叠;审核状态记录内容有没有经过判定,取值是待审、通过、拒绝或删除。把两者压成一个 status 枚举,运营想「先折叠、等人工复核」时就会发现没有合适的值。
分开之后,状态转换也清晰了。新评论可以进入待审并默认折叠,审核通过后只改审核状态,展示状态由折叠规则重新计算;人工折叠只改展示状态,不影响审核记录;确认违规才进入删除,删除要级联处理回复、搜索索引和缓存。每一步都能单独回滚,误操作不会被放大。
审核记录还要保留原因、规则版本和操作者。用户申诉时,运营需要知道这条评论是被哪条规则、哪个模型版本、在什么时间点判定的;只存一个 rejected,申诉只能靠人工重新读一遍内容。规则版本也能帮助定位问题:一次模型升级导致误杀,可以按版本号批量回滚,而不是逐条猜。
测试:根不折叠、拉黑子树、阈值、不修改入参
折叠测试要覆盖四类边界:根评论不能折叠、拉黑作者的整棵子树折叠、低赞阈值只在指定深度生效、输入树不能被修改。少任何一类,线上都可能出现「误折叠主干讨论」或「审核后台改了规则,列表跟着变」。
下面的测试用 Build 构造一棵四层树,根评论的作者也在拉黑名单里,用来验证「根永不折叠」优先于「拉黑作者」;同时检查被拉黑子树、低赞深层回复和 FoldedCount 的结果。
// file: internal/comment/fold_test.go
// tier: L1
// verify: go test ./internal/comment/
package comment
import "testing"
func foldFixture(t *testing.T) []Node {
t.Helper()
list := []Comment{
{ID: "r1", AuthorID: "u-block", At: 1, Likes: 0},
{ID: "c1", ParentID: "r1", AuthorID: "u1", At: 2, Likes: 1},
{ID: "g1", ParentID: "c1", AuthorID: "u2", At: 3, Likes: 1},
{ID: "g2", ParentID: "c1", AuthorID: "u2", At: 4, Likes: 9},
{ID: "c2", ParentID: "r1", AuthorID: "u-block", At: 5, Likes: 100},
{ID: "g3", ParentID: "c2", AuthorID: "u3", At: 6, Likes: 100},
{ID: "c3", ParentID: "r1", AuthorID: "u4", At: 7, Likes: 100},
{ID: "g4", ParentID: "c3", AuthorID: "u5", At: 8, Likes: 0},
{ID: "gg1", ParentID: "g4", AuthorID: "u6", At: 9, Likes: 0},
}
nodes, err := Build(list, 4)
if err != nil {
t.Fatalf("Build: %v", err)
}
return nodes
}
func TestFoldMarksWithoutDeleting(t *testing.T) {
nodes := foldFixture(t)
got := Fold(nodes, FoldRule{MinLikes: 5, MaxDepth: 2, BlockedAuthors: []string{"u-block"}})
if got[0].Folded {
t.Fatal("root must never fold")
}
if len(got[0].Children) != 3 {
t.Fatalf("root children = %d, want 3 (nodes must not be deleted)", len(got[0].Children))
}
c2 := got[0].Children[1]
if !c2.Folded || !c2.Children[0].Folded {
t.Fatal("blocked author subtree must be folded")
}
c1 := got[0].Children[0]
if c1.Folded {
t.Fatal("depth 1 below MinDepth must not fold on likes")
}
if !c1.Children[0].Folded || c1.Children[1].Folded {
t.Fatal("depth 2 likes threshold applied incorrectly")
}
if n := FoldedCount(got); n != 5 {
t.Fatalf("FoldedCount = %d, want 5", n)
}
}
func TestFoldDoesNotMutateInput(t *testing.T) {
nodes := foldFixture(t)
_ = Fold(nodes, FoldRule{MinLikes: 100, MaxDepth: 1, BlockedAuthors: []string{"u-block"}})
for _, n := range nodes {
if n.Folded {
t.Fatalf("input node %s was mutated", n.ID)
}
}
}
func TestFoldKeepsExistingFlags(t *testing.T) {
nodes := []Node{{Comment: Comment{ID: "r1", At: 1}}}
got := Fold(nodes, FoldRule{MinLikes: 100, MaxDepth: 1})
if got[0].Folded {
t.Fatal("pre-existing Folded flag should be preserved, not invented")
}
nodes[0].Folded = true
got = Fold(nodes, FoldRule{})
if !got[0].Folded {
t.Fatal("pre-existing Folded flag was lost")
}
}
TestFoldMarksWithoutDeleting 是最值得看的一条。它同时断言三件事:根评论即使作者被拉黑、点赞为 0 也不折叠;被拉黑作者的回复和它的子回复都折叠;深度 1 的低赞回复不折叠,深度 2 的低赞回复折叠。只测「折叠了几条」不够,还要检查具体节点,否则阈值写错一层也能碰巧得到相同数量。
TestFoldDoesNotMutateInput 验证 Fold 不修改原树。列表接口可能先按普通用户视角折叠一次,再按审核视角计算一次;如果第一次调用把原树的 Folded 改了,第二次的输入就已经被污染。
TestFoldKeepsExistingFlags 验证人工折叠不会被规则覆盖。审核后台把一条评论标记为折叠后,列表渲染应该继续尊重这个标记,而不是因为点赞数达标就自动展开。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/comment/ -run 'TestFold' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestFoldMarksWithoutDeleting
--- PASS: TestFoldMarksWithoutDeleting (0.00s)
=== RUN TestFoldDoesNotMutateInput
--- PASS: TestFoldDoesNotMutateInput (0.00s)
=== RUN TestFoldKeepsExistingFlags
--- PASS: TestFoldKeepsExistingFlags (0.00s)
PASS
ok webapp/internal/comment 0.358s
PASS 表示根评论保护、拉黑子树、低赞阈值和入参不可变四类用例全部通过。输出里的耗时是纯内存遍历的耗时,不代表审核服务或数据库的延迟;线上折叠的难点在规则一致性、缓存失效和回扫范围,而不是这段递归。
小结
评论审核要覆盖所有公开展示面,而不只是正文。正文、根评论、回复、昵称和简介各有风险与时机;发布前审核最安全,先发后审则需要折叠来缩小可见窗口。折叠是可逆的展示降级,删除是不可逆的内容清除,两者不能共用一个状态字段。
Fold 把规则收口成纯函数:根评论永不折叠,拉黑作者的子树整体折叠,低赞深层回复按阈值折叠,已有标记保留,入参不修改。下一节讨论互动通知的触发边界,重点是如何避免一个人连点二十个赞,把接收者的通知权限直接关掉。
阅读导航:上一节:7.1 评论树与分页 · 下一节:7.3 互动通知的触发边界 。
常见错误与边界
- 只审核正文,评论先发布后抽样。违规内容在抽样窗口内已经曝光,截图和转发无法收回;评论入口必须有独立的发布前或默认折叠策略。
- 把折叠当成删除。折叠是可逆的展示降级,删除是不可逆的内容清除;混成一个状态,误折叠后无法恢复评论 ID 和回复关系。
- 折叠根评论。根评论是讨论入口,折叠后回复失去上下文;根评论违规应该删除、下架或整段隐藏。
- 拉黑作者只过滤新内容。历史评论和回复仍然可见,通知也照常发送;拉黑动作要触发回扫和子树折叠。
- 低赞阈值从第一层生效。浅层回复是讨论主干,点赞少不代表低质;阈值应该从配置的深度开始。
- 原地修改传入的树。同一棵树要按不同视角渲染多次,原地写
Folded会污染后续调用;必须返回新树。 - 折叠后不同步搜索和缓存。列表隐藏了,搜索仍能命中原文,用户会认为删除无效;折叠和删除都要有缓存失效与索引更新。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。