《互联网产品的服务端架构·产品、流量与基础能力》4.1 资料模型与可见性

以「回声」个人主页上线三天后的一次手机号字段泄露为入口,拆解公开、好友、自己三档可见性,讨论好友的好友、拉黑之后能否看到历史内容、本人可见与未知档位的边界,给出可测试的 Go 过滤器和表驱动测试,并说明为什么前端隐藏字段不能替代服务端过滤,最后列出落地时的常见错误与审计要求。

一个“个人主页”上线三天后的投诉

「回声」上线个人主页时,产品文档只有一句话:用户可以设置昵称、头像、简介、手机号和最近动态,别人能来看。开发团队按最直接的方式实现:一个 GET /users/{id} 接口返回用户表里除密码以外的所有字段,前端根据“是不是好友”决定显示哪些。

上线第三天,客服收到投诉:一个用户说,陌生人通过抓包看到了自己的手机号。开发的第一反应是“页面上没有显示”,第二反应是“前端确实做了隐藏”。问题恰恰出在这两句话里:页面没显示,不代表响应里没有;前端隐藏,不代表数据没有离开服务端。

团队随后补了一版过滤:如果访问者不是好友,就把手机号字段置空。但新的问题马上出现。用户把某人拉黑后,对方仍然能看到公开头像和简介;用户自己打开主页时,因为“不是自己的好友”,简介也被置空了;另一个接口把“最近动态”的可见性写成“好友的好友”,前端却按“好友”判断,两个端的口径从此对不上。

这些问题的共同点不是代码写错了某一行,而是“可见性”没有被建模成一个独立概念。它被拆散在接口、模板、缓存和前端条件判断里,每一处都只覆盖了自己记得的那一部分。

三档可见性不是三个 if

资料字段的可见性首先要回答“给谁看”,而不是“显不显示”。在「回声」里,团队最后把字段收敛成三档:

档位谁可见典型字段常见误解
public任何已登录或未登录的访问者昵称、头像、主页封面公开等于可以被搜索引擎无限抓取
friends与 owner 存在好友关系的访问者简介、最近动态、共同好友数好友的好友也能看
self只有 owner 本人手机号、邮箱、登录设备管理员也应该默认可见

三档之上还有两个横切规则,它们比档位本身更容易出错。

第一,本人永远可见。这不是“给 owner 一个 self 档位”那么简单,而是无论字段当前是什么档位、无论 owner 有没有被自己拉黑,owner 读取自己的资料都必须成功。否则用户会在自己的主页上看到一片空白,客服也无法解释。

第二,拉黑优先于好友。拉黑不是“取消好友”的同义词:取消好友只影响 friends 档位,拉黑要让被拉黑者在所有档位上都看不到 owner,包括 public。团队一开始只把拉黑实现成“好友关系置为 false”,结果被拉黑者仍然能刷到公开动态,骚扰没有停止。

还有一个容易被忽略的边界:未知档位。配置表里可能出现拼写错误,代码里可能从数据库读到历史遗留值。对未知档位,正确做法是默认不可见,而不是“既然不认识就放行”。这类错误应该尽早暴露,而不是变成一次数据泄露。

好友的好友要不要放开

产品经理曾提出“好友的好友可见简介”,理由是扩大社交发现。这个需求听起来只多一层,实际会把可见性判断从“我和 owner 的关系”变成“我和 owner 的关系图”。服务端需要额外查询二度关系,缓存 key 也要从 viewerID 扩展到关系版本;更麻烦的是,用户很难理解“我的好友的好友能看到什么”。

「回声」最后没有做这个功能,而是把“最近动态”拆成两条路径:好友可见的完整动态,以及所有人可见的公开摘要。产品想要的是曝光,不是关系链推理;用一条公开摘要就能满足,成本远低于二度关系计算。

如果业务确实需要“好友的好友”,至少要满足三个条件:关系图查询有明确的延迟上限;可见性结果可以按关系版本失效;产品能向用户解释清楚。三条缺一条,最后都会变成客服工单。

拉黑之后还能不能看到历史内容

拉黑生效后,被拉黑者是否还能看到拉黑前已经看过的内容?服务端的答案是:已经离开服务端的内容管不了,但新的读取必须立即拒绝。缓存里如果还留着按旧关系生成的响应,就会出现“拉黑后还能刷新出来”的窗口。

因此可见性判断不能只放在写路径,也不能只放在读路径的某一层。它必须出现在最终组装响应的地方,并且缓存 key 要包含 viewer 的身份和关系版本。前端隐藏只能作为体验优化,不能作为安全边界。

失败场景:前端隐藏不等于服务端过滤

回到那次手机号泄露。真正的链路是这样的:客户端请求 /users/u2,服务端返回完整用户对象;前端模板只渲染昵称和头像,手机号躺在 JSON 里;用户抓包后拿到手机号,再配合公开的昵称去其他平台搜索。整个过程没有绕过任何前端逻辑,因为前端从来没有被要求保密。

更严重的一次发生在导出接口。团队为了做“资料下载”,复用了同一个用户对象序列化函数,把内部字段 risk_score 和 operator_note 也带了出去。前端页面不显示这两个字段,测试也只断言了页面文案,没有人检查响应 JSON 的键集合。

这类问题的后果通常不是一次性的。手机号可以被用于撞库和骚扰,内部风控分被用户看到会暴露风控规则,运营备注泄露会直接伤害用户信任。事后补救的成本也高:日志、备份、第三方缓存里可能都已经有了副本,删一个接口字段远远不够。

所以判断标准要写成一句话:只要字段进入了响应体,就必须认为它已经被公开。页面是否渲染、客户端是否展示、用户是否“应该看不到”,都不构成保护。

用 Visible 和 Filter 把规则写死

规则写在文档里,下一次改需求就会漂移。把它写成纯函数,输入是显式的 viewer 和 owner,输出是布尔值或字段名列表,才能被测试锁定。

下面这段代码来自本书配套的 webapp-lab。Visible 先校验档位是否合法,再处理本人和拉黑,最后才按档位判断;Filter 复用同一个判断,保证单字段和列表接口不会出现两套口径。

// file: internal/profile/visibility.go
// tier: L1
// verify: go test ./internal/profile/

package profile

import "sort"

// Audience 是字段对外的可见档位,不是用户角色。
type Audience string

const (
	Public  Audience = "public"
	Friends Audience = "friends"
	Self    Audience = "self"
)

// Viewer 是发起读取的人。IsBlocked 表示 viewer 已拉黑 owner,
// 它优先于好友关系。
type Viewer struct {
	ID        string
	IsFriend  bool
	IsBlocked bool
}

// Visible 判断 viewer 能否读取 owner 的某个档位字段。
// 未知档位是配置错误,对任何人都不可见;已知档位下本人永远可见,
// 拉黑优先于好友。
func Visible(audience Audience, viewer Viewer, ownerID string) bool {
	switch audience {
	case Public, Friends, Self:
	default:
		return false
	}
	if ownerID != "" && viewer.ID == ownerID {
		return true
	}
	if viewer.IsBlocked {
		return false
	}
	switch audience {
	case Public:
		return true
	case Friends:
		return viewer.IsFriend
	default:
		return false
	}
}

// Filter 返回 viewer 可见的字段名,按字段名排序。
// 排序让接口输出稳定,便于缓存、diff 和回归测试。
func Filter(fields map[string]Audience, viewer Viewer, ownerID string) []string {
	names := make([]string, 0, len(fields))
	for name, audience := range fields {
		if Visible(audience, viewer, ownerID) {
			names = append(names, name)
		}
	}
	sort.Strings(names)
	return names
}

这段代码有几个刻意的选择。

  • 未知档位最先返回 false,连 owner 也不例外。这样配置里出现 Audience("team") 时,测试会立刻失败,而不是等某个访问者碰巧触发。把“本人永远可见”理解成“已知档位下本人永远可见”,边界更清楚。
  • ownerID != "" 的判断防止匿名 viewer 和空 owner 意外相等。真实系统里 ID 不应该为空,但入口处多一个判断比事后排查便宜。
  • 拉黑判断放在档位判断之前,所以被拉黑者连 public 字段也读不到。这是一个产品决策,不是唯一答案;重要的是把它写进代码和测试,而不是留给每个接口自己解释。
  • Filter 返回排序后的字段名,而不是 map。map 的遍历顺序不稳定,会让缓存、快照 diff 和接口测试出现随机失败;排序是最便宜的稳定化手段。

代码没有解决的问题也要说清楚:它只判断“这个字段能不能给这个 viewer 看”,不判断“这个 viewer 是否应该知道 owner 存在”。后者涉及搜索、推荐和反骚扰,需要在上层接口再做一次可见性判断。

测试:边界要比产品文档多问一步

产品文档通常只写“好友可见”,不会写“被拉黑的好友”、“本人被自己拉黑”、“未知档位”这些情况。测试的价值就是把这些问题提前问出来,并固定成可回归的用例。

下面的测试覆盖三组边界:档位与 viewer 的组合、拉黑与本人的优先级、Filter 的排序和空输入。

// file: internal/profile/visibility_test.go
// tier: L1
// verify: go test ./internal/profile/

package profile

import (
	"reflect"
	"testing"
)

func TestVisibleFollowsAudience(t *testing.T) {
	owner := Viewer{ID: "u1"}
	friend := Viewer{ID: "u2", IsFriend: true}
	stranger := Viewer{ID: "u3"}

	cases := []struct {
		name     string
		audience Audience
		viewer   Viewer
		want     bool
	}{
		{"public-stranger", Public, stranger, true},
		{"friends-friend", Friends, friend, true},
		{"friends-stranger", Friends, stranger, false},
		{"self-owner", Self, owner, true},
		{"self-friend", Self, friend, false},
		{"unknown-audience", Audience("team"), friend, false},
		{"unknown-audience-owner", Audience("team"), owner, false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := Visible(c.audience, c.viewer, "u1"); got != c.want {
				t.Fatalf("Visible(%q) = %v, want %v", c.audience, got, c.want)
			}
		})
	}
}

func TestVisibleBlockBeatsFriendButNotOwner(t *testing.T) {
	blockedFriend := Viewer{ID: "u2", IsFriend: true, IsBlocked: true}
	if Visible(Public, blockedFriend, "u1") {
		t.Fatal("blocked viewer must not see public fields")
	}
	if Visible(Friends, blockedFriend, "u1") {
		t.Fatal("blocked viewer must not see friends fields")
	}

	blockedOwner := Viewer{ID: "u1", IsBlocked: true}
	if !Visible(Self, blockedOwner, "u1") {
		t.Fatal("owner must always see own fields")
	}
	if Visible(Self, Viewer{IsBlocked: true}, "u1") {
		t.Fatal("anonymous blocked viewer must not see self fields")
	}
}

func TestFilterSortsAndDropsInvisible(t *testing.T) {
	fields := map[string]Audience{
		"phone":    Self,
		"bio":      Friends,
		"avatar":   Public,
		"location": Public,
		"internal": Audience("team"),
	}

	got := Filter(fields, Viewer{ID: "u3"}, "u1")
	want := []string{"avatar", "location"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("stranger filter = %v, want %v", got, want)
	}

	got = Filter(fields, Viewer{ID: "u2", IsFriend: true}, "u1")
	want = []string{"avatar", "bio", "location"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("friend filter = %v, want %v", got, want)
	}

	got = Filter(fields, Viewer{ID: "u1"}, "u1")
	want = []string{"avatar", "bio", "location", "phone"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("owner filter = %v, want %v", got, want)
	}

	got = Filter(fields, Viewer{ID: "u4", IsBlocked: true}, "u1")
	if len(got) != 0 {
		t.Fatalf("blocked filter = %v, want empty", got)
	}
}

func TestFilterEmptyMapIsNotNil(t *testing.T) {
	got := Filter(nil, Viewer{ID: "u3"}, "u1")
	if got == nil {
		t.Fatal("Filter returned nil slice")
	}
	if len(got) != 0 {
		t.Fatalf("len = %d, want 0", len(got))
	}
}

测试里最值得看的是 unknown-audience-owner。很多实现会把“本人永远可见”写成最前面的无条件返回,结果连未知档位也放行;这个用例会把那种实现挡在门外。另一个是 TestVisibleBlockBeatsFriendButNotOwner:它同时验证拉黑压过好友,以及 owner 即使处在“被拉黑”的 viewer 结构里也仍然可见。

TestFilterEmptyMapIsNotNil 看起来吹毛求疵,但它防的是接口层常见的事故:空结果返回 null,客户端把它当成“没有权限”而不是“没有字段”,于是弹出错误提示。返回非 nil 的空切片可以让契约更稳定。

测试没有覆盖真实的关系查询、缓存失效和并发更新。这些属于集成测试和线上验证的范围,不能用单元测试替代。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/profile/ -run 'TestVisible|TestFilter' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestVisibleFollowsAudience
=== RUN   TestVisibleFollowsAudience/public-stranger
=== RUN   TestVisibleFollowsAudience/friends-friend
=== RUN   TestVisibleFollowsAudience/friends-stranger
=== RUN   TestVisibleFollowsAudience/self-owner
=== RUN   TestVisibleFollowsAudience/self-friend
=== RUN   TestVisibleFollowsAudience/unknown-audience
=== RUN   TestVisibleFollowsAudience/unknown-audience-owner
--- PASS: TestVisibleFollowsAudience (0.00s)
    --- PASS: TestVisibleFollowsAudience/public-stranger (0.00s)
    --- PASS: TestVisibleFollowsAudience/friends-friend (0.00s)
    --- PASS: TestVisibleFollowsAudience/friends-stranger (0.00s)
    --- PASS: TestVisibleFollowsAudience/self-owner (0.00s)
    --- PASS: TestVisibleFollowsAudience/self-friend (0.00s)
    --- PASS: TestVisibleFollowsAudience/unknown-audience (0.00s)
    --- PASS: TestVisibleFollowsAudience/unknown-audience-owner (0.00s)
=== RUN   TestVisibleBlockBeatsFriendButNotOwner
--- PASS: TestVisibleBlockBeatsFriendButNotOwner (0.00s)
=== RUN   TestFilterSortsAndDropsInvisible
--- PASS: TestFilterSortsAndDropsInvisible (0.00s)
=== RUN   TestFilterEmptyMapIsNotNil
--- PASS: TestFilterEmptyMapIsNotNil (0.00s)
PASS
ok  	webapp/internal/profile	0.240s

PASS 表示过滤规则全部通过,ok 行末尾是本次运行的包路径和耗时。输出里的 0.00s 是用例耗时,精确到两位小数,不代表接口的线上延迟。

小结

可见性不是模板里的 if,而是资料模型的一部分。三档可见性回答“给谁看”,本人可见和拉黑优先回答“例外怎么处理”,未知档位默认拒绝回答“配置错了怎么办”。把这三层写进一个纯函数,再让单字段接口和列表接口共用它,才能避免前端、后端和缓存各说各话。

判断一条字段是否安全,只看一个事实:它有没有进入响应体。页面不渲染、客户端不展示、用户“应该看不到”,都不是保护。可见性判断必须发生在服务端组装响应的最后一公里,并且缓存 key 要带上 viewer 身份和关系版本。

下一节会把视角从“谁能看”转到“该不该收”:同样是手机号,注册、推荐、客服和风控需要它的理由并不相同。

阅读导航:上一节:目录与学习路径 · 下一节:4.2 个人信息分级与最小化 。

常见错误与边界

  • 把前端隐藏当服务端过滤。响应体里有的字段就等于已经公开,抓包和导出接口都不会遵守前端的显示规则。
  • 把拉黑实现成“取消好友”。取消好友只影响 friends 档位,拉黑要让对方在所有档位上都看不到 owner。
  • 让未知档位默认放行。配置拼写错误或历史遗留值会直接变成数据泄露,默认拒绝才能让错误尽早暴露。
  • 只在一个接口做过滤。详情页过滤了,列表页、搜索页、导出接口和消息推送仍可能复用完整对象。
  • 缓存 key 不带 viewer 身份。按用户维度缓存的资料,如果 key 只有 ownerID,第二个访问者会拿到第一个人的可见结果。
  • 把“本人可见”写成无条件返回。这样未知档位也会对 owner 放行,配置错误被掩盖。
  • 把好友关系当成静态字段。拉黑、删好友、关系版本变化后,旧缓存和旧 token 都要能失效。
  • 忽略字段级审计。至少要能回答“谁在什么时候通过哪个接口看到了手机号”,否则泄露发生后无法界定范围。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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