《游戏服务端编程实践》4.3.3 第三方账号绑定

围绕一个游戏账号绑定多个渠道的完整生命周期,拆解绑定表唯一约束、首次登录自动建号与强制绑定的取舍、跨账号冲突与同一设备切换账号的处理,以及解绑冷却、保留最后登录方式和注销释放 external_id 的策略,并给出可运行的 Go BindingStore 与冲突测试。

一、绑定表是账号体系的枢纽

第三方登录解决“怎么证明你是谁”,账号绑定解决“这个证明对应哪个游戏账号”。两者之间必须有一张独立的绑定表,而不是把 openid 直接写进玩家表。

一张可用的绑定表至少包含:

字段说明
account_id游戏内部账号 ID,不随渠道变化
provider渠道枚举,如 wechat、steam、google
external_id渠道侧唯一标识,如 openid、steamid、sub
created_at绑定时间
unbound_at解绑时间,未解绑为空
statusactive、unbound、revoked 等状态

必须有两组唯一约束:

  1. (provider, external_id) 在“有效绑定”范围内唯一,保证一个渠道账号不能同时绑两个游戏账号;
  2. (account_id, provider) 在“有效绑定”范围内唯一,保证一个游戏账号在同一渠道下只有一个身份。

很多数据库不支持“部分唯一索引”,可以用 status 参与唯一键,或者把有效绑定单独放一张表、历史记录放另一张表。无论哪种实现,应用层都要先做检查,数据库唯一约束作为最后防线。

绑定表还要保留历史。直接物理删除解绑记录,会让客服无法回答“这个微信以前绑过谁”,也无法发现“同一渠道账号短时间内反复换绑”的异常模式。历史记录只追加、不修改,有效状态由 status 和 unbound_at 表达。

二、首次登录:自动建号还是强制绑定

策略适用场景优点风险
自动建号新玩家、买量投放、休闲游戏转化路径短,登录即玩容易产生重复账号,游客数据分散
强制绑定已有账号老游戏、账号资产重、跨端互通账号唯一,资产集中新玩家流失,客服成本高
先游客后升级大多数中重度游戏兼顾转化与账号安全升级流程与冲突处理更复杂

自动建号的关键不是“建”,而是“建之前先按 provider + external_id 查”。查到就返回已有账号,查不到才创建。这个顺序必须在一个事务或唯一约束保护下完成,否则两个并发请求会各建一个号。

强制绑定适合已经有账号体系的项目:玩家先用手机号、邮箱或游客账号登录,再在设置页绑定微信、Steam、Google。绑定成功后,后续可以任选一种方式登录,最终都落到同一个 account_id。

游客升级是最容易出问题的路径。正确做法是“先证明游客身份,再证明渠道身份,最后合并”:

  1. 游客登录时已经持有游戏会话,服务端知道当前 guest_account_id;
  2. 玩家完成渠道授权,服务端换票得到 external_id;
  3. 检查 external_id 是否已绑定其他账号;
  4. 未绑定则把渠道绑定到游客账号,并标记账号已升级;
  5. 已绑定其他账号时,进入“合并或切换”流程,不能静默覆盖。

游客升级不能只靠客户端说“我是这个游客”。升级请求必须携带当前有效的游戏会话,服务端从会话中取 account_id,而不是从请求体里取。

三、冲突处理:同一渠道账号只能属于一个游戏账号

冲突主要有三类。

第一,渠道账号已经绑定其他游戏账号。此时不能自动改绑,也不能把两个账号合并。应提示玩家“该微信已绑定其他账号”,并提供“登录原账号”或“联系客服”的入口。自动改绑等于把原账号的登录方式抢走,是严重的账号安全问题。

第二,同一设备切换账号。设备上可能残留上一个账号的会话或缓存,导致新玩家授权后仍带着旧 account_id。服务端应以本次换票得到的 external_id 为准重新解析账号,不能信任客户端传来的 account_id;客户端在切换账号时必须清理本地会话。

第三,玩家要求合并两个账号。合并涉及角色、背包、订单、社交关系,不能由登录流程自动完成。通常只允许“空账号合并到有资产账号”,或者由客服在验证双方身份后人工处理。合并后要保留旧账号的绑定历史,并把冲突的 external_id 明确归属到一个账号。

一个真实的失败场景是:客服为了“帮玩家找回账号”,直接把玩家的微信从 A 账号解绑,再绑到 B 账号,没有做二次验证。结果 A 账号的真正主人无法登录,B 账号则获得了 A 的登录方式。修复需要三重约束:解绑与换绑都要二次验证;换绑要有冷却期;所有操作写审计日志并通知原账号。

四、解绑、冷却与注销

解绑不是删除一行记录,而是一组策略:

  • 至少保留一种登录方式。账号只剩一个有效绑定时,拒绝解绑,否则玩家会永久失去登录入口。
  • 解绑冷却期。解绑后一段时间内,同一 external_id 不能绑定到其他账号,防止“先解绑再抢绑”。
  • 换绑二次验证。换绑前要求玩家用原登录方式或绑定手机确认,不能只凭当前会话。
  • 注销释放时机。账号注销后,external_id 不应立即释放给新账号,应保留一段观察期,处理误注销、找回和风控调查。
  • 审计与通知。解绑、换绑、注销都记录操作者、时间、IP、设备,并向账号预留的联系方式发送通知。

冷却期的长度要结合客服流程。太短起不到防抢绑作用,太长会让正常换设备、换微信的玩家无法自助恢复。常见做法是 24 小时到 7 天,并允许客服在验证身份后加急处理。

注销后的 external_id 释放要特别小心。如果玩家注销后立刻用同一个微信注册新账号,而旧账号的订单、封禁记录还没结清,容易出现“换个号继续”的漏洞。更稳妥的做法是保留一段时间的“已注销”占位,期间该 external_id 不能绑定新账号,也不能用于登录。

五、Go 实现:BindingStore

下面的 BindingStore 把绑定、解绑、解析三个动作收敛到一组接口。MemoryBindingStore 用内存表保存有效绑定、外部标识索引、释放时间和历史记录,演示唯一约束、至少保留一种登录方式和解绑冷却;Identity 来自 4.3.1 的 provider.go,绑定层只使用其中的 Provider 与 ExternalID。

// file: ch04/oauth/binding.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth

import (
	"context"
	"errors"
	"sync"
	"time"
)

var (
	ErrBindingExists  = errors.New("oauth: external id already bound")
	ErrBindingMissing = errors.New("oauth: binding not found")
	ErrLastAuthMethod = errors.New("oauth: cannot unbind the last login method")
	ErrRebindCooldown = errors.New("oauth: external id is in rebind cooldown")
)

type Binding struct {
	AccountID  string
	Provider   ProviderKind
	ExternalID string
	CreatedAt  time.Time
	UnboundAt  *time.Time
}

type BindingStore interface {
	Bind(ctx context.Context, accountID string, id Identity) (Binding, error)
	Unbind(ctx context.Context, accountID string, provider ProviderKind) error
	Resolve(ctx context.Context, provider ProviderKind, externalID string) (string, error)
}

type MemoryBindingStore struct {
	mu       sync.Mutex
	now      func() time.Time
	cooldown time.Duration
	active   map[string]Binding
	owners   map[string]string
	released map[string]time.Time
	history  []Binding
}

func NewMemoryBindingStore(cooldown time.Duration) *MemoryBindingStore {
	return &MemoryBindingStore{
		now:      time.Now,
		cooldown: cooldown,
		active:   make(map[string]Binding),
		owners:   make(map[string]string),
		released: make(map[string]time.Time),
	}
}

func accountKey(accountID string, provider ProviderKind) string {
	return accountID + "|" + string(provider)
}

func externalKey(provider ProviderKind, externalID string) string {
	return string(provider) + "|" + externalID
}

func (s *MemoryBindingStore) Bind(_ context.Context, accountID string, id Identity) (Binding, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if accountID == "" || id.ExternalID == "" {
		return Binding{}, ErrBindingMissing
	}
	ek := externalKey(id.Provider, id.ExternalID)
	ak := accountKey(accountID, id.Provider)
	if owner, ok := s.owners[ek]; ok {
		if owner == accountID {
			if b, ok := s.active[ak]; ok && b.ExternalID == id.ExternalID {
				return b, nil
			}
		}
		return Binding{}, ErrBindingExists
	}
	if at, ok := s.released[ek]; ok && s.now().Before(at.Add(s.cooldown)) {
		return Binding{}, ErrRebindCooldown
	}
	if _, ok := s.active[ak]; ok {
		return Binding{}, ErrBindingExists
	}
	b := Binding{AccountID: accountID, Provider: id.Provider, ExternalID: id.ExternalID, CreatedAt: s.now()}
	s.active[ak] = b
	s.owners[ek] = accountID
	delete(s.released, ek)
	s.history = append(s.history, b)
	return b, nil
}

func (s *MemoryBindingStore) Unbind(_ context.Context, accountID string, provider ProviderKind) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	ak := accountKey(accountID, provider)
	b, ok := s.active[ak]
	if !ok {
		return ErrBindingMissing
	}
	if s.activeCountLocked(accountID) <= 1 {
		return ErrLastAuthMethod
	}
	now := s.now()
	b.UnboundAt = &now
	delete(s.active, ak)
	delete(s.owners, externalKey(provider, b.ExternalID))
	s.released[externalKey(provider, b.ExternalID)] = now
	s.history = append(s.history, b)
	return nil
}

func (s *MemoryBindingStore) Resolve(_ context.Context, provider ProviderKind, externalID string) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	accountID, ok := s.owners[externalKey(provider, externalID)]
	if !ok {
		return "", ErrBindingMissing
	}
	return accountID, nil
}

func (s *MemoryBindingStore) History() []Binding {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Binding, len(s.history))
	copy(out, s.history)
	return out
}

func (s *MemoryBindingStore) activeCountLocked(accountID string) int {
	n := 0
	for _, b := range s.active {
		if b.AccountID == accountID {
			n++
		}
	}
	return n
}

实现里有四个关键点:owners 保证一个 external_id 只属于一个账号;active 保证一个账号在同一渠道下只有一条有效绑定;解绑时写入 released 并保留历史;只剩一种登录方式时拒绝解绑。

// file: ch04/oauth/binding_test.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth

import (
	"context"
	"errors"
	"testing"
	"time"
)

func googleIdentity(id string) Identity {
	return Identity{Provider: Google, ExternalID: id}
}

func TestBindRejectsSameExternalIDOnAnotherAccount(t *testing.T) {
	store := NewMemoryBindingStore(time.Hour)
	ctx := context.Background()
	if _, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1")); err != nil {
		t.Fatalf("first bind: %v", err)
	}
	if _, err := store.Bind(ctx, "acc-2", googleIdentity("sub-1")); !errors.Is(err, ErrBindingExists) {
		t.Fatalf("cross-account bind: got %v, want %v", err, ErrBindingExists)
	}
}

func TestBindRejectsSecondRecordForSameProvider(t *testing.T) {
	store := NewMemoryBindingStore(time.Hour)
	ctx := context.Background()
	if _, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1")); err != nil {
		t.Fatalf("first bind: %v", err)
	}
	if _, err := store.Bind(ctx, "acc-1", googleIdentity("sub-2")); !errors.Is(err, ErrBindingExists) {
		t.Fatalf("second google binding: got %v, want %v", err, ErrBindingExists)
	}
}

func TestResolveFailsAfterUnbindAndRespectsCooldown(t *testing.T) {
	store := NewMemoryBindingStore(time.Hour)
	ctx := context.Background()
	if _, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1")); err != nil {
		t.Fatalf("bind google: %v", err)
	}
	if _, err := store.Bind(ctx, "acc-1", Identity{Provider: WeChat, ExternalID: "openid-1"}); err != nil {
		t.Fatalf("bind wechat: %v", err)
	}
	if err := store.Unbind(ctx, "acc-1", Google); err != nil {
		t.Fatalf("unbind: %v", err)
	}
	if _, err := store.Resolve(ctx, Google, "sub-1"); !errors.Is(err, ErrBindingMissing) {
		t.Fatalf("resolve after unbind: got %v, want %v", err, ErrBindingMissing)
	}
	if _, err := store.Bind(ctx, "acc-2", googleIdentity("sub-1")); !errors.Is(err, ErrRebindCooldown) {
		t.Fatalf("rebind during cooldown: got %v, want %v", err, ErrRebindCooldown)
	}
}

func TestUnbindRejectsLastLoginMethod(t *testing.T) {
	store := NewMemoryBindingStore(time.Hour)
	ctx := context.Background()
	if _, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1")); err != nil {
		t.Fatalf("bind: %v", err)
	}
	if err := store.Unbind(ctx, "acc-1", Google); !errors.Is(err, ErrLastAuthMethod) {
		t.Fatalf("unbind last method: got %v, want %v", err, ErrLastAuthMethod)
	}
	if got, err := store.Resolve(ctx, Google, "sub-1"); err != nil || got != "acc-1" {
		t.Fatalf("binding should survive: got %q, %v", got, err)
	}
}

func TestBindIsIdempotent(t *testing.T) {
	store := NewMemoryBindingStore(time.Hour)
	ctx := context.Background()
	first, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1"))
	if err != nil {
		t.Fatalf("first bind: %v", err)
	}
	second, err := store.Bind(ctx, "acc-1", googleIdentity("sub-1"))
	if err != nil {
		t.Fatalf("idempotent bind: %v", err)
	}
	if !first.CreatedAt.Equal(second.CreatedAt) {
		t.Fatalf("idempotent bind changed createdAt: %v != %v", first.CreatedAt, second.CreatedAt)
	}
}

测试覆盖跨账号冲突、同渠道重复绑定、解绑后解析失败、冷却期内禁止换绑、最后一种登录方式不可解绑,以及同一绑定重复提交的幂等行为。

六、运营与风控清单

  • 绑定、解绑、换绑都记录审计日志,保留操作者、来源 IP、设备指纹和请求 ID。
  • 同一 external_id 在短时间内多次解绑/换绑,触发风控审核。
  • 客服后台提供“绑定关系查询”,但默认脱敏 external_id,避免内部人员滥用。
  • 账号找回流程要求至少两种验证材料,不能只凭一个渠道账号。
  • 注销流程明确告知冷却期与数据保留策略,避免合规风险。

小结

  • 绑定表用 account_id + provider + external_id 表达关系,并加两组唯一约束。
  • 首次登录先按 provider + external_id 解析,查到即返回,查不到才建号。
  • 游客升级必须基于当前有效游戏会话,不能信任客户端传来的 account_id。
  • 渠道账号已绑他人时不要自动改绑,合并账号要走人工或明确的资产规则。
  • 解绑要保留最后一种登录方式,设置冷却期,并保留历史记录供审计。

常见错误与边界

  • 绑定不做唯一约束:并发请求或重试会建出两个账号,后期只能靠客服合并。
  • 解绑直接删记录:无法审计,也无法判断是否在冷却期内被恶意抢绑。
  • 换绑不做二次验证:拿到一次会话就能把受害者的登录方式换走。
  • 把 external_id 写进玩家表:渠道标识与账号主体耦合,换绑、解绑都会污染玩家数据。
  • 注销后立即释放 external_id:旧账号的封禁、订单、风控记录可能被新账号绕过。
  • 只按客户端 account_id 解析:设备切换或缓存残留时,可能把渠道绑定到错误账号。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist