「回声」的好友功能上线后,删除好友没有生效
「回声」最早只有关注:点一下按钮,对方出现在你的关注列表里,不需要对方同意。后来产品要增强熟人之间的内容分发,加了“好友”功能:双方互相确认后,可以看到仅好友开放的动态、相册和共同好友数。
上线两周后,客服转来一条投诉:用户 A 说,自己已经把 B 删除好友,但 B 仍然能在“好友动态”里看到自己的内容。开发查了日志,A 的删除请求成功了,A 这边的界面上也确实显示“已不是好友”。问题出在数据库里:好友关系是用两条单向记录表示的,删除请求只删掉了 A 发起的那一条,B 发起的那一条还在。
这条投诉把两个概念摆到了台面上。第一,关注和好友不是同一种关系,不能只用一个 is_friend 字段区分。第二,好友不是一条边的属性,而是两条边共同构成的状态。只删一边,关系就处在“半条”状态;读路径只要有一处按单边判断,隐私就会漏出去。
关注是单向,好友是双向
把关系还原成有方向的边之后,很多争论会变得具体。关注是 a -> b 一条边,好友是 a -> b 和 b -> a 两条边,拉黑也是一条边,但它否决的不是某个方向,而是两个方向上的可见性。三者的区别可以放进一张表:
| 关系 | 存储形状 | 单向是否成立 | 删除语义 | 常见误用 |
|---|---|---|---|---|
| 关注 follow | 一条 from -> to | 成立 | 只删这一条 | 把关注当成好友,给关注者开放好友内容 |
| 好友 friend | 两个方向各一条 | 不成立 | 必须同事务删两条 | 只删当前用户发起的那条 |
| 拉黑 block | 一条 from -> to | 成立,但否决双向 | 删除后恢复普通可见性 | 把拉黑实现成“取消好友” |
表里最容易被忽略的是“单向是否成立”这一列。关注单向成立,所以 a 关注 b 不要求 b 关注 a;好友不成立,所以只看到 a -> b 的 friend 边时,只能认为这是半条关系,不能回答“他们是好友”。如果代码把它们塞进同一张表、再配一个布尔字段,读路径就很容易把半条好友当成整条。
关注之后,对方要不要知道
产品评审里经常出现这个问题。有人说“关注是公开的,对方应该收到通知”,有人说“要允许悄悄关注”。从服务端角度看,这两句话不冲突:关系本身是一条边,通知是另一个事件。边是否写入、是否发通知、通知里是否展示关注者,是三个独立决策。
「回声」最后把关注通知做成了可配置项:默认通知,用户可以关闭。关闭通知不会改变 follow 边,也不会把关系变成“不可见关注”。这样查询逻辑只需要回答“有没有这条边”,不需要再判断“这条边是不是秘密的”。把隐私偏好编码进关系类型,会让后续每一条查询都多一个分支,而且很容易漏。
好友请求被拒之后还能不能看到
好友请求是第三个状态,不是 friend 边。请求 pending 时,双方最多只有关注关系;对方拒绝后,请求记录关闭,不能留下任何 friend 边。被拒的一方如果之前关注了你,仍然可以看到公开内容,但看不到 friends-only 内容,因为他没有通过双向好友检查。
这里还有一个常见误解:被拒之后“还能不能看到对方主页”。主页本身通常是公开的,能不能看到主页和能不能看到好友内容是两个问题。服务端应该把“主页可见”和“字段可见”分开,前者由账号状态和拉黑决定,后者由好友关系决定。把两者混成一个接口返回值,前端就会在“主页能打开但内容为空”时显示错误提示。
失败场景:删除好友只删了一条边
回到那次投诉。假设 A 和 B 在 2026 年 3 月成为好友,双方各有两条 friend 边。4 月 B 提出删除好友,客户端调用删除接口,服务端执行:
DELETE FROM relation WHERE from_id = 'B' AND to_id = 'A' AND kind = 'friend';
这条语句本身没错,错在它只覆盖了一个方向。A 发起的 A -> B 仍然存在,而 A 的动态列表查询写的是“只要我关注或好友的人发布的内容就展示”,判断条件是 EXISTS (SELECT 1 FROM relation WHERE from_id = 'A' AND kind IN ('follow','friend'))。于是 A 仍然把 B 放进候选集,friends-only 字段又因为“存在 friend 边”被放行。
后果不是一条动态的问题。B 删除好友通常意味着不想再让对方看到私人内容,但半条边让这个意图失效;如果 B 之后又拉黑了 A,而拉黑只写了一条边、读路径只检查一个方向,A 还可能继续看到公开内容。客服能做的只有重置关系、清理缓存,但已经暴露的内容收不回来。
正确的做法有两个层面:写入时,删除好友必须在同一个事务里删掉两个方向,或者把好友关系建模成一条无向记录加两个成员;读取时,好友判断必须同时检查两个方向,不能依赖“我这边有边”就认定对方也同意。两层都做,才能既保证数据一致,又让读路径不依赖写入顺序。
用 Edge 和 Validate 固定关系边的形状
规则如果只写在文档里,下一次改需求就会漂移。把关系边写成结构体,把合法性写进入口函数,才能让错误在写入时暴露,而不是在查询时变成一个隐私漏洞。
下面的代码来自本书配套的 webapp-lab。Validate 拒绝空 ID、自环和未知类型;Mutual 把“好友要双向、关注单向即可、拉黑不构成关系”写成可测试的判定。
// file: internal/social/relation.go
// tier: L1
// verify: go test ./internal/social/
package social
import "fmt"
// Kind 是关系边的类型。follow 是单向关注,friend 是双向好友,
// block 是单向拉黑。三者的读写语义不同,不能压缩成用户表上的一个字段。
type Kind string
const (
Follow Kind = "follow"
Friend Kind = "friend"
Block Kind = "block"
)
// Edge 是一条有方向的关系记录。From 发起关系,To 承接关系。
// 一条 friend 边只代表半条好友关系,完整好友需要两个方向各有一条。
type Edge struct {
From string
To string
Kind Kind
}
// Validate 检查一条关系边能否写入。自环、空 ID 和未知 Kind
// 必须在入口拒绝,不能留给查询层去猜。
func Validate(e Edge) error {
if e.From == "" || e.To == "" {
return fmt.Errorf("social: edge has empty id: %q -> %q", e.From, e.To)
}
if e.From == e.To {
return fmt.Errorf("social: self edge on %q", e.From)
}
switch e.Kind {
case Follow, Friend, Block:
return nil
default:
return fmt.Errorf("social: unknown kind %q", e.Kind)
}
}
// Mutual 判断 a 与 b 之间是否存在可确认的关系。
// friend 必须双向都有 friend 边;follow 单向即可;block 不构成关系,
// 它是否否决访问由 CanView 负责,不在这里混入。
func Mutual(edges []Edge, a, b string) bool {
if a == "" || b == "" || a == b {
return false
}
var abFriend, baFriend, abFollow, baFollow bool
for _, e := range edges {
switch {
case e.From == a && e.To == b:
switch e.Kind {
case Friend:
abFriend = true
case Follow:
abFollow = true
}
case e.From == b && e.To == a:
switch e.Kind {
case Friend:
baFriend = true
case Follow:
baFollow = true
}
}
}
if abFriend && baFriend {
return true
}
return abFollow || baFollow
}
代码里有几个刻意的选择。
Kind是字符串类型而不是整数枚举,日志和数据库里都能直接读出含义。代价是占用空间略大,但关系类型只有三种,可读性更值钱。Validate先检查空 ID,再检查自环,最后检查类型。顺序不影响结果,但错误信息会告诉调用方到底错在哪一层。Mutual不把 block 当成“负关系”参与布尔运算。block 的语义是“否决访问”,它属于可见性判断,不属于关系是否存在。把两者混在一起,会出现“拉黑后反而算出关系”的奇怪结果。- follow 只要有一个方向就返回 true,因为
Mutual回答的是“a 与 b 之间是否存在可确认的关系”,而不是“双方是否互相关注”。调用方如果要做 friends-only 判断,应该用CanView,不要拿Mutual代替。
这段代码只负责关系边的形状和最基本的互相确认,不负责权限、缓存和通知。它刻意保持纯函数,输入是切片,输出是布尔值或错误,方便在单元测试里穷举边界。
测试:把“半条好友关系”钉成用例
关系建模的错误往往出现在组合里:一条 friend 边、重复的 follow 边、指向自己的边、未知的 kind。测试不需要等线上事故来发现它们,可以把这些组合提前写成表驱动用例。
下面的测试覆盖三组边界:合法与非法边、好友与关注的方向性、拉黑和重复边对 Mutual 的影响。
// file: internal/social/relation_test.go
// tier: L1
// verify: go test ./internal/social/
package social
import "testing"
func TestValidateAcceptsKnownKinds(t *testing.T) {
cases := []Edge{
{From: "u1", To: "u2", Kind: Follow},
{From: "u1", To: "u2", Kind: Friend},
{From: "u1", To: "u2", Kind: Block},
}
for _, e := range cases {
if err := Validate(e); err != nil {
t.Fatalf("Validate(%+v) = %v, want nil", e, err)
}
}
}
func TestValidateRejectsBadEdges(t *testing.T) {
cases := []struct {
name string
edge Edge
}{
{"empty-from", Edge{To: "u2", Kind: Follow}},
{"empty-to", Edge{From: "u1", Kind: Follow}},
{"self-loop", Edge{From: "u1", To: "u1", Kind: Friend}},
{"unknown-kind", Edge{From: "u1", To: "u2", Kind: Kind("mute")}},
{"empty-kind", Edge{From: "u1", To: "u2"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := Validate(c.edge); err == nil {
t.Fatalf("Validate(%+v) = nil, want error", c.edge)
}
})
}
}
func TestMutualFriendNeedsBothDirections(t *testing.T) {
oneWay := []Edge{{From: "u1", To: "u2", Kind: Friend}}
if Mutual(oneWay, "u1", "u2") {
t.Fatal("one-sided friend edge must not be mutual")
}
both := []Edge{
{From: "u1", To: "u2", Kind: Friend},
{From: "u2", To: "u1", Kind: Friend},
}
if !Mutual(both, "u1", "u2") {
t.Fatal("two-sided friend edges must be mutual")
}
if !Mutual(both, "u2", "u1") {
t.Fatal("Mutual must be symmetric for friends")
}
}
func TestMutualFollowIsOneWay(t *testing.T) {
edges := []Edge{{From: "u1", To: "u2", Kind: Follow}}
if !Mutual(edges, "u1", "u2") {
t.Fatal("single follow edge must be enough")
}
if !Mutual(edges, "u2", "u1") {
t.Fatal("relation existence is symmetric for the query")
}
}
func TestMutualIgnoresBlockAndDuplicates(t *testing.T) {
edges := []Edge{
{From: "u1", To: "u2", Kind: Block},
{From: "u2", To: "u1", Kind: Block},
}
if Mutual(edges, "u1", "u2") {
t.Fatal("block edges must not create a mutual relation")
}
dupes := []Edge{
{From: "u1", To: "u2", Kind: Follow},
{From: "u1", To: "u2", Kind: Follow},
{From: "u1", To: "u2", Kind: Follow},
}
if !Mutual(dupes, "u1", "u2") {
t.Fatal("duplicate follow edges must remain a valid relation")
}
}
func TestMutualRejectsEmptyAndSelf(t *testing.T) {
edges := []Edge{{From: "u1", To: "u1", Kind: Follow}}
if Mutual(edges, "u1", "u1") {
t.Fatal("self relation must not be mutual")
}
if Mutual(edges, "", "u1") {
t.Fatal("empty id must not be mutual")
}
if Mutual(nil, "u1", "u2") {
t.Fatal("nil edges must not be mutual")
}
}
最值得看的是 TestMutualFriendNeedsBothDirections。它先喂一条 friend 边,断言不能算好友;再补上反向边,断言两个查询方向都成立。这个用例能挡住“看到一条边就放行”的实现。
TestMutualFollowIsOneWay 验证关注的方向语义:一条 u1 -> u2 的 follow 边足以让 Mutual(u1, u2) 为真,但反过来查询时,Mutual 回答的是“这两个人之间有没有关系”,因此同样为真。它不回答“u2 是否关注 u1”,那个问题应该用 Following 查询。函数名容易让人误会,测试和注释要把边界写清楚。
TestMutualIgnoresBlockAndDuplicates 则固定了 block 不参与关系确认的行为。拉黑是否否决访问由下一节的 CanView 负责;如果在这里也处理 block,两个函数就会出现两套优先级,迟早对不上。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/social/ -run 'TestValidate|TestMutual' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsKnownKinds
--- PASS: TestValidateAcceptsKnownKinds (0.00s)
=== RUN TestValidateRejectsBadEdges
=== RUN TestValidateRejectsBadEdges/empty-from
=== RUN TestValidateRejectsBadEdges/empty-to
=== RUN TestValidateRejectsBadEdges/self-loop
=== RUN TestValidateRejectsBadEdges/unknown-kind
=== RUN TestValidateRejectsBadEdges/empty-kind
--- PASS: TestValidateRejectsBadEdges (0.00s)
--- PASS: TestValidateRejectsBadEdges/empty-from (0.00s)
--- PASS: TestValidateRejectsBadEdges/empty-to (0.00s)
--- PASS: TestValidateRejectsBadEdges/self-loop (0.00s)
--- PASS: TestValidateRejectsBadEdges/unknown-kind (0.00s)
--- PASS: TestValidateRejectsBadEdges/empty-kind (0.00s)
=== RUN TestMutualFriendNeedsBothDirections
--- PASS: TestMutualFriendNeedsBothDirections (0.00s)
=== RUN TestMutualFollowIsOneWay
--- PASS: TestMutualFollowIsOneWay (0.00s)
=== RUN TestMutualIgnoresBlockAndDuplicates
--- PASS: TestMutualIgnoresBlockAndDuplicates (0.00s)
=== RUN TestMutualRejectsEmptyAndSelf
--- PASS: TestMutualRejectsEmptyAndSelf (0.00s)
PASS
ok webapp/internal/social 0.352s
PASS 表示所有关系形状用例通过,ok 行末尾是包路径和本次耗时。输出里的 0.00s 是用例耗时,不是数据库写入延迟;真实系统的关系写入还涉及事务、唯一索引和缓存失效,不能拿单元测试耗时估算。
小结
关注和好友不是同一个表的两个字段,而是两种不同形状的边。关注单向成立,好友必须双向,拉黑是一种否决关系而不是“取消好友”。把这三件事写进 Kind、Edge 和 Validate,再让 Mutual 只回答“关系是否存在”,读路径就不会因为一条半截边而放行。
删除好友时只删一条边,是这类系统最典型的隐私事故。写入要在事务里处理两个方向,读取要同时检查两个方向,两层都做才能让“删除”这个动作真正生效。
下一节讨论拉黑与可见性:同样是“看不到”,拉黑、屏蔽和 friends-only 的判定顺序完全不同。
阅读导航:上一节:目录与学习路径 · 下一节:5.2 拉黑、屏蔽与可见性判定 。
常见错误与边界
- 把关注和好友放进同一张表,只加一个
is_friend布尔字段。关注边和好友边的方向语义不同,混在一起后每个查询都要猜。 - 好友只写一条边。单条 friend 边只是半条关系,读路径一旦按单边判断,就会把对方没同意的好友内容放出去。
- 删除好友只删当前用户发起的那条。必须在同一事务里处理两个方向,否则会留下“半条好友”状态。
- 用
Mutual判断 friends-only。Mutual对单向关注也返回 true,权限判断应该用CanView或显式的双向好友检查。 - 把“悄悄关注”编码成新的 kind。通知偏好是事件层配置,关系边只需要回答有没有关注,混进类型会让所有查询多一个分支。
- 把好友请求当成 friend 边。pending 和 accepted 是两个状态,请求被拒后不能留下任何 friend 边。
- 只在写入时校验边。历史数据、批量导入和后台脚本都可能绕过入口,读路径也要能容忍半条边和重复边。
- 忽略关系版本。删除好友、拉黑和重新加好友都会改变可见性,缓存 key 不带关系版本就会继续返回旧结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。