拉黑之后,换一个客户端还能看到
「回声」的拉黑功能上线时,产品描述只有一句:用户可以把不想看到的人加入黑名单。移动端实现得很快:拉黑后从本地列表移除,个人主页显示“已拉黑”,动态流里不再出现对方的内容。上线一周后,一位用户反馈:拉黑之后,对方仍然能在网页端看到自己刚发布的动态。
开发的第一反应是“移动端已经隐藏了”。但网页端走的是同一套 API,API 返回的 JSON 里仍然有对方的动态。移动端只是根据本地的拉黑列表做了过滤,网页端没有这段逻辑。用户换一个客户端,或者直接抓包,就能看到内容。
这次事故说明,“拉黑”不是一个前端开关,而是一条要在所有读路径生效的规则。更麻烦的是,团队把拉黑实现成了“取消好友”:只影响 friends-only 内容,公开动态照常返回。用户拉黑一个人,通常希望对方彻底看不到自己,而不是“看不到一部分”。
拉黑、屏蔽、可见性是三个层次
把三个词拆开,才能确定它们各自应该写在哪里。
| 概念 | 作用范围 | 存储形态 | 生效位置 | 常见误用 |
|---|---|---|---|---|
| 拉黑 block | 关系级,否决两个方向 | owner -> viewer 的 block 边 | 所有读路径的最终判定 | 只在前端隐藏,或实现成取消好友 |
| 屏蔽 mute | 内容级,只影响某个人的信息流 | 用户偏好或过滤规则 | Feed 组装、搜索与推荐 | 当成拉黑,导致对方无法访问主页 |
| 可见性 audience | 单条内容的受众档位 | 内容字段上的 public/friends | 服务端组装响应时 | 只做前端条件渲染 |
拉黑和屏蔽最容易被混为一谈。拉黑是关系级的:被拉黑者不能看你的内容,你也不应该继续在信息流里看到对方;有些产品还会限制对方再次发起好友请求。屏蔽是内容级的:你只是不想在某个列表里看到某个人或某个话题,双方的直接访问和好友关系不受影响。把屏蔽做成拉黑,会让用户在处理“不想看”时误伤真实关系;把拉黑做成屏蔽,则会让骚扰者继续访问。
可见性则是另一个维度。一条动态可以是 public,也可以是 friends-only;拉黑不是可见性档位,而是覆盖在所有档位之上的否决。判断顺序必须是:本人、拉黑、公开、好友。顺序错了,就会出现“公开内容绕过拉黑”或“好友关系压过拉黑”的漏洞。
拉黑为什么要双向否决
block 边通常只在拉黑者一侧写入,但效果要覆盖两个方向。被拉黑者不能看 owner 的内容,这是最直接的需求;拉黑者也不应该继续在推荐、搜索和动态流里看到对方,否则“拉黑”只完成了一半。
「回声」的做法是:写入时只存一条 owner -> viewer 的 block 边,读路径的 CanView 同时检查两个方向。这样存储简单,语义也清楚。另一种做法是写入两条边,查询只查一个方向;代价是写入要保证原子性,删除时要清理两条,容易留下半条记录。
无论选哪种,读路径都要能回答“这两个人之间有没有任一方向的拉黑”。如果只检查 owner 是否拉黑 viewer,用户主动拉黑对方后,自己的信息流里仍会出现对方,体验上等于没拉黑。
缓存和搜索也是读路径
拉黑生效的范围不只是详情页。动态列表、搜索结果、推荐、通知、导出接口,只要会返回内容或用户信息,就必须经过同一个判定。团队常见的做法是只在 GET /users/{id} 里检查拉黑,列表接口复用旧的对象序列化函数,结果又漏了出去。
缓存是另一个容易漏的地方。按 owner 维度缓存的动态列表,如果 key 里没有 viewer 身份和关系版本,第一个访问者的结果会被第二个访问者复用。拉黑之后,关系版本变化,旧缓存必须失效;否则用户会看到“拉黑已经成功,但刷新几次又出现了”的现象。
拉黑与好友关系、好友请求的关系
拉黑不应该顺手删掉好友边。用户拉黑一个人,往往是一时的安全需求;解除拉黑后,如果原来的好友关系已经丢失,用户会觉得自己被系统“偷偷改了数据”。更合理的做法是保留 friend 边,让 block 在读路径上覆盖它;解除拉黑后,好友关系自然恢复。
但这带来一个前提:判断必须始终先看 block,再看 friend。只要有一处读路径只查 friend,拉黑就会被绕过。写入好友请求时也要检查 block:被拉黑者不能再次发起请求,否则用户会反复收到骚扰通知。这个检查放在写入入口,比在通知发送时过滤更可靠。
关注边也是同样的道理。拉黑期间,用户可以继续保留对对方的关注记录,但信息流不应该展示对方的内容;解除拉黑后,关注关系可以恢复。把“关注是否存在”和“内容是否可见”分开,能避免解除拉黑时还要重建关系。
失败场景:只在一个接口过滤
回到网页端还能看到动态的事故。链路是这样的:用户在移动端拉黑 B,服务端写入 A -> B 的 block 边;移动端本地维护黑名单,动态流过滤掉 B;网页端请求同一个 Feed API,服务端没有检查 block,返回 B 的动态;网页端也没有本地黑名单,于是正常渲染。
更隐蔽的一次发生在缓存层。团队给个人主页加了 60 秒缓存,key 是 profile:{ownerID}。A 拉黑 B 后,B 打开 A 的主页,缓存里还是拉黑前生成的公开资料;60 秒内 B 继续能看到头像和简介。虽然后端在详情接口里已经加了判断,但缓存命中时根本没有走到判断。
这两次问题的共同点是:判定被放在了某一条路径上,而不是放在所有路径共同的出口。只要有一个客户端、一个接口或一层缓存绕过去,拉黑就形同虚设。后果也不只是内容泄露:骚扰者可以继续通过主页、搜索和推荐找到用户,用户会认为平台没有履行承诺,客服也无法用“前端已经隐藏”解释。
从审计角度看,问题同样严重。拉黑是一次用户明确表达的安全操作,服务端应该记录谁在什么时候拉黑了谁、哪些请求因为拉黑被拒绝。如果判定只发生在客户端,服务端日志里只有正常的 Feed 请求,事后无法证明平台执行了用户的意愿,也无法定位还有哪些路径没有接上判定。
修正方案不是再加几个 if,而是把判定收敛成一个函数,让详情、列表、搜索、通知和导出都调用它,并且把 viewer 身份和关系版本放进缓存 key。前端隐藏可以保留,但只能作为体验优化,不能作为安全边界。
判定的输入不能来自客户端
可见性判断需要四类输入:owner 是谁、viewer 是谁、内容档位是什么、两人之间有哪些关系。owner 和 viewer 应该从服务端会话或令牌里取,不能相信请求参数里的 viewer_id;内容档位应该来自内容记录,不能由客户端传 audience=friends 来覆盖;关系边应该来自关系服务或带版本的缓存,不能由客户端声明“我们是好友”。
看起来这些都是常识,但事故经常出在“为了性能,让客户端把好友列表带上来”。客户端传来的关系无法验证,攻击者可以伪造一条 friend 边,直接绕过后端判断。可见性函数再正确,输入被污染也没有意义。
拉黑之后的通知与未读
拉黑不仅影响内容读取,也影响通知。已经发出的通知收不回来,但新的点赞、评论、关注通知必须在生成或投递时过滤。未读计数如果按关系实时计算,拉黑后要能降下去;如果使用缓存计数,缓存 key 或版本要跟着关系变化。
「回声」的做法是:通知生成时检查一次 block,投递前再检查一次,读者拉取通知列表时用同一个 CanView 过滤。三次检查看起来重复,但分别覆盖了异步延迟、队列积压和读取时关系已变化三种情况。只做其中一次,都会留下一个可见窗口。
用 CanView 把优先级写成顺序
规则写在文档里,下一次改需求就会漂移;写成纯函数,输入是 owner、viewer、关系边和内容档位,输出是布尔值和原因,才能被测试锁定。
下面的代码来自本书配套的 webapp-lab。CanView 的判断顺序就是优先级:本人永远可见;任一方向的拉黑否决一切;public 内容对未拉黑者开放;friends-only 需要双向好友。
// file: internal/social/block.go
// tier: L1
// verify: go test ./internal/social/
package social
// CanView 判断 viewer 能否读取 owner 的内容,并返回可读原因。
// 判断顺序就是优先级:本人永远可见;任一方向的 block 否决一切;
// public 内容对未拉黑者开放;friends-only 需要双向 friend 边。
func CanView(owner, viewer string, edges []Edge, public bool) (bool, string) {
if owner != "" && owner == viewer {
return true, "self"
}
if blocked(edges, owner, viewer) {
return false, "blocked"
}
if public {
return true, "public"
}
if friends(edges, owner, viewer) {
return true, "friend"
}
return false, "friends-only"
}
// blocked 检查两个方向。被拉黑者不能看 owner 的内容,
// 主动拉黑者也不应该继续在信息流里看到对方,两个方向都要拦。
func blocked(edges []Edge, a, b string) bool {
return hasEdge(edges, a, b, Block) || hasEdge(edges, b, a, Block)
}
// friends 要求两个方向都存在 friend 边。单条 friend 边只是半条关系,
// 不能当作好友;follow 也不等于 friend。
func friends(edges []Edge, a, b string) bool {
if a == "" || b == "" || a == b {
return false
}
return hasEdge(edges, a, b, Friend) && hasEdge(edges, b, a, Friend)
}
func hasEdge(edges []Edge, from, to string, kind Kind) bool {
for _, e := range edges {
if e.From == from && e.To == to && e.Kind == kind {
return true
}
}
return false
}
代码里有几个刻意的选择。
- 本人判断要求
owner != ""。如果直接写owner == viewer,匿名请求的空 ID 和空 owner 会相等,未登录用户就被当成 owner 放行。 - 拉黑判断放在 public 之前。公开内容不是“任何人都能看”,而是“除拉黑者外任何人都能看”;顺序反了,公开动态就会绕过拉黑。
blocked同时检查两个方向。无论谁拉黑谁,双方都不应该继续互相看到内容;存储可以是一条边,判定必须覆盖双向。friends要求两个方向都有 friend 边,不接受单条边,也不把 follow 折算成 friend。好友互相关注是 friends-only 的最低条件。- 返回值带一个可读原因,方便日志、埋点和客服排查。原因字符串不是给用户看的错误码,但能回答“为什么这次请求被拒绝”。
代码没有处理内容级屏蔽。屏蔽不应该让 CanView 返回 false,它属于 Feed 组装时的过滤条件;如果把屏蔽塞进这里,用户屏蔽一个人之后连主页都打不开,就变成了拉黑。
测试:拉黑、好友、公开的交叉
可见性 bug 往往出现在组合里:拉黑与好友同时存在、公开内容遇到拉黑、只有单向好友边、匿名访问者。测试要把这些组合固定下来。
下面的测试覆盖四组边界:本人永远可见、拉黑双向压过好友、公开内容对陌生人开放、friends-only 需要双向好友。
// file: internal/social/block_test.go
// tier: L1
// verify: go test ./internal/social/
package social
import "testing"
func TestCanViewSelfAlwaysAllowed(t *testing.T) {
edges := []Edge{
{From: "u2", To: "u1", Kind: Block},
{From: "u2", To: "u1", Kind: Friend},
}
for _, public := range []bool{true, false} {
ok, reason := CanView("u1", "u1", edges, public)
if !ok || reason != "self" {
t.Fatalf("owner view = %v, %q; want true, self", ok, reason)
}
}
}
func TestCanViewBlockBeatsFriendBothDirections(t *testing.T) {
friend := []Edge{
{From: "u1", To: "u2", Kind: Friend},
{From: "u2", To: "u1", Kind: Friend},
}
ownerBlocks := append(append([]Edge{}, friend...), Edge{From: "u1", To: "u2", Kind: Block})
if ok, reason := CanView("u1", "u2", ownerBlocks, true); ok || reason != "blocked" {
t.Fatalf("owner-blocked = %v, %q; want false, blocked", ok, reason)
}
viewerBlocks := append(append([]Edge{}, friend...), Edge{From: "u2", To: "u1", Kind: Block})
if ok, reason := CanView("u1", "u2", viewerBlocks, true); ok || reason != "blocked" {
t.Fatalf("viewer-blocked = %v, %q; want false, blocked", ok, reason)
}
}
func TestCanViewPublicWithoutRelation(t *testing.T) {
ok, reason := CanView("u1", "u3", nil, true)
if !ok || reason != "public" {
t.Fatalf("public stranger = %v, %q; want true, public", ok, reason)
}
}
func TestCanViewFriendsOnlyNeedsMutualFriend(t *testing.T) {
mutual := []Edge{
{From: "u1", To: "u2", Kind: Friend},
{From: "u2", To: "u1", Kind: Friend},
}
if ok, reason := CanView("u1", "u2", mutual, false); !ok || reason != "friend" {
t.Fatalf("mutual friends = %v, %q; want true, friend", ok, reason)
}
half := []Edge{{From: "u1", To: "u2", Kind: Friend}}
if ok, reason := CanView("u1", "u2", half, false); ok || reason != "friends-only" {
t.Fatalf("half friend = %v, %q; want false, friends-only", ok, reason)
}
follow := []Edge{
{From: "u1", To: "u2", Kind: Follow},
{From: "u2", To: "u1", Kind: Follow},
}
if ok, reason := CanView("u1", "u2", follow, false); ok || reason != "friends-only" {
t.Fatalf("mutual follow = %v, %q; want false, friends-only", ok, reason)
}
}
func TestCanViewAnonymousIsNotSelf(t *testing.T) {
ok, reason := CanView("", "", nil, false)
if ok || reason != "friends-only" {
t.Fatalf("empty ids = %v, %q; want false, friends-only", ok, reason)
}
if ok, reason := CanView("u1", "", nil, true); !ok || reason != "public" {
t.Fatalf("anonymous public = %v, %q; want true, public", ok, reason)
}
}
TestCanViewSelfAlwaysAllowed 里特意放了 block 和 friend 边。真实系统不应该允许自己拉黑自己,但读路径不能假设数据永远干净;只要 owner 和 viewer 相同,就应该返回本人可见。
TestCanViewBlockBeatsFriendBothDirections 是最重要的一条。它分别构造“owner 拉黑 viewer”和“viewer 拉黑 owner”两种情况,断言两个方向都返回 blocked。如果实现只检查一个方向,这个用例会立刻失败。
TestCanViewFriendsOnlyNeedsMutualFriend 则把三种似是而非的关系放在一起:双向好友通过,单向好友不通过,互相关注也不通过。它防止实现把 follow 当成 friend,或者把一条 friend 边当成完整好友。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/social/ -run 'TestCanView' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestCanViewSelfAlwaysAllowed
--- PASS: TestCanViewSelfAlwaysAllowed (0.00s)
=== RUN TestCanViewBlockBeatsFriendBothDirections
--- PASS: TestCanViewBlockBeatsFriendBothDirections (0.00s)
=== RUN TestCanViewPublicWithoutRelation
--- PASS: TestCanViewPublicWithoutRelation (0.00s)
=== RUN TestCanViewFriendsOnlyNeedsMutualFriend
--- PASS: TestCanViewFriendsOnlyNeedsMutualFriend (0.00s)
=== RUN TestCanViewAnonymousIsNotSelf
--- PASS: TestCanViewAnonymousIsNotSelf (0.00s)
PASS
ok webapp/internal/social 0.295s
PASS 表示所有可见性用例通过。输出里的耗时只覆盖纯函数判断,不包含数据库查询和缓存访问;线上一次拉黑判定可能还要读关系缓存,延迟要以实际链路为准。
小结
拉黑是关系级的否决,屏蔽是内容级的过滤,可见性是单条内容的受众档位。三者不能互相替代,也不能只在前端实现。CanView 把优先级写成固定顺序:本人、拉黑、公开、好友;拉黑覆盖两个方向,公开内容也不例外。
判断一条读路径是否安全,标准只有一个:它有没有调用同一个可见性函数,并且缓存 key 有没有带上 viewer 身份和关系版本。详情页做了、列表页没做,或者接口做了、缓存没做,都会让拉黑失效。
下一节把视角从“怎么判”转到“怎么查”:关系链一旦到了千万级,我关注谁、谁关注我、共同好友这三个问题的代价完全不同。
阅读导航:上一节:5.1 关注、好友与双向关系 · 下一节:5.3 关系链的存储与查询 。
常见错误与边界
- 只在前端隐藏。响应体里还有对方的内容,抓包或换客户端就能看到;前端隐藏只能作为体验优化。
- 把拉黑实现成取消好友。取消好友只影响 friends-only 内容,公开动态仍然可见,拉黑必须覆盖所有档位。
- 只检查拉黑者到被拉黑者的方向。用户主动拉黑对方后,自己的信息流里仍会出现对方,等于没有拉黑。
- 让公开内容绕过拉黑。判断顺序必须是先拉黑再 public;公开不等于对拉黑者开放。
- 把屏蔽当成拉黑。屏蔽是内容级过滤,不应该让
CanView返回 false,否则会误伤主页和好友关系。 - 把 follow 当成 friend。互相关注不等于好友,friends-only 需要两个方向都有 friend 边。
- 缓存 key 不带 viewer 和关系版本。拉黑后旧缓存会继续返回内容,用户刷新几次又看到对方。
- 只在详情接口做判定。列表、搜索、推荐、通知和导出都必须走同一个函数,否则总有一条路径漏掉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。