《游戏服务端架构·核心系统与契约》1.1 账号、角色、设备与租户

《裂隙战场》《群岛纪元》《泡泡工坊》共用账号底座后,客服工单暴露出账号、角色、设备与租户四层被混为一谈。本文拆开四层模型的责任边界,给出主键与软删除口径、同租户合并与跨租户迁移的分界、注销宽限期与墓碑保留策略,并用 internal/account 的 Go 实现和五组表驱动测试说明每次状态跃迁的代价。

一张充值工单,牵出四本账

《泡泡工坊》上线第三周,客服收到一张充值纠纷:玩家在旧手机上买了礼包,换新手机后用游客登录,礼包不见了。支付渠道的订单号查得到,金额也对得上,但订单挂在旧设备创建的游客账号上;新设备又生成了一个账号,两个账号之间没有任何关联。客服能证明“这个人付过钱”,却无法证明“这个角色属于这个人”。

同一个月,《群岛纪元》的渠道服要求按渠道账号隔离数据,《裂隙战场》则坚持自有账号加渠道绑定。三款产品共用 northstar-platform 的登录入口,代码里却只有一个 player 表:它同时被当作账号、角色、设备记录和游戏归属。一次改名需求,四个团队各自改了一处,最后连“哪个字段是主键”都说不清。

问题不在于表少,而在于四件事被压成了一件事。账号回答“谁拥有这些数据”,角色回答“玩家在这个世界里是谁”,设备回答“哪台终端持有登录凭据”,租户回答“数据属于哪款产品”。把它们拆开,代价是查询多一次关联;不拆,代价是每一次客服、合规和合并请求都要靠人肉推断。

四层模型与各自的责任

层回答的问题生命周期能否共享失败时的表现
租户 tenant数据属于哪款产品上线到停服不共享跨游戏串数据、合规口径混乱
账号 account谁拥有角色与订单跨产品长期存在平台内可共享换设备即丢角色、客服无法归户
角色 role玩家在某款游戏里是谁租户内不跨租户多角色无法表达、合区困难
设备 device哪台终端持有凭据通常短于账号可多账号共用共号被误判、设备封禁株连

先确定一条边界:付费主体是账号在某个租户下的付费档案,不是角色。 订单必须能回指 (account_id, tenant_id),角色只是订单发货的收货对象。这样角色被删、被合并、被迁移时,订单仍然可查;反过来,把订单挂在角色上,一次合区就会制造大量无法对账的历史单。

设备则是凭据载体,不是所有权证明。网吧机器、家庭平板、共用主机都会出现“一设备多账号”。设备标识还应当按租户加盐后存储,平台层面不保留原始硬件号,否则三款游戏的玩家会被同一套设备指纹横向关联,这既是隐私问题,也会让“同设备小号”的判定跨游戏误伤。

三种建模方案与代价

方案做法优点代价适用
账号即角色一张表,登录即角色实现最快无法表达多角色、多游戏;合并只能改主键单产品、无跨端需求
全局角色角色不带租户,靠类型字段区分查询简单隔离靠自觉,删除请求无法按产品传播不推荐
四层分离账号、角色、设备、租户各自成表责任清晰,可审计多一次关联;客服工具与迁移脚本更复杂多产品共用平台

北辰选第三种的真正原因不是“规范”,而是注销。监管要求按产品响应删除请求,如果角色不带租户,平台无法回答“这次注销影响哪几款游戏、各自该保留什么”。四层模型让删除范围变成一次可按 tenant_id 枚举的查询,而不是一次全库扫描。

主键、唯一约束与软删除

四条约束决定了后面所有状态机能否成立。

第一,账号主键全局唯一且永不复用。注销后保留墓碑行,新注册不得拿到旧 id,否则历史订单、封禁记录和客服备注会串到新人身上。

第二,角色主键全局唯一,但角色名只在租户内唯一。不同游戏可以有同名角色,同一游戏内重名则按产品规则处理;把角色名做成全局唯一,会在合服时制造大量无意义的改名。

第三,设备标识按租户作用域。同一台手机在《裂隙战场》和《群岛纪元》得到两个不同的设备 id,平台无法据此跨游戏追踪,但单款游戏内仍能做共号风控。

第四,软删除必须配合部分唯一索引。Postgres 里常见的写法是 UNIQUE (tenant_id, name) WHERE status <> 'DELETED';如果只用普通唯一索引,已注销角色会永久占用名字。删除状态与 deleted_at 同时存在,查询必须显式带状态条件,不能依赖“反正查不到”。

合并与注销:两条不能混用的状态机

合并处理“两个账号其实是同一个人”,注销处理“这个人要求删除数据”。它们的共同点是都不能物理删除,区别在于合并保留角色,注销删除角色。

合并:
ACTIVE --begin--> MERGING --complete--> MERGED
                     |
                     +--abort--> ACTIVE

注销:
ACTIVE --request--> DELETING --purge(宽限期后)--> DELETED
                        |
                        +--cancel(截止前)--> ACTIVE

合并有三个硬约束。其一,默认只允许同一 home_tenant 的账号合并;跨租户合并本质是数据迁移,涉及两份用户协议、两套订单和两个合规主体,不能用一个 UPDATE 糊过去。其二,合并窗口内源账号的角色必须冻结,否则玩家在迁移一半时继续操作,会产生归属不明的写入。其三,角色 id 必须保留,只改 account_id;一旦重新生成 id,成就、邮件附件和订单发货记录全部断链。

注销要回答三个问题。删什么:显示名、凭据与绑定关系、设备关联、可识别个人身份的资料;留什么:账号 id 墓碑、订单号与金额、封禁与申诉记录、审计日志;留多久:租户宽限期加上审计保留期。宽限期是给“误操作”和“未成年人家长撤回”用的,不是给平台拖延用的;截止时间一到,purge 必须拒绝再取消。

代码:internal/account

下面的 model.go 只包含四层实体、唯一性来源和归属校验。合并、注销与墓碑在同一个包的 lifecycle.go 里,测试覆盖了两条状态机的边界。

// file: internal/account/model.go
// tier: L1
// verify: go test ./internal/account/
package account

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

// Status is the lifecycle of an account. MERGING and DELETING are reversible;
// MERGED and DELETED are terminal. Rows are never removed: terminal rows become
// tombstones so a retired id cannot be recycled by a later registration.
type Status string

const (
	Active   Status = "ACTIVE"
	Merging  Status = "MERGING"
	Merged   Status = "MERGED"
	Deleting Status = "DELETING"
	Deleted  Status = "DELETED"
)

type RoleStatus string

const (
	RoleActive  RoleStatus = "ACTIVE"
	RoleFrozen  RoleStatus = "FROZEN"
	RoleDeleted RoleStatus = "DELETED"
)

var (
	ErrNotFound         = errors.New("account: not found")
	ErrBadState         = errors.New("account: transition not allowed")
	ErrTenantMismatch   = errors.New("account: tenant mismatch")
	ErrCrossTenantMerge = errors.New("account: cross-tenant merge disabled")
	ErrRoleNotOwned     = errors.New("account: role does not belong to account")
	ErrGraceActive      = errors.New("account: deletion grace still active")
	ErrGraceExpired     = errors.New("account: deletion grace expired")
	ErrSelfMerge        = errors.New("account: cannot merge into itself")
	ErrDeviceFull       = errors.New("account: device account limit reached")
)

// Tenant is one shipped product. It owns the deletion clock because the
// retention obligation belongs to the product, not to the platform.
type Tenant struct {
	ID          string
	DeleteGrace time.Duration
}

// Account is the identity subject: it holds credentials and survives products.
// HomeTenant records where it first appeared, for residency and audit only.
type Account struct {
	ID         string
	HomeTenant string
	Status     Status
	MergedInto string
	DeleteAt   time.Time
	Roles      map[string]*Role
	Devices    map[string]struct{}
}

// Role is the play subject inside one tenant. It can move between accounts,
// but it never crosses a tenant boundary, because its data lives there.
type Role struct {
	ID        string
	AccountID string
	TenantID  string
	Name      string
	Status    RoleStatus
}

// Device is a credential carrier, not an owner. One device may hold several
// accounts (shared tablet, net cafe) and one account may use several devices.
// Device ids are tenant-scoped so the platform cannot correlate players
// across products by hardware.
type Device struct {
	ID       string
	TenantID string
	Accounts map[string]struct{}
}

type MergePolicy struct {
	AllowCrossTenant bool
	RenameOnConflict bool
}

// Store is an in-memory reference model. Production splits these tables across
// services, but keeps the same state machine and uniqueness rules.
type Store struct {
	tenants  map[string]Tenant
	accounts map[string]*Account
	roles    map[string]*Role
	devices  map[string]*Device
	policy   MergePolicy
	clock    func() time.Time
	seq      int

	// MaxAccountsPerDevice bounds casual account sharing; zero means unlimited.
	MaxAccountsPerDevice int
}

func NewStore(clock func() time.Time, policy MergePolicy, tenants ...Tenant) *Store {
	s := &Store{
		tenants:  make(map[string]Tenant, len(tenants)),
		accounts: make(map[string]*Account),
		roles:    make(map[string]*Role),
		devices:  make(map[string]*Device),
		policy:   policy,
		clock:    clock,
	}
	for _, t := range tenants {
		s.tenants[t.ID] = t
	}
	return s
}

func (s *Store) next(prefix string) string {
	s.seq++
	return fmt.Sprintf("%s-%03d", prefix, s.seq)
}

func (s *Store) CreateAccount(home string) (*Account, error) {
	if _, ok := s.tenants[home]; !ok {
		return nil, ErrTenantMismatch
	}
	a := &Account{
		ID:         s.next("acc"),
		HomeTenant: home,
		Status:     Active,
		Roles:      map[string]*Role{},
		Devices:    map[string]struct{}{},
	}
	s.accounts[a.ID] = a
	return a, nil
}

func (s *Store) CreateRole(accountID, tenantID, name string) (*Role, error) {
	a, ok := s.accounts[accountID]
	if !ok {
		return nil, ErrNotFound
	}
	if a.Status != Active {
		return nil, ErrBadState
	}
	if _, ok := s.tenants[tenantID]; !ok {
		return nil, ErrTenantMismatch
	}
	r := &Role{
		ID:        s.next("role"),
		AccountID: accountID,
		TenantID:  tenantID,
		Name:      name,
		Status:    RoleActive,
	}
	s.roles[r.ID] = r
	a.Roles[r.ID] = r
	return r, nil
}

func (s *Store) AttachDevice(deviceID, tenantID, accountID string) error {
	if _, ok := s.tenants[tenantID]; !ok {
		return ErrTenantMismatch
	}
	a, ok := s.accounts[accountID]
	if !ok {
		return ErrNotFound
	}
	if a.Status != Active {
		return ErrBadState
	}
	d, ok := s.devices[deviceID]
	if !ok {
		d = &Device{ID: deviceID, TenantID: tenantID, Accounts: map[string]struct{}{}}
		s.devices[deviceID] = d
	}
	if d.TenantID != tenantID {
		return ErrTenantMismatch
	}
	if _, ok := d.Accounts[accountID]; ok {
		return nil
	}
	if s.MaxAccountsPerDevice > 0 && len(d.Accounts) >= s.MaxAccountsPerDevice {
		return ErrDeviceFull
	}
	d.Accounts[accountID] = struct{}{}
	a.Devices[deviceID] = struct{}{}
	return nil
}

// Authorize is the ownership check every gameplay request goes through: the
// account must be active, the role must belong to it, and both must agree on
// the tenant carried by the request.
func (s *Store) Authorize(accountID, roleID, tenantID string) error {
	a, ok := s.accounts[accountID]
	if !ok || a.Status != Active {
		return ErrNotFound
	}
	r, ok := s.roles[roleID]
	if !ok {
		return ErrNotFound
	}
	if r.AccountID != accountID {
		return ErrRoleNotOwned
	}
	if r.TenantID != tenantID || r.Status != RoleActive {
		return ErrTenantMismatch
	}
	return nil
}

三个设计点值得单独说明。Authorize 只做三次判断:账号存在且活跃、角色确实属于该账号、角色租户与请求租户一致。它不查缓存,也不相信客户端传来的 account_id,因为归属校验一旦可以绕过,后面的风控和封禁都失去意义。Device 用 map[string]struct{} 记录关联账号,表达的是多对多,而不是“设备属于某个账号”。MergePolicy 把跨租户合并和重名处理显式化,默认关闭;需要开启时必须由产品、法务和运维共同签字,而不是某个调用方传一个 true。

lifecycle.go 里 Purge 的顺序也值得注意:先统计角色与租户,再清空角色上的个人字段,最后解除设备关联。顺序反了,统计就会得到零,删除报告无法向合规解释“到底删了哪些租户的数据”。

测试覆盖了什么

internal/account 有 5 个测试函数,其中 4 个是表驱动;它们覆盖的不是 happy path,而是状态机的拒绝分支。

测试覆盖的失败模式
TestAuthorizeTable错账号、错租户、冻结角色、未知角色与未知账号
TestMergeTransitionsTable自合并、跨租户拒绝、未开始就完成、完成后角色归属
TestDeletionGraceTable宽限期内取消、截止点取消、截止前清理、截止后清理
TestPurgeRetentionTable墓碑计数、租户列表、保留期、设备解绑
TestDeviceTenantTable设备跨租户、重复绑定、共号上限

本机运行结果:

$ go test -count=1 ./internal/account/
ok  	gsarchlab/internal/account	0.242s

失败模式与兜底

合并进行到一半进程崩溃。 账号停在 MERGING,角色已冻结但尚未迁移。恢复任务必须按 MERGING 状态扫描并重放 CompleteMerge;重放只处理仍留在源账号下的角色,已迁移角色不受影响,角色 id 不变,因此不会产生重复角色。不能用“启动时把所有 MERGING 回滚”兜底,因为部分角色可能已经迁移,回滚会把它们留在一个既不属于源也不属于目标的空洞里。

注销后渠道回调又把账号建回来。 第三方登录回调只带外部标识,不带内部账号 id。如果绑定表已经随注销删除,回调会走“新用户注册”路径,生成一个全新账号,玩家会以为注销失败。兜底是保留外部标识的不可逆指纹墓碑,注册前先查墓碑;命中则拒绝自动创建,转人工。

设备上限误伤共用终端。 网吧、体验店和家庭共用机会在短时间内出现很多账号。上限必须是风控信号而不是硬拒绝:超过阈值时进入挑战或人工审核,并允许客服带审计地临时放行。把它做成数据库约束,等于把正常业务场景判成攻击。

删除请求在某个租户失败。 Purge 返回的租户列表就是下游的待办清单。调度器必须按租户逐个确认,任何一个未确认都保持账号为 DELETING 并重试;不能因为主租户成功就把账号标记为已删除,否则残留数据会永远失去归属线索。

常见错误与边界

第一,把设备当作账号主体。设备会换、会共享、会被回收,用它做归属,换机就是一次数据丢失。

第二,用角色名做全局唯一。合服或跨游戏同名会让迁移脚本大面积失败,最终只能人工改名。

第三,合并时重建角色 id。这看起来“干净”,实际上切断了订单、邮件和成就的外键,事后无法对账。

第四,注销时物理删除账号行。id 被复用后,新玩家会继承旧玩家的封禁记录和客服备注,这是最难排查的一类事故。

第五,跨租户直接合并。它绕过两套用户协议和订单归属,短期省事,长期无法向任何一个合规主体解释数据去了哪里。

小结

四层模型的价值不在表结构,而在每次操作都能回答“这是谁的数据、属于哪款产品、凭据在哪台设备上”。账号负责所有权,角色负责游戏内身份,设备负责凭据载体,租户负责隔离与删除边界。合并保留角色 id,注销保留墓碑,跨租户只做迁移不做合并,这三条边界决定了账号系统能不能撑过客服、合规和合区。

账号自身的模型定下来之后,下一个问题是它从哪里来:渠道 open_id、union_id、游客设备和平台账号,如何映射到同一个内部账号,绑定、解绑与换绑又该在什么条件下被拒绝。

阅读导航:上一节:目录与阅读路径 · 下一节:1.2 第三方登录与账号绑定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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