《互联网产品的服务端架构·产品、流量与基础能力》4.2 个人信息分级与最小化

从「回声」注册页多收的一个手机号说起,说明公开、内部、敏感三级字段如何按注册、推荐、客服、风控四个目的裁剪,给出保持输入顺序、拒绝负数保留期的 Go 最小化函数与测试,并拆解全量采集在注销、日志、第三方 SDK 和备份上留下的实际代价与审计缺口。

注册表单上的“以后可能有用”

「回声」准备做推荐功能时,产品经理在注册页加了三项:手机号、真实姓名、所在城市。理由很朴素:推荐需要城市,实名有利于社区治理,手机号“以后可能有用”。三项都不是必填,但默认展开,用户顺手就填了。

三个月后,推荐策略换成基于行为的协同过滤,城市权重被降到接近零;实名信息只在一次社区活动里用过;手机号则被复制到了短信、客服、风控三条链路。团队想清理,却发现没人能说清哪个字段是为哪个目的收集的。

更麻烦的是注销请求。用户要求删除账号时,客服只能删用户表里的行,但手机号还留在短信发送日志、客服工单附件和第三方分析 SDK 的事件里。团队被问到“你们为什么收集真实姓名”时,答案只有一句“当时觉得以后可能有用”。

这句话不是错误,而是没有决策。个人信息的最小化,不是等用户来注销时再删,而是在收集的那一刻回答:这个目的真的需要这个字段吗?

分级与目的绑定

「回声」最后把资料字段分成三级,并把“目的”作为收集的入口参数。分级回答“这个字段有多敏感”,目的回答“为什么现在需要它”。两者交叉,才决定字段是否保留。

分级定义典型字段泄露后的主要后果
public用户主动公开、可被任何人看到昵称、头像、主页封面骚扰、冒充,但通常不涉及身份
internal服务内部使用、不直接展示给用户风控分、运营备注、设备指纹暴露规则,帮助绕过风控
sensitive可识别到具体自然人或联系方式手机号、邮箱、真实姓名、地址、证件号撞库、诈骗、身份盗用

分级不是敏感度排行榜,而是处理方式的分类。public 字段仍要考虑抓取和冒充;internal 字段不能因为“用户看不到”就放松访问控制;sensitive 字段则需要目的、保留期和审计三件事同时成立。

目的与字段的对照,建议直接写成一张表,并让代码读同一张表:

目的业务动作需要的字段允许的分级不应顺手带上
register创建账号、登录、找回昵称、手机号、邮箱public、sensitive真实姓名、详细地址
recommend生成推荐结果昵称、头像、行为标签public、internal手机号、邮箱
support处理工单、核对身份昵称、订单号、脱敏手机号public、internal、sensitive完整证件号、支付密码
risk识别盗号、刷单、欺诈设备指纹、登录 IP、手机号public、internal、sensitive营销偏好、聊天内容

这张表的关键不是字段名单,而是最后一列。团队在评审时最容易问“这些字段够不够”,很少问“哪些字段不该出现在这里”。最小化的收益往往来自后者:推荐链路少拿一个手机号,就少一条泄露路径。

最小化不是删字段

很多团队把最小化理解成“把不用的字段删掉”。真正的最小化是按目的裁剪:同一个手机号,注册需要、客服需要、风控需要,推荐不需要;同一个真实姓名,实名认证需要,推荐和客服都不需要。

因此 Minimize 的输入是字段列表和目的,输出是这次目的下应该保留的字段子集。它不修改原始字段定义,也不删除数据库列;它决定的是“这次业务动作能拿到什么”。

保留期也是字段的一部分

一个字段只有分级和目的还不够,还要有保留期。保留期回答“这个目的结束后,字段还能留多久”。注册时的手机号可能保留到账号注销后 30 天,风控设备指纹可能保留 180 天,客服工单里的脱敏手机号可能保留 90 天。

保留期必须是正数或零,负数没有业务含义,通常是配置写错或上游传错。对负数直接报错,比默默当成“永久保留”安全得多。

失败场景:全量采集后无法解释用途

「回声」的真实事故发生在一次数据导出。运营同学为了做用户画像,把注册表全量导出到分析平台,里面有手机号、真实姓名和城市。分析平台默认把文件放在共享目录,权限是“公司内可读”。两周后,一名离职员工的账号仍在共享目录的访问列表里。

事故暴露的不是某个权限配置,而是三个更早的决定:

  • 注册页收集了推荐并不需要的真实姓名,理由是“以后可能有用”。
  • 导出时没有按目的裁剪,因为字段定义里没有“目的”这个维度。
  • 日志和第三方 SDK 各自保存了一份手机号,注销时没人知道要清理几处。

后果是多重的。对用户,手机号和真实姓名同时泄露,风险高于单一字段;对团队,无法证明这些字段的收集有明确目的,面对监管问询只能被动;对工程,清理范围无法界定,因为字段副本散落在数据库、日志、备份、消息队列和第三方系统里。

还有一个更隐蔽的失败:为了“方便排查”,接口把请求体原样打进日志。手机号、邮箱、证件号就这样进入了日志系统,而日志的保留期通常比业务数据更长。等用户来注销时,业务表删了,日志还在。

判断标准可以写成一句话:如果一个字段说不出“为哪个目的、保留多久”,它就不应该被收集。这句话对数据库字段、日志字段和 SDK 事件同样成立。

用 Minimize 把目的写进代码

把目的做成显式参数,有两个直接好处:新功能必须声明目的,否则调用方无法通过编译;评审时可以对着目的表检查字段,而不是凭记忆。

下面这段代码来自 webapp-lab。Minimize 先校验每个字段的保留期和分级,再按目的查允许的分级表;空目的只保留 public 和 internal,未知目的直接报错。

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

package profile

import "fmt"

// Class 是个人信息的分级。分级决定字段在某个目的下是否保留。
type Class string

const (
	ClassPublic    Class = "public"
	ClassInternal  Class = "internal"
	ClassSensitive Class = "sensitive"
)

// Field 是一个待裁剪的资料字段。RetainDays 是业务承诺的保留天数。
type Field struct {
	Name       string
	Class      Class
	RetainDays int
}

// Purpose 是收集字段的业务目的。没有目的就没有默认采集权。
type Purpose string

const (
	PurposeRegister  Purpose = "register"
	PurposeRecommend Purpose = "recommend"
	PurposeSupport   Purpose = "support"
	PurposeRisk      Purpose = "risk"
)

// purposeClasses 是目的与分级的对照表。推荐只需要公开和内部字段,
// 注册、客服、风控才允许触碰敏感字段。
var purposeClasses = map[Purpose]map[Class]bool{
	PurposeRegister:  {ClassPublic: true, ClassInternal: true, ClassSensitive: true},
	PurposeRecommend: {ClassPublic: true, ClassInternal: true},
	PurposeSupport:   {ClassPublic: true, ClassInternal: true, ClassSensitive: true},
	PurposeRisk:      {ClassPublic: true, ClassInternal: true, ClassSensitive: true},
}

// Minimize 按目的裁剪字段,保持输入顺序。空目的只保留公开和内部字段;
// 未知目的、未知分级和负数保留期都返回错误。
func Minimize(fields []Field, purpose Purpose) ([]Field, error) {
	for _, f := range fields {
		if f.RetainDays < 0 {
			return nil, fmt.Errorf("profile: field %q has negative retain days", f.Name)
		}
		switch f.Class {
		case ClassPublic, ClassInternal, ClassSensitive:
		default:
			return nil, fmt.Errorf("profile: field %q has unknown class %q", f.Name, f.Class)
		}
	}

	allowed, ok := purposeClasses[purpose]
	if !ok {
		if purpose != "" {
			return nil, fmt.Errorf("profile: unknown purpose %q", purpose)
		}
		allowed = map[Class]bool{ClassPublic: true, ClassInternal: true}
	}

	out := make([]Field, 0, len(fields))
	for _, f := range fields {
		if allowed[f.Class] {
			out = append(out, f)
		}
	}
	return out, nil
}

这段代码有几个值得说明的边界。

  • purposeClasses 是包内变量,不是调用方传入的 map。目的表属于策略,应该由代码评审和版本控制管理,而不是让每个服务自己解释。
  • 空目的不是错误,但只能拿到 public 和 internal。这给“还没有想清楚目的”的调用方一个安全默认值:可以继续做非敏感功能,但拿不到手机号和邮箱。
  • 未知目的直接报错。新增“广告投放”这样的目的时,必须显式修改策略表,不能靠传一个字符串悄悄拿到全部字段。
  • 保留期和分级在裁剪前统一校验。这样即使某个字段最后因为目的被裁掉,配置错误也会暴露,而不是被“反正没用到”掩盖。
  • 输出保持输入顺序,方便调用方做稳定 diff;返回新切片,不修改输入,避免上游复用同一个字段列表时互相污染。

代码没有解决“字段副本”的问题。它只能约束调用方拿到什么,不能阻止有人绕过它直接查表、直接打日志、直接把请求体发给第三方 SDK。要覆盖这些路径,还需要在数据访问层、日志脱敏和出站网关各设一道检查。

测试:把目的表和错误输入都固定下来

目的表最容易被“临时加一个字段”改坏。测试要覆盖三类情况:推荐必须裁掉敏感字段、注册必须保持输入顺序、非法输入必须报错。

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

package profile

import (
	"reflect"
	"testing"
)

func testFields() []Field {
	return []Field{
		{Name: "nickname", Class: ClassPublic, RetainDays: 365},
		{Name: "email", Class: ClassSensitive, RetainDays: 365},
		{Name: "risk_score", Class: ClassInternal, RetainDays: 180},
		{Name: "phone", Class: ClassSensitive, RetainDays: 365},
	}
}

func TestMinimizeCropsSensitiveForRecommend(t *testing.T) {
	got, err := Minimize(testFields(), PurposeRecommend)
	if err != nil {
		t.Fatal(err)
	}
	want := []Field{
		{Name: "nickname", Class: ClassPublic, RetainDays: 365},
		{Name: "risk_score", Class: ClassInternal, RetainDays: 180},
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("recommend = %+v, want %+v", got, want)
	}
}

func TestMinimizeKeepsOrderAndSensitiveWithPurpose(t *testing.T) {
	got, err := Minimize(testFields(), PurposeRegister)
	if err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(got, testFields()) {
		t.Fatalf("register = %+v, want all fields in input order", got)
	}
}

func TestMinimizeEmptyPurposeDropsSensitive(t *testing.T) {
	got, err := Minimize(testFields(), "")
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 || got[0].Name != "nickname" || got[1].Name != "risk_score" {
		t.Fatalf("empty purpose = %+v, want public and internal only", got)
	}
}

func TestMinimizeRejectsBadInput(t *testing.T) {
	if _, err := Minimize([]Field{{Name: "phone", Class: ClassSensitive, RetainDays: -1}}, PurposeSupport); err == nil {
		t.Fatal("want error for negative retain days")
	}
	if _, err := Minimize([]Field{{Name: "x", Class: Class("secret"), RetainDays: 1}}, PurposeSupport); err == nil {
		t.Fatal("want error for unknown class")
	}
	if _, err := Minimize(testFields(), Purpose("ads")); err == nil {
		t.Fatal("want error for unknown purpose")
	}
}

func TestMinimizeDoesNotMutateInput(t *testing.T) {
	fields := testFields()
	before := append([]Field(nil), fields...)
	if _, err := Minimize(fields, PurposeRecommend); err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(fields, before) {
		t.Fatalf("input mutated: %+v", fields)
	}

	empty, err := Minimize(nil, PurposeRisk)
	if err != nil {
		t.Fatal(err)
	}
	if empty == nil || len(empty) != 0 {
		t.Fatalf("nil input = %#v, want non-nil empty slice", empty)
	}
}

TestMinimizeCropsSensitiveForRecommend 是最重要的用例。它不仅断言推荐拿不到 email 和 phone,还断言 nickname 和 risk_score 的顺序不变。顺序看起来无关紧要,但下游如果按位置读取字段,顺序变化会造成静默错位。

TestMinimizeEmptyPurposeDropsSensitive 固定了安全默认值:没有目的时,敏感字段一律不出现。这个用例可以防止后人为了“方便调试”把空目的改成“全部放行”。

TestMinimizeDoesNotMutateInput 防的是切片别名问题。如果实现直接对输入切片做原地过滤,调用方持有的字段列表会被悄悄改短,后续再按同一个列表做别的目的裁剪就会丢字段。

测试没有覆盖策略表本身是否合理。推荐到底需不需要 internal 的风控分,是一个产品和合规问题,不是单元测试能回答的。测试只能保证代码忠实执行已经评审过的表。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestMinimizeCropsSensitiveForRecommend
--- PASS: TestMinimizeCropsSensitiveForRecommend (0.00s)
=== RUN   TestMinimizeKeepsOrderAndSensitiveWithPurpose
--- PASS: TestMinimizeKeepsOrderAndSensitiveWithPurpose (0.00s)
=== RUN   TestMinimizeEmptyPurposeDropsSensitive
--- PASS: TestMinimizeEmptyPurposeDropsSensitive (0.00s)
=== RUN   TestMinimizeRejectsBadInput
--- PASS: TestMinimizeRejectsBadInput (0.00s)
=== RUN   TestMinimizeDoesNotMutateInput
--- PASS: TestMinimizeDoesNotMutateInput (0.00s)
PASS
ok  	webapp/internal/profile	0.269s

输出里每个 PASS 对应一个目的或一类错误输入。ok 行表示包级测试通过;如果只想看某个用例,可以把 -run 的正则缩窄到单个函数名。

小结

个人信息分级回答“有多敏感”,目的绑定回答“为什么现在需要”,保留期回答“能用多久”。三者缺一不可:只有分级,敏感字段会被“业务需要”轻易拿走;只有目的,字段会在目的结束后无限期留下;只有保留期,清理时仍然说不清哪些副本该删。

最小化不是删字段,而是按目的裁剪。同一个手机号在注册、客服、风控里可能需要,在推荐里不需要;代码应该让这种差异显式可见,而不是让每个调用方自己记得。

下一节会把“目的结束”之后的动作补齐:用户注销时,导出、删除、备份清除和审计留痕应该按什么顺序发生。

阅读导航:上一节:4.1 资料模型与可见性 · 下一节:4.3 注销、导出与数据留存 。

常见错误与边界

  • 用“以后可能有用”作为收集理由。说不清目的和保留期的字段,注销时也无法界定清理范围。
  • 把最小化理解成删数据库列。真正要裁的是每个业务动作能拿到的字段子集,数据库列可以保留给其他目的。
  • 只在一处做裁剪。接口裁剪了,日志、消息队列、导出任务和第三方 SDK 仍可能拿到完整对象。
  • 把敏感字段和内部字段混为一谈。内部字段泄露的是规则,敏感字段泄露的是身份,处理方式不同。
  • 空目的默认放行。没有目的时应该只给公开和内部字段,敏感字段必须有明确目的。
  • 保留期写成负数或零表示永久。负数应当报错,零要有明确语义,不能靠约定。
  • 忽略日志和备份。请求体日志、错误日志、备份文件往往比业务表保留更久,必须纳入同一张字段表。
  • 让每个服务自己解释目的表。策略表应该集中管理、版本控制、经过评审,而不是散落在调用方。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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