《互联网产品的服务端架构·产品、流量与基础能力》3.1 注册登录与第三方身份

从一个账号绑定手机号、微信和 Apple 的冲突讲起,区分账号与身份,给出合并策略、冲突判定和可回归的 Go 绑定实现,并用测试锁定重复回调、幂等绑定与跨账号抢占三种边界,最后附上本机运行输出与常见错误清单,帮助团队在接入第三方登录前把规则写进代码。

一次微信登录,绑出了两个账号

「回声」上线第三个月,客服收到一条奇怪的投诉:用户用微信登录看到了自己的历史订单,换手机号登录却是一个空白账号。排查后发现,同一个人先用手机号注册,后来在另一台设备上点了“微信一键登录”,系统没有找到可绑定的账号,于是新建了一个账号。两个账号都真实存在,订单却只属于其中一个。

这不是代码 bug,而是产品规则没有被写进服务端。第三方登录接入时,团队通常只讨论三件事:能拿到什么字段、回调地址怎么配、按钮放在哪里。真正决定长期维护成本的,是另一个问题:当同一个人带着不同身份回来时,服务端凭什么判断“这是同一个人”。

本章的案例数字都是案例假设值,用来演示判断过程,不是任何真实系统的实测。我们把问题收敛成三个对象:账号、身份、绑定关系。先分开,再讨论合并,最后用一段可测试的 Go 代码把规则固定下来。

账号与身份:先分开,再谈合并

账号是站内主体,订单、余额、收藏、会员权益都挂在它上面。身份是外部平台提供的事实,例如微信的 openid、Apple 的 sub、手机号的 E.164 号码。一个账号可以有多个身份,一个身份在正常情况下只能属于一个账号。

把两者混在一起,最常见的写法是“账号表里加一列微信 ID、一列 Apple ID、一列手机号”。这种写法在只有一种登录方式时很省事,接入第二种时就会开始长出分支判断,接入第三种时字段名和空值组合会变成事故来源。

维度账号身份
谁生成站内服务端外部平台或运营商
是否可变ID 永不变可能换绑、可能被回收
唯一性范围站内全局至少 provider + externalID 唯一
生命周期跟随用户可解绑、可新增
典型字段account_id、状态、创建时间provider、external_id、绑定时间
冲突处理合并、冻结、人工审核拒绝抢占、提示换绑

表里最容易被忽略的是“外部 ID 也可能变化”。手机号会被运营商回收,邮箱可能被注销后重新注册,微信 openid 在同一应用内稳定但用户可能换微信号。因此账号 ID 必须独立生成,任何外部标识都只能作为登录入口,不能作为主键。

合并的三种策略

发现“同一个人有两个账号”时,服务端通常有三种选择。

第一种是自动合并:以某个身份为准,把另一个账号的订单、权益迁移过来。它体验最好,但风险最高,因为一旦判断错人,就是把两个真实用户的资产混在一起。自动合并只适合身份可信度极高、且业务允许回滚的场景。

第二种是提示绑定:登录时发现身份已属于另一个账号,要求用户先验证原账号,再决定是否合并。它把判断责任交还给用户,服务端只负责证明“你确实拥有这两个身份”。大多数面向消费者的产品应该选这一种。

第三种是拒绝并人工处理:身份冲突时冻结新绑定,进入客服或风控队列。它成本高,但对金融、医疗、企业账号这类错绑代价极大的业务是合理选择。

无论选哪种,服务端都必须能回答两个问题:这个身份现在属于谁;这次绑定请求是新增、重复还是抢占。把这两个问题写成函数,比写成文档更容易在回归中守住。

失败场景:同一回调到达两次,身份绑到了两个账号

第三方登录的典型流程是:客户端拿到授权码,服务端用授权码换 access token,再拉用户信息,最后按 provider + externalID 查找或创建账号。问题出在最后一步之前:回调可能重复。

假设微信回调因为网络抖动重试了两次,两次请求几乎同时到达。服务端第一次查询“这个 openid 有没有绑定账号”,答案是“没有”,于是创建账号 A 并写入绑定;第二次请求在写入完成前也查到“没有”,于是创建账号 B 并写入同一 openid。数据库如果没有对 (provider, external_id) 建唯一约束,两条绑定都会成功。此后用户从不同入口登录,会随机落到 A 或 B。

更隐蔽的版本发生在并发解绑和换绑之间:用户先把微信从账号 A 解绑,再绑定到账号 B,两个请求乱序执行,最终可能出现 A 仍然持有旧绑定、B 也持有同一身份。表面上看只是“登录进了旧账号”,实际后果可能是订单、优惠券和实名信息错位。

这类问题的共同点是:冲突检测和写入不是一个原子动作。应用层的“先查再写”只能降低概率,不能消除竞态。正确的做法是三层一起上:数据库唯一约束兜底,应用层把重复绑定做成幂等,冲突时返回明确错误而不是覆盖。下面的 Go 代码实现第二层,并用测试把三条规则固定下来。

Go 实现:把绑定规则写成纯函数

这段代码来自本书配套的 webapp-lab。Link 接收账号列表、目标账号和待绑定身份,返回新的账号列表,不修改入参。它先校验空值,再查找目标账号,最后扫描全部身份:同一身份属于目标账号时直接返回副本,属于其他账号时报冲突。

// file: internal/account/signup.go
// tier: L1
// verify: go test ./internal/account/

package account

import (
	"fmt"
	"strings"
)

// Identity 是外部身份源提供的事实:哪个平台、哪个不可变 ID。
// 手机号、邮箱这类可能变化的标识不应直接塞进 ExternalID。
type Identity struct {
	Provider   string
	ExternalID string
}

func (i Identity) key() string {
	return i.Provider + "\x00" + i.ExternalID
}

// Account 是站内主体,可以有多个身份。一个账号绑手机号、微信、Apple
// 是常态,账号 ID 必须独立于任何一种登录方式。
type Account struct {
	ID         string
	Identities []Identity
}

// Link 把外部身份绑定到指定账号。同一 provider+externalID 只能属于一个
// 账号;重复绑定同一账号是幂等的;空字段和未知账号直接报错。
func Link(accounts []Account, accountID string, identity Identity) ([]Account, error) {
	if strings.TrimSpace(accountID) == "" {
		return nil, fmt.Errorf("account: account id is required")
	}
	if strings.TrimSpace(identity.Provider) == "" || strings.TrimSpace(identity.ExternalID) == "" {
		return nil, fmt.Errorf("account: identity provider and external id are required")
	}

	target := -1
	for i := range accounts {
		if accounts[i].ID == accountID {
			target = i
			break
		}
	}
	if target < 0 {
		return nil, fmt.Errorf("account: account %q not found", accountID)
	}

	for _, a := range accounts {
		for _, id := range a.Identities {
			if id.key() != identity.key() {
				continue
			}
			if a.ID == accountID {
				return cloneAccounts(accounts), nil
			}
			return nil, fmt.Errorf("account: identity %s/%s already bound to %s",
				identity.Provider, identity.ExternalID, a.ID)
		}
	}

	out := cloneAccounts(accounts)
	out[target].Identities = append(out[target].Identities, identity)
	return out, nil
}

// cloneAccounts 复制切片和身份列表,避免调用方持有的旧切片被意外修改。
func cloneAccounts(accounts []Account) []Account {
	out := make([]Account, len(accounts))
	for i, a := range accounts {
		out[i] = Account{ID: a.ID, Identities: append([]Identity(nil), a.Identities...)}
	}
	return out
}

代码里有四个值得说明的边界。

  • key() 用 \x00 拼接而不是冒号或下划线。provider 和 externalID 都可能包含常见分隔符,用不可见字符能避免 a:b 与 a、b:c 这类拼接碰撞。真正入库时仍然应该用两列联合唯一约束,而不是存拼接后的字符串。
  • 幂等判断放在冲突判断之前。同一账号重复提交返回成功,是因为重复回调是正常网络行为;不同账号抢占同一身份返回错误,是因为那通常意味着账号归属出了问题。
  • 返回新切片而不是原地修改。调用方可能同时持有旧版本,原地修改会让并发调用看到半成品。纯函数也让测试更容易写:输入不变,输出可比。
  • 空值用 TrimSpace 判断。只包含空格的 provider 与空字符串等价,在有表单或配置导入的入口尤其常见。

身份可信度决定冲突处理方式

不是所有身份都值得同样的信任。手机号经过短信验证,可信度较高;邮箱只证明“能收信”,不能证明“是本人”;第三方 openid 只在该应用内有效,换一个应用就是另一串字符。服务端在冲突时应该按身份类型决定动作:高可信身份可以走提示绑定,低可信身份应该直接拒绝自动合并。

还有一个容易漏掉的边界:同一个 provider 可能返回多种 subject。微信的 openid 按应用隔离,unionid 才跨应用稳定;Apple 的 sub 在同一开发者团队内稳定,但用户可以选择隐藏邮箱。接入前必须确认用的是哪一个字段,以及换应用、换环境后它是否仍然不变。用错字段的典型症状是“正式环境登录正常,测试环境每次都是新账号”。

代码没有解决数据库唯一约束。生产环境必须在 (provider, external_id) 上建唯一索引,并把唯一键冲突转换成和 Link 一致的错误。应用层负责给用户可读的提示,数据库负责在并发下守住最后一道门。

测试:冲突、幂等与空值

下面的测试覆盖四个行为:新增绑定、同账号幂等、跨账号冲突、空值与未知账号。第二个用例还检查了输入切片没有被修改,因为纯函数一旦悄悄改成原地修改,很多调用方不会立刻报错,而是在更远的地方出现重复身份。

// file: internal/account/signup_test.go
// tier: L1
// verify: go test ./internal/account/

package account

import "testing"

func TestLinkAddsIdentity(t *testing.T) {
	base := []Account{{ID: "a1"}}
	got, err := Link(base, "a1", Identity{Provider: "wechat", ExternalID: "wx-1001"})
	if err != nil {
		t.Fatal(err)
	}
	if len(got[0].Identities) != 1 {
		t.Fatalf("identities = %d, want 1", len(got[0].Identities))
	}
	if len(base[0].Identities) != 0 {
		t.Fatal("Link mutated the input account")
	}
}

func TestLinkIsIdempotentForSameAccount(t *testing.T) {
	accounts := []Account{{ID: "a1", Identities: []Identity{{Provider: "apple", ExternalID: "ap-7"}}}}
	got, err := Link(accounts, "a1", Identity{Provider: "apple", ExternalID: "ap-7"})
	if err != nil {
		t.Fatal(err)
	}
	if len(got[0].Identities) != 1 {
		t.Fatalf("identities = %d, want 1", len(got[0].Identities))
	}
}

func TestLinkRejectsIdentityOwnedByAnotherAccount(t *testing.T) {
	accounts := []Account{
		{ID: "a1", Identities: []Identity{{Provider: "wechat", ExternalID: "wx-1001"}}},
		{ID: "a2"},
	}
	if _, err := Link(accounts, "a2", Identity{Provider: "wechat", ExternalID: "wx-1001"}); err == nil {
		t.Fatal("want conflict error for identity already bound to a1")
	}
}

func TestLinkValidatesInput(t *testing.T) {
	accounts := []Account{{ID: "a1"}}
	cases := []struct {
		name     string
		account  string
		identity Identity
	}{
		{"empty-provider", "a1", Identity{ExternalID: "x"}},
		{"empty-external-id", "a1", Identity{Provider: "wechat"}},
		{"unknown-account", "missing", Identity{Provider: "wechat", ExternalID: "x"}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, err := Link(accounts, c.account, c.identity); err == nil {
				t.Fatal("want error")
			}
		})
	}
}

测试里没有覆盖并发。纯函数本身没有共享状态,并发安全来自“不修改入参”;真正的并发冲突发生在数据库写入,需要用唯一索引和事务来测,而不是靠这个包的单测。这个边界要在评审时说清楚,避免有人看到全绿就以为可以省掉唯一约束。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/account/ -count=1

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

ok  	webapp/internal/account	0.288s

ok 表示包内所有测试通过。如果只想看这一篇相关的用例,可以加 -run 'TestLink' -v,输出会列出每个子测试;判定标准不变:退出码为 0,且没有 FAIL。

小结

第三方登录的难点不在 OAuth 流程,而在身份归属。账号和身份分开建模,绑定规则收敛成一个纯函数,重复绑定做成幂等,跨账号冲突明确报错,再让数据库唯一约束兜住并发,才算是把“同一个人”这个问题回答完整。

合并策略没有标准答案:自动合并体验最好但风险最高,提示绑定最常见,人工审核适合错绑代价极大的业务。选择之前先问清楚业务能承受多大的误合并,再决定服务端要保存哪些证据。

下一节讨论登录之后的状态:会话为什么不是 token,以及多端互踢时服务端到底要删除什么。

阅读导航:上一节:目录与学习路径 · 下一节:3.2 会话、设备与多端互踢 。

常见错误与边界

  • 把外部 ID 当账号主键。手机号会被回收,微信可能换号,主键必须由站内生成。
  • 只在前端判断“已绑定”。前端状态可以伪造,绑定规则必须在服务端执行。
  • 先查再写却不建唯一约束。并发回调下两次查询都可能返回“未绑定”。
  • 把重复绑定当成错误。重复回调是正常网络行为,同账号重复绑定应该幂等成功。
  • 冲突时自动覆盖旧绑定。覆盖会静默改变账号归属,至少要让原账号侧可感知。
  • 忽略大小写和空白。邮箱、provider 名称在不同入口可能带空格或大小写差异。
  • 解绑不做审计。谁在什么时间解绑了哪个身份,是排查账号纠纷的关键证据。
  • 把合并当成纯技术操作。合并涉及资产和隐私,必须有产品规则、用户确认和回滚方案。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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