《游戏服务端架构·核心系统与契约》1.2 第三方登录与账号绑定

渠道登录把 open_id、union_id、游客设备和平台账号四类标识同时带进服务端,绑定表稍有不慎就会产生影子账号、重复账号和无法解绑的死号。本文讨论 provider、租户与外部主体组成的唯一键,绑定、解绑、换绑与影子账号收编的语义边界,并用 internal/identitylink 的 Go 实现和五组表驱动测试说明幂等与冲突处理。

同一个微信,两个账号

《群岛纪元》先上安卓渠道,后上 iOS。安卓包用的是渠道应用 A,iOS 包用的是应用 B。玩家在安卓上玩了两个月,换到 iPhone 用微信登录,角色没了。渠道后台查得到同一个 union_id,服务端却生成了第二个内部账号,因为绑定表的主键是 open_id,而 open_id 按应用隔离。

《泡泡工坊》的问题相反。它允许游客直接开局,每台设备第一次进入就建一个游客账号。测试同学的平板上一周出现了六个游客账号:卸载重装、清缓存、换浏览器各算一次。等到要接平台账号时,产品问了一个没人能立刻回答的问题:绑定的时候,这六个影子账号是合并、是丢弃,还是让玩家自己选?

这两件事看起来一个是“键选错了”,一个是“流程没定义”,本质是同一个:外部身份、内部账号和登录凭据被当成同一个东西。渠道给的标识只证明“这个外部主体是谁”,它不证明“内部应该有几个账号”,更不证明“角色该归谁”。

外部标识到内部账号的映射

映射表至少有三个维度:provider、租户、外部主体。缺一个,唯一性就会在某个场景下失效。

方案唯一键优点代价适用
open_id 做主键provider + open_id实现直接同一渠道多 app 时同一人分裂单包、单应用
union_id 做全局主键union_id跨 app 稳定并非所有渠道都提供;跨游戏强行关联渠道矩阵统一
provider + 租户 + 主体provider + tenant + (union_id 优先,否则 open_id)租户内稳定,可隔离同一个人跨游戏默认不共享,需要显式平台账号多产品共用平台

北辰选第三种,并把 tenant 放进唯一键。原因是隔离优先:《裂隙战场》和《群岛纪元》的渠道合同、数据保留期和停服节奏都不同,如果默认用 union_id 把两边合成一个账号,任何一款游戏注销都会牵动另一款。代价是同一个玩家在两款游戏里默认是两个内部账号;需要共享时,走平台账号或显式的账号关联,而不是让绑定表偷偷完成。

union_id 优先于 open_id,因为前者在渠道的应用族内稳定,后者只在一个应用内稳定。这个优先级必须写进唯一键生成函数,而不是散落在各个登录入口;否则安卓包和 iOS 包会各自算出不同的键,历史问题会以“偶发丢角色”的形式反复出现。

绑定、解绑、换绑的语义

四种操作,四组不同的前置条件。把它们混成一个 saveLink,事故只是时间问题。

操作允许条件失败后状态角色与订单典型场景
绑定 bind外部主体未占用,或已属于同一账号不变不变首次接入渠道
解绑 unbind账号至少还剩一个可用凭据不变保留玩家主动解绑
换绑 replace新凭据可用且不属于他人回滚到换绑前保留换手机号、换渠道
收编 adopt目标账号存在,影子未退休影子保持原状需另做迁移游客转正式账号

解绑有一条底线:不能把账号变成没有凭据的裸号。所以“最后一个凭据”必须拒绝,游客凭据是否算数要由策略决定。游客凭据能登录,但换设备就失效,把它当作唯一找回方式等于让玩家的一次清缓存变成永久丢号;北辰默认不允许游客成为最后凭据,只有明确接受风险的休闲产品才打开这个开关。

换绑的顺序必须是“先绑新的,再解旧的”。反过来,一旦解绑成功而绑定失败,账号在中途没有任何可用凭据,玩家会直接卡在登录页。代码里如果第二步失败,要回滚第一步新增的绑定;回滚不是“尽量做”,而是换绑能否被称为原子操作的前提。

影子账号的处理则要回到业务:绑定渠道账号时,设备上已有的游客账号有两种命运。若游客账号没有角色、没有订单、没有好友关系,直接退休,让渠道账号成为唯一身份;若已经有实质进度,应当把绑定关系与数据并入目标账号,而不是把渠道标识挂到一个空账号上再告诉玩家“进度不兼容”。收编的关键不是移动几条绑定记录,而是订单、角色、邮件和好友关系都要有明确的迁移清单。

幂等:重复回调不会造出第二个账号

渠道回调会重试,客户端会重发,网关会补投。绑定接口必须做到:同一个外部主体,无论调用多少次,最终只指向一个内部账号。

第一层保证是唯一键。provider + tenant + subject 在数据库上必须有唯一约束,插入用 ON CONFLICT DO NOTHING 后再回查;只在应用层先查后插,两个并发回调会同时通过检查。

第二层保证是结果语义。重复绑定返回“已绑定”,而不是报错;调用方拿到同样的 account_id 才能安全重试。冲突则要显式返回,让上层决定是收编、拒绝还是人工处理,不能在绑定函数里自动合并两个真实账号。

第三层保证是解绑审计。解绑不是删除历史,而是把当前绑定移走并留下撤销记录;否则渠道对账时无法解释“这个 open_id 上周还能登录,这周查不到”。

下面的 contract.go 定义标识、唯一键、绑定结果和注册表。解绑、换绑、收编在同一个包的 lifecycle.go 里,测试覆盖了冲突、最后凭据和影子账号三条边界。

// file: internal/identitylink/contract.go
// tier: L1
// verify: go test ./internal/identitylink/
package identitylink

import (
	"errors"
	"fmt"
	"time"
)

type Provider string

const (
	Guest  Provider = "guest"
	WeChat Provider = "wechat"
	Apple  Provider = "apple"
	Steam  Provider = "steam"
)

var (
	ErrIncomplete     = errors.New("identitylink: provider, tenant and subject are required")
	ErrNotFound       = errors.New("identitylink: link not found")
	ErrUnknownAccount = errors.New("identitylink: unknown account")
	ErrNotOwner       = errors.New("identitylink: link belongs to another account")
	ErrLastCredential = errors.New("identitylink: refusing to remove the last credential")
	ErrGuestOnly      = errors.New("identitylink: guest link cannot be the last credential")
	ErrLinkLimit      = errors.New("identitylink: account link limit reached")
	ErrAlreadyLinked  = errors.New("identitylink: subject already linked to another account")
	ErrSameCredential = errors.New("identitylink: old and new credential are identical")
	ErrSameAccount    = errors.New("identitylink: cannot adopt into itself")
	ErrShadowRetired  = errors.New("identitylink: shadow account already retired")
)

// ExternalID is one credential observed from a provider. Tenant scopes the
// identifier: the same union id may map to different accounts in different
// products when data isolation is required by contract.
type ExternalID struct {
	Provider Provider
	Tenant   string
	OpenID   string
	UnionID  string
}

// key is the uniqueness rule. UnionID wins over OpenID because it is stable
// across the provider's app family; OpenID is only unique per app.
func (e ExternalID) key() (string, error) {
	if e.Provider == "" || e.Tenant == "" {
		return "", ErrIncomplete
	}
	subject := e.UnionID
	if subject == "" {
		subject = e.OpenID
	}
	if subject == "" {
		return "", ErrIncomplete
	}
	return string(e.Provider) + "|" + e.Tenant + "|" + subject, nil
}

type Link struct {
	Key       string
	AccountID string
	Provider  Provider
	Tenant    string
	OpenID    string
	UnionID   string
	CreatedAt time.Time
}

type Revoked struct {
	Key       string
	AccountID string
	Provider  Provider
	Tenant    string
	At        time.Time
	Reason    string
}

type BindStatus string

const (
	Bound        BindStatus = "BOUND"
	AlreadyBound BindStatus = "ALREADY_BOUND"
	Conflict     BindStatus = "CONFLICT"
)

// BindOutcome reports a conflict instead of hiding it in an error: merging two
// real accounts is a product decision, not something this package may do.
type BindOutcome struct {
	Status            BindStatus
	AccountID         string
	ConflictAccountID string
}

type Policy struct {
	AutoCreate         bool
	AllowGuestOnly     bool
	MaxLinksPerAccount int
}

// Registry is the mapping table. In production the primary key is the same
// string produced by key(); the uniqueness guarantee must live in the database,
// because two concurrent callbacks can both pass an in-memory check.
type Registry struct {
	links     map[string]*Link
	byAccount map[string]map[string]struct{}
	accounts  map[string]struct{}
	retired   map[string]string
	revoked   []Revoked
	policy    Policy
	clock     func() time.Time
	seq       int
}

func New(clock func() time.Time, policy Policy) *Registry {
	return &Registry{
		links:     make(map[string]*Link),
		byAccount: make(map[string]map[string]struct{}),
		accounts:  make(map[string]struct{}),
		retired:   make(map[string]string),
		policy:    policy,
		clock:     clock,
	}
}

func (r *Registry) NewAccount() string {
	r.seq++
	id := fmt.Sprintf("acc-%03d", r.seq)
	r.accounts[id] = struct{}{}
	r.byAccount[id] = map[string]struct{}{}
	return id
}

func (r *Registry) AccountExists(id string) bool {
	_, ok := r.accounts[id]
	return ok
}

// Resolve is the login-time lookup. It never mutates an existing mapping, so a
// retried provider callback returns the same account every time.
func (r *Registry) Resolve(id ExternalID) (string, bool, error) {
	key, err := id.key()
	if err != nil {
		return "", false, err
	}
	if l, ok := r.links[key]; ok {
		return l.AccountID, false, nil
	}
	if !r.policy.AutoCreate {
		return "", false, ErrNotFound
	}
	accountID := r.NewAccount()
	if _, err := r.Bind(accountID, id); err != nil {
		return "", false, err
	}
	return accountID, true, nil
}

func (r *Registry) Bind(accountID string, id ExternalID) (BindOutcome, error) {
	key, err := id.key()
	if err != nil {
		return BindOutcome{}, err
	}
	if _, ok := r.accounts[accountID]; !ok {
		return BindOutcome{}, ErrUnknownAccount
	}
	if l, ok := r.links[key]; ok {
		if l.AccountID == accountID {
			return BindOutcome{Status: AlreadyBound, AccountID: accountID}, nil
		}
		return BindOutcome{Status: Conflict, AccountID: accountID, ConflictAccountID: l.AccountID}, nil
	}
	if r.policy.MaxLinksPerAccount > 0 && len(r.byAccount[accountID]) >= r.policy.MaxLinksPerAccount {
		return BindOutcome{}, ErrLinkLimit
	}
	now := r.clock()
	r.links[key] = &Link{
		Key:       key,
		AccountID: accountID,
		Provider:  id.Provider,
		Tenant:    id.Tenant,
		OpenID:    id.OpenID,
		UnionID:   id.UnionID,
		CreatedAt: now,
	}
	r.byAccount[accountID][key] = struct{}{}
	return BindOutcome{Status: Bound, AccountID: accountID}, nil
}

ExternalID.key() 是整个包的核心:union_id 存在时用它,否则退回 open_id,再拼上 provider 与 tenant。Resolve 只读既有映射,不存在时才按策略创建账号,所以重复回调不会改变结果。Bind 把“已绑定”和“冲突”放进 BindOutcome 而不是 error,这是一个刻意的接口选择:冲突是业务状态,不是程序异常;把它包装成 error,调用方最常见的处理就是 if err != nil { return err },冲突会被当成失败丢弃,玩家则被引导去注册第三个账号。

MaxLinksPerAccount 限制的是单个账号可挂的凭据数量,不是账号数量。它防的是批量绑定刷号和凭据爆炸,不防正常的多渠道玩家;阈值应当按产品配置,而不是写死在协议里。

测试覆盖了什么

internal/identitylink 有 5 个表驱动测试,重点在拒绝路径与幂等。

测试覆盖的失败模式
TestResolveTable已存在映射、自动建号、关闭自动建号、union_id 优先、缺失主体
TestBindTable首次绑定、重复绑定、跨账号冲突、跨租户同主体、未知账号、绑定上限
TestUnbindTable三凭据解绑、最后凭据、游客独存、策略放行、非本人解绑
TestReplaceTable成功换绑、相同凭据、新凭据被占、游客不能成为唯一凭据、旧凭据不存在
TestAdoptShadowTable收编两个链接、自我收编、未知影子、重复退休

本机运行结果:

$ go test -count=1 ./internal/identitylink/
ok  	gsarchlab/internal/identitylink	0.341s

失败模式与兜底

渠道停服或合同到期。 绑定表里的 provider 不能一删了之,否则玩家无法登录,客服也查不到历史。可行路径是先提供一个替代登录方式,用 Replace 把玩家迁走,再保留只读的撤销记录到合同约定的期限。迁移期间新旧凭据并存是可以接受的,但必须限制旧凭据只能登录、不能新绑。

union_id 发生变化。 少数渠道在应用迁移或主体变更后会给新的 union_id,旧键突然失效。此时不能自动把新 union_id 绑到“最近登录的账号”,因为最近登录可能来自共用设备。正确做法是要求玩家先用旧凭据或订单凭证证明归属,再由客服或迁移任务建立新映射,并把旧映射标记为历史。

影子账号带着订单。 Adopt 只移动绑定关系,不会移动订单与角色;如果调用方直接退休影子账号,订单会指向一个已不存在的账号。兜底是收编前先做数据盘点:有订单的影子账号必须走完整迁移,不能收编;空影子才允许直接退休。

并发绑定同一主体到两个账号。 应用层检查会同时通过,最终由数据库唯一键裁决。输的一方必须收到冲突并提示“该渠道已绑定其他账号”,而不是重试到成功;重试只会把两个真实玩家的数据越绑越乱。

常见错误与边界

第一,用 open_id 做全局唯一键。渠道每多一个应用包,就会多一批重复账号,而且历史数据无法自动合并。

第二,绑定成功即删除影子账号。绑定关系只是入口,订单、角色、邮件和好友关系才是玩家的资产;入口迁完不等于资产迁完。

第三,先解绑再绑定。中途失败会产生没有任何凭据的账号,玩家只能走人工找回。

第四,把冲突自动合并。冲突意味着两个内部账号都可能有真实进度,自动合并等于替玩家做了一次不可逆的数据合并。

第五,设备 id 跨租户复用。它会让“同设备小号”的判定横跨两款游戏,既误伤正常玩家,也违反隔离承诺。

小结

第三方登录的难点不是调通渠道,而是定义唯一性:谁和谁是同一个外部主体,这个主体在哪个租户内对应哪个内部账号,绑定失败时旧状态是否原样保留。唯一键解决“会不会重复”,绑定结果解决“调用方能不能安全重试”,解绑与换绑的顺序解决“会不会产生裸号”,收编策略解决“影子账号的资产去哪里”。

身份映射正确,只说明玩家能被认出来,不说明这次登录可信。设备变化、异地、失败频次和行为异常都发生在认证之后、放行之前,下一节讨论风控如何在不制造永久封禁的前提下给出放行、挑战或阻断。

阅读导航:上一节:1.1 账号、角色、设备与租户 · 下一节:1.3 账号安全、找回与风控 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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