《移动游戏产品的架构·商业化与发行工程》2.1 游客、账号与三端互通的标识设计

游客、设备号、平台 openId 与全服 playerId 是四层不同的标识,混用会把登录、迁移和合服一起拖进泥潭。本节用《潮汐消消》的渠道占比与 62% 游客转正率,拆开标识命名空间、绑定关系与游客转正路径,给出可运行的标识索引与校验函数,并划清它与登录态、账号合并的边界。

问题:一个「用户」在系统里有几个名字

《潮汐消消》案例设定中 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_cn55%40%
iOSios30%45%
TapTaptaptap8%—
海外overseas7%—
Google Playgoogle_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 登录态、令牌刷新与多端互踢 。

常见错误与边界

  1. 把设备号当 playerId。设备号会重置、会重复、可能拿不到,换机后玩家会被当成新用户,进度和订单无法归户。
  2. 直接拿 openId 当主键。不同平台的 openId 空间独立,字符串相同不代表同一人,合服时必然冲突。
  3. 游客只在本地存随机串。卸载或清数据后服务端没有可恢复的 playerId,客服只能人工补发,且无法证明归属。
  4. 转正时新建账号再搬资产。迁移中途失败会留下两个半账号,订单号、活动资格和客服工单全部对不上。
  5. 允许同一 playerId 在同一平台绑两个 openId。后续登录无法判断该用哪个凭据,踢人与刷新都会出现歧义。
  6. 解绑时直接删除绑定行。原 openId 会重新变成可用键,可能被另一个账号绑定,形成账号劫持。
  7. 把三端互通做成 token 共享。一台设备的刷新会顶掉另一台,复用检测还会把正常多端误判为盗用。
  8. 转正失败时自动并入对方账号。绑定键被占用说明存在另一个真实账号,自动合并等于绕过申诉与风控。
  9. 平台名不做归一化。渠道展示名一变,绑定键就变,历史绑定全部失配;必须使用固定枚举。
  10. 边界:本节不处理会话与合并。令牌签发、过期、刷新与互踢属于 2.2,两个已有账号的资产合并属于 2.3;把三者塞进一个模块会让状态机无法测试。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查