问题:一个「用户」在系统里有几个名字
《潮汐消消》案例设定中 DAU 80 万、月新增 42 万,接入 9 个渠道、产出 11 个渠道包。登录要回答的第一个问题不是「密码对不对」,而是「眼前这个请求属于哪个玩家」。如果这个问题答错,后面的订单、广告归因、活动资格和客服申诉都会跟着错。
实际系统里至少有四个名字:设备或安装实例、游客账号、平台账号、全服 playerId。它们生命周期不同、来源不同、可信度也不同。把其中任意两个当成同一个东西,短期能省一张表,长期会在换机、换渠道、合服和申诉时集中还债。
最典型的误用是把设备号当账号主键。设备号会变、会被重置、会在模拟器里重复,也会因为隐私限制拿不到稳定值;一旦它成为主键,玩家换机就变成「换了一个人」,游客进度无法找回,客服只能靠人工补发。
四层标识:设备、游客、平台账号、全服 playerId
| 层 | 生命周期 | 来源 | 能否直接当账号 | 常见误用 |
|---|---|---|---|---|
| 设备/安装实例 | 天到月,可能重置 | 客户端生成或系统提供 | 不能 | 拿来当 playerId 或合并依据 |
| 游客账号 | 从首次启动到转正或流失 | 服务端签发 | 能,但只有一种凭据 | 只在本地保存,卸载即丢 |
| 平台账号 | 跟随应用商店账号 | iOS、安卓渠道、TapTap 等 | 不能单独当全服账号 | 直接把 openId 当主键 |
| 全服 playerId | 账号生命周期 | 账号中台签发 | 能,且应唯一 | 每个渠道各发一套,合服时冲突 |
四层里只有游客账号和全服 playerId 是「账号」,设备是环境,平台账号是凭据来源。这个区分决定了后面所有规则:设备只用于风控辅助,平台账号只用于证明「同一个人」,playerId 才承载资产与进度。
一张推荐的关系是:一个 playerId 对应零个或多个平台绑定;游客阶段没有平台绑定,转正后至少有一个;每个平台绑定由一个 platform 加一个 openId 组成,二者合起来才是全局唯一键。
命名空间:openId 只能做凭据,不能做主键
不同平台各自签发 openId,数值空间完全独立。iOS 的 openId 与安卓渠道的 openId 即使字符串相同,也不代表同一个人;反过来,同一个玩家在不同平台一定有不同 openId。直接用 openId 当 playerId,会把这两个方向都搞错。
所以绑定键必须是复合键:平台:openId。平台名要先归一化成固定枚举,不能把渠道传来的展示名直接拼进去;openId 要去掉首尾空白,空值直接拒绝。这一步看起来琐碎,却是后面索引、冲突检测和申诉取证的基础。
| 平台 | 绑定前缀 | 消消占比(案例设定) | 方舟占比(案例设定) |
|---|---|---|---|
| 国内安卓 | android_cn | 55% | 40% |
| iOS | ios | 30% | 45% |
| TapTap | taptap | 8% | — |
| 海外 | overseas | 7% | — |
| Google Play | google_play | — | 15% |
渠道越碎,复合键越重要。《潮汐消消》的国内安卓、iOS、TapTap 与海外四类来源各有自己的账号体系,同一个玩家可能先在安卓游客态玩,再在 iOS 绑定;《潮汐方舟》的 iOS 与 Google Play 占比更高,跨商店绑定更常见,但平台数量少,冲突反而更容易定位。
游客不是临时账号,而是可转正的实体
游客账号经常被当成「还没登录的临时状态」,只在本地存一个随机串。这个做法在弱联网休闲游戏里很危险:玩家可能玩了几十局才想起绑定,而卸载、清数据、换机随时可能发生在绑定之前。
更稳的做法是让服务端在首次启动时就签发一个真实的 playerId,类型标记为 guest,不挂任何平台绑定。它的进度、订单和活动资格与正式账号走同一套存储,只是凭据类型不同。案例设定中《潮汐消消》的游客转正率是 62%,《潮汐方舟》是 88%;转正率越高,游客态承载的资产越多,越不能用「临时」心态设计。
| 维度 | 游客 | 正式账号 |
|---|---|---|
| playerId | 服务端签发,转正后不变 | 服务端签发 |
| 平台绑定 | 0 个 | 至少 1 个 |
| 凭据 | 设备侧保存的游客令牌 | 平台登录换取的令牌 |
| 资产 | 与正式账号同库 | 与游客同库 |
| 转正动作 | 绑定平台并切换类型 | 不适用 |
游客态的关键约束是「playerId 不变,凭据可变」。转正不是新建一个账号再把资产搬过去,而是给同一个 playerId 增加平台绑定并把类型改成 registered。这样进度、订单号、客服工单都不需要迁移,也就不存在迁移中途失败导致资产两分的窗口。
绑定关系:一个 playerId 可以有多个平台凭据
绑定表至少要保证三条规则。第一,平台:openId 全局唯一,不能出现两个 playerId 抢同一个平台凭据;第二,同一个 playerId 在同一个平台只能有一个绑定,避免同一商店的两个 openId 指向同一账号;第三,绑定只增不减,解绑不是删除记录,而是标记失效并保留审计。
这三条规则分别防三类事故:跨账号抢占、同平台重复绑定、以及「解绑后原平台又能绑到别人」的账号劫持。第三条尤其容易被忽略:如果解绑直接删行,原来的 openId 会重新变成可用键,攻击者可以用同一个平台账号绑定到自己的 playerId。
绑定列表本身应该是不可变数据:每次增加绑定返回新对象,旧对象不变。这样日志、重放和测试都能拿到确定的快照,不会出现「同一个对象在中途被改过」的排查难题。
游客转正:保留 playerId,换的是凭据
转正的步骤可以固定成五步:
| 步骤 | 动作 | 失败处理 |
|---|---|---|
| 1 | 校验当前身份是游客 | 非游客直接拒绝,避免重复转正 |
| 2 | 校验平台绑定键可用 | 已被占用则提示换绑或走申诉 |
| 3 | 写入平台绑定,类型改为正式 | 同一事务内完成 |
| 4 | 吊销游客令牌,签发正式令牌 | 由 2.2 的会话模块处理 |
| 5 | 记录转正事件与来源渠道 | 供归因与客服查询 |
第 3 步和第 4 步之间有一个真实窗口:绑定已写入但新令牌还没签发。如果此时客户端崩溃,玩家会用旧游客令牌继续请求。正确做法是把「绑定 + 类型切换」放在一个事务里,令牌签发失败时回滚绑定;或者让旧令牌在短时间内仍可读,但不能写资产。
转正失败最常见的原因是绑定键已被占用。此时不能自动合并到对方账号,也不能静默覆盖;应该进入 2.3 的申诉与合并流程。2.1 只负责「同一个 playerId 如何获得第一个平台绑定」,不负责「两个已有账号如何变成一个人」。
三端互通:互通的是 playerId,不是登录态
「三端互通」经常被理解成「同一个 token 三端通用」,这是另一个常见错误。互通的是 playerId 与资产,登录态必须按设备或会话隔离:每台设备有自己的访问令牌、刷新令牌和踢出策略,一台设备退出不应影响另一台正在进行的对局。
案例设定中《潮汐消消》多端登录用户占 18%,《潮汐方舟》占 34%。方舟的强联网与更长单局让多端并存更常见,但它的互踢策略反而是「同账号最多 2 端并存」;消消是弱联网休闲,采用「后登录顶掉先登录」。两种策略都建立在「playerId 相同、会话不同」这个前提上。
标识层只回答「这些会话属于谁」,不回答「同时能有几个会话」。后者是 2.2 的职责。把两件事混在一起,就会出现「为了互通把 token 做成全局共享」的错误设计,最终表现为一端操作把另一端顶下线,或者刷新令牌被多端同时使用而触发复用检测。
可运行模型:identity.ts
模型把四层标识收敛成三个可测试的动作:签发游客身份、增加平台绑定、建立绑定索引。核心约束都写在 assertIdentity 里,任何构造出来的身份都要先过这一关。
createGuestIdentity(region, sequence)生成g-<region>-<6 位序号>的 playerId,游客不带平台绑定。upgradeGuest(identity, binding)保留 playerId,把类型改为registered,并写入第一个绑定。bindPlatform(identity, binding)只允许正式账号追加绑定,同平台第二次绑定直接报错。indexIdentities(identities)建立平台:openId → playerId的索引,同时检测重复 playerId 与跨账号抢占。findPlayerByIdentity与formatIdentity分别用于查询和可读输出。
// file: src/biz/identity.ts
// tier: L1
// verify: node --test test/biz/identity.test.ts
export type Platform = "android_cn" | "ios" | "taptap" | "google_play" | "overseas";
export type AccountKind = "guest" | "registered";
export interface IdentityBinding {
readonly platform: Platform;
readonly openId: string;
}
export interface PlayerIdentity {
readonly playerId: string;
readonly kind: AccountKind;
readonly region: string;
readonly bindings: readonly IdentityBinding[];
}
export class IdentityError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "IdentityError";
this.code = code;
}
}
const PLATFORM_PREFIX: Record<Platform, string> = {
android_cn: "ac",
ios: "ios",
taptap: "tt",
google_play: "gp",
overseas: "ov",
};
const REGION_PATTERN = /^[a-z0-9-]{2,12}$/;
function fail(code: string, message: string): never {
throw new IdentityError(code, message);
}
export function normalizeRegion(region: string): string {
const value = region.trim().toLowerCase();
if (!REGION_PATTERN.test(value)) {
fail("INVALID_REGION", `region must match ${REGION_PATTERN.source}`);
}
return value;
}
export function bindingKey(binding: IdentityBinding): string {
const openId = binding.openId.trim();
if (openId.length === 0) {
fail("EMPTY_OPEN_ID", "openId must not be empty");
}
return `${binding.platform}:${openId}`;
}
export function createGuestIdentity(region: string, sequence: number): PlayerIdentity {
if (!Number.isInteger(sequence) || sequence < 1) {
fail("INVALID_SEQUENCE", "sequence must be a positive integer");
}
const normalized = normalizeRegion(region);
const playerId = `g-${normalized}-${String(sequence).padStart(6, "0")}`;
return { playerId, kind: "guest", region: normalized, bindings: [] };
}
export function assertIdentity(identity: PlayerIdentity): void {
if (!/^g-[a-z0-9-]+-\d{6,}$/.test(identity.playerId)) {
fail("INVALID_PLAYER_ID", `invalid playerId: ${identity.playerId}`);
}
normalizeRegion(identity.region);
const seenPlatforms = new Set<Platform>();
const seenKeys = new Set<string>();
for (const binding of identity.bindings) {
const key = bindingKey(binding);
if (seenPlatforms.has(binding.platform)) {
fail("DUPLICATE_PLATFORM", `platform already bound: ${binding.platform}`);
}
if (seenKeys.has(key)) {
fail("DUPLICATE_BINDING", `binding already present: ${key}`);
}
seenPlatforms.add(binding.platform);
seenKeys.add(key);
}
if (identity.kind === "guest" && identity.bindings.length > 0) {
fail("GUEST_HAS_BINDING", "guest identity must not carry platform bindings");
}
if (identity.kind === "registered" && identity.bindings.length === 0) {
fail("REGISTERED_WITHOUT_BINDING", "registered identity needs at least one binding");
}
}
export function bindPlatform(identity: PlayerIdentity, binding: IdentityBinding): PlayerIdentity {
assertIdentity(identity);
if (identity.kind !== "registered") {
fail("NOT_REGISTERED", "only registered identities can bind more platforms");
}
bindingKey(binding);
if (identity.bindings.some((item) => item.platform === binding.platform)) {
fail("PLATFORM_ALREADY_BOUND", `platform already bound: ${binding.platform}`);
}
const next: PlayerIdentity = {
...identity,
bindings: [...identity.bindings, { platform: binding.platform, openId: binding.openId.trim() }],
};
assertIdentity(next);
return next;
}
export function upgradeGuest(identity: PlayerIdentity, binding: IdentityBinding): PlayerIdentity {
assertIdentity(identity);
if (identity.kind !== "guest") {
fail("NOT_GUEST", "only a guest identity can be upgraded");
}
bindingKey(binding);
const next: PlayerIdentity = {
...identity,
kind: "registered",
bindings: [{ platform: binding.platform, openId: binding.openId.trim() }],
};
assertIdentity(next);
return next;
}
export function indexIdentities(identities: readonly PlayerIdentity[]): Map<string, string> {
const index = new Map<string, string>();
const players = new Set<string>();
for (const identity of identities) {
assertIdentity(identity);
if (players.has(identity.playerId)) {
fail("DUPLICATE_PLAYER", `playerId appears twice: ${identity.playerId}`);
}
players.add(identity.playerId);
for (const binding of identity.bindings) {
const key = bindingKey(binding);
const owner = index.get(key);
if (owner !== undefined) {
fail("BINDING_TAKEN", `binding ${key} already owned by ${owner}`);
}
index.set(key, identity.playerId);
}
}
return index;
}
export function findPlayerByIdentity(
identities: readonly PlayerIdentity[],
binding: IdentityBinding,
): PlayerIdentity | null {
const key = bindingKey(binding);
for (const identity of identities) {
if (identity.bindings.some((item) => bindingKey(item) === key)) {
return identity;
}
}
return null;
}
export function formatIdentity(identity: PlayerIdentity): string {
const bindings = identity.bindings
.map((binding) => `${PLATFORM_PREFIX[binding.platform]}=${binding.openId.trim()}`)
.sort()
.join(",");
return `${identity.playerId} ${identity.kind} region=${identity.region} bindings=${bindings || "none"}`;
}
以 createGuestIdentity("cn", 7) 为例,它得到 g-cn-000007;再执行 upgradeGuest(guest, { platform: "ios", openId: "apple-7" }),playerId 保持 g-cn-000007,类型变成 registered,formatIdentity 输出 g-cn-000007 registered region=cn bindings=ios=apple-7。
模型里没有数据库、没有网络、也没有令牌。它只验证标识关系本身是否自洽:同一个 playerId 能否安全地增加绑定、同一个绑定键是否会被两个账号占用、游客转正后 playerId 是否保持不变。真正的登录态与令牌生命周期放在 2.2,两个已有账号的合并放在 2.3。
双案例:消消的渠道碎片与方舟的商店集中
《潮汐消消》案例设定中国内安卓 55%、iOS 30%、TapTap 8%、海外 7%,9 个渠道、11 个渠道包;标识层必须容忍同一玩家在多个平台各有 openId,同时保住 62% 的游客转正率。
《潮汐方舟》iOS 45%、国内安卓 40%、Google Play 15%,渠道更集中、游客转正率 88%,但强联网与 34% 的多端登录让绑定冲突更早暴露;它更需要「同一 playerId 多绑定」的清晰规则,而不是更多渠道适配。
代价与取舍:多一层映射换来什么
| 代价 | 来源 | 控制方法 |
|---|---|---|
| 多一张绑定表 | 每个平台凭据都要落库 | 复合键唯一索引,写入走同一入口 |
| 多一次查询 | 登录要先由绑定找 playerId | 缓存热点绑定,缓存失效时回源 |
| 游客数据长期保留 | 未转正账号也占存储 | 按活跃度归档,不删除 playerId |
| 规则更复杂 | 转正、绑定、解绑各有状态 | 把状态机写进模型与测试,不靠口头约定 |
这套设计牺牲的是「一个 openId 直接当账号」的简单性,换来的是换机、换渠道、合服和申诉时不需要重建身份。对休闲产品,游客转正率越高,这笔账越划算;对强联网产品,多端登录占比越高,越不能省掉绑定层。
与相邻章节的关系
本节只定义「谁是谁」:设备、游客、平台账号与 playerId 的关系,以及游客转正和平台绑定的规则。登录态如何签发、刷新、过期和互踢,放在 2.2;两个已有账号如何合并、如何风控与人工复核,放在 2.3。三节合起来才是完整的账号体系。
小结
- 设备、游客、平台账号、全服 playerId 是四层不同标识,只有后两层是账号,playerId 才承载资产。
- 平台绑定键必须是
平台:openId复合键,openId 不能单独当主键,也不能跨平台比较。 - 游客是服务端签发的真实身份,转正时 playerId 不变、只增加绑定并切换类型。
- 绑定关系要全局唯一、同平台唯一、只增不减;解绑是标记失效,不是删除记录。
- 三端互通互通的是 playerId 与资产,登录态必须按设备隔离,交给 2.2 处理。
阅读导航:上一节:1.3 mga-biz-lab 的商业化基线 · 下一节:2.2 登录态、令牌刷新与多端互踢 。
常见错误与边界
- 把设备号当 playerId。设备号会重置、会重复、可能拿不到,换机后玩家会被当成新用户,进度和订单无法归户。
- 直接拿 openId 当主键。不同平台的 openId 空间独立,字符串相同不代表同一人,合服时必然冲突。
- 游客只在本地存随机串。卸载或清数据后服务端没有可恢复的 playerId,客服只能人工补发,且无法证明归属。
- 转正时新建账号再搬资产。迁移中途失败会留下两个半账号,订单号、活动资格和客服工单全部对不上。
- 允许同一 playerId 在同一平台绑两个 openId。后续登录无法判断该用哪个凭据,踢人与刷新都会出现歧义。
- 解绑时直接删除绑定行。原 openId 会重新变成可用键,可能被另一个账号绑定,形成账号劫持。
- 把三端互通做成 token 共享。一台设备的刷新会顶掉另一台,复用检测还会把正常多端误判为盗用。
- 转正失败时自动并入对方账号。绑定键被占用说明存在另一个真实账号,自动合并等于绕过申诉与风控。
- 平台名不做归一化。渠道展示名一变,绑定键就变,历史绑定全部失配;必须使用固定枚举。
- 边界:本节不处理会话与合并。令牌签发、过期、刷新与互踢属于 2.2,两个已有账号的资产合并属于 2.3;把三者塞进一个模块会让状态机无法测试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。