一次微信登录,绑出了两个账号
「回声」上线第三个月,客服收到一条奇怪的投诉:用户用微信登录看到了自己的历史订单,换手机号登录却是一个空白账号。排查后发现,同一个人先用手机号注册,后来在另一台设备上点了“微信一键登录”,系统没有找到可绑定的账号,于是新建了一个账号。两个账号都真实存在,订单却只属于其中一个。
这不是代码 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 名称在不同入口可能带空格或大小写差异。
- 解绑不做审计。谁在什么时间解绑了哪个身份,是排查账号纠纷的关键证据。
- 把合并当成纯技术操作。合并涉及资产和隐私,必须有产品规则、用户确认和回滚方案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。