一张充值工单,牵出四本账
《泡泡工坊》上线第三周,客服收到一张充值纠纷:玩家在旧手机上买了礼包,换新手机后用游客登录,礼包不见了。支付渠道的订单号查得到,金额也对得上,但订单挂在旧设备创建的游客账号上;新设备又生成了一个账号,两个账号之间没有任何关联。客服能证明“这个人付过钱”,却无法证明“这个角色属于这个人”。
同一个月,《群岛纪元》的渠道服要求按渠道账号隔离数据,《裂隙战场》则坚持自有账号加渠道绑定。三款产品共用 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 第三方登录与账号绑定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。