一、绑定表是账号体系的枢纽
第三方登录解决“怎么证明你是谁”,账号绑定解决“这个证明对应哪个游戏账号”。两者之间必须有一张独立的绑定表,而不是把 openid 直接写进玩家表。
一张可用的绑定表至少包含:
| 字段 | 说明 |
|---|---|
account_id | 游戏内部账号 ID,不随渠道变化 |
provider | 渠道枚举,如 wechat、steam、google |
external_id | 渠道侧唯一标识,如 openid、steamid、sub |
created_at | 绑定时间 |
unbound_at | 解绑时间,未解绑为空 |
status | active、unbound、revoked 等状态 |
必须有两组唯一约束:
(provider, external_id)在“有效绑定”范围内唯一,保证一个渠道账号不能同时绑两个游戏账号;(account_id, provider)在“有效绑定”范围内唯一,保证一个游戏账号在同一渠道下只有一个身份。
很多数据库不支持“部分唯一索引”,可以用 status 参与唯一键,或者把有效绑定单独放一张表、历史记录放另一张表。无论哪种实现,应用层都要先做检查,数据库唯一约束作为最后防线。
绑定表还要保留历史。直接物理删除解绑记录,会让客服无法回答“这个微信以前绑过谁”,也无法发现“同一渠道账号短时间内反复换绑”的异常模式。历史记录只追加、不修改,有效状态由 status 和 unbound_at 表达。
二、首次登录:自动建号还是强制绑定
| 策略 | 适用场景 | 优点 | 风险 |
|---|---|---|---|
| 自动建号 | 新玩家、买量投放、休闲游戏 | 转化路径短,登录即玩 | 容易产生重复账号,游客数据分散 |
| 强制绑定已有账号 | 老游戏、账号资产重、跨端互通 | 账号唯一,资产集中 | 新玩家流失,客服成本高 |
| 先游客后升级 | 大多数中重度游戏 | 兼顾转化与账号安全 | 升级流程与冲突处理更复杂 |
自动建号的关键不是“建”,而是“建之前先按 provider + external_id 查”。查到就返回已有账号,查不到才创建。这个顺序必须在一个事务或唯一约束保护下完成,否则两个并发请求会各建一个号。
强制绑定适合已经有账号体系的项目:玩家先用手机号、邮箱或游客账号登录,再在设置页绑定微信、Steam、Google。绑定成功后,后续可以任选一种方式登录,最终都落到同一个 account_id。
游客升级是最容易出问题的路径。正确做法是“先证明游客身份,再证明渠道身份,最后合并”:
- 游客登录时已经持有游戏会话,服务端知道当前
guest_account_id; - 玩家完成渠道授权,服务端换票得到
external_id; - 检查
external_id是否已绑定其他账号; - 未绑定则把渠道绑定到游客账号,并标记账号已升级;
- 已绑定其他账号时,进入“合并或切换”流程,不能静默覆盖。
游客升级不能只靠客户端说“我是这个游客”。升级请求必须携带当前有效的游戏会话,服务端从会话中取 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解析:设备切换或缓存残留时,可能把渠道绑定到错误账号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。