《互联网产品的服务端架构·内容、社交与实时互动》5.2 拉黑、屏蔽与可见性判定

从「回声」一次拉黑只在前端生效的事故写起:用户拉黑骚扰者后,换个客户端仍能看到对方内容。文章把拉黑、屏蔽与可见性拆成不同层次,给出 CanView 的判定顺序、可读原因、拉黑压过好友的实现、边界测试与真实输出,并说明为什么可见性必须在服务端最终响应处统一生效。

拉黑之后,换一个客户端还能看到

「回声」的拉黑功能上线时,产品描述只有一句:用户可以把不想看到的人加入黑名单。移动端实现得很快:拉黑后从本地列表移除,个人主页显示“已拉黑”,动态流里不再出现对方的内容。上线一周后,一位用户反馈:拉黑之后,对方仍然能在网页端看到自己刚发布的动态。

开发的第一反应是“移动端已经隐藏了”。但网页端走的是同一套 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 和关系版本。拉黑后旧缓存会继续返回内容,用户刷新几次又看到对方。
  • 只在详情接口做判定。列表、搜索、推荐、通知和导出都必须走同一个函数,否则总有一条路径漏掉。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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