《移动游戏产品的架构·商业化与发行工程》2.3 账号迁移、合并与风控

游客转正、账号迁移与账号合并是三件不同的事,混为一谈会让绑定关系、资产与风控同时失控。本节用《潮汐消消》每月 1,200 起与《潮汐方舟》每月 400 起账号合并申诉,拆开主账号选择、资产合并、风险信号与人工复核带,并给出可运行的合并计划与决策输出。

问题:转正、迁移、合并是三件事

玩家说「我账号没了」,实际可能是三种完全不同的情况。第一种是游客还没绑定平台,换机后找不到进度,这是转正问题;第二种是同一个平台账号换了设备,或者从安卓换到 iOS,这是迁移问题;第三种是玩家确实拥有两个都充过钱的账号,希望把资产并到一个号上,这是合并问题。

三件事的处理边界完全不同。转正只涉及一个 playerId 和第一个平台绑定;迁移主要验证「新设备上的平台账号就是原来那个人」;合并则要在两个都已存在的 playerId 之间搬绑定、搬资产,并且必须回答「谁是主账号」「冲突资产怎么算」「合并后能否回滚」。

把三者塞进同一个「账号找回」入口,最直接的后果是客服无法判断该走哪条规则:该转正的被引导去合并,该合并的被当成盗号拒绝,该迁移的被要求提供另一套材料。案例设定中《潮汐消消》每月账号合并申诉 1,200 起,《潮汐方舟》每月 400 起;申诉量越大,入口分流越重要。

什么时候需要合并

合并的前提是「两个 playerId 都已经存在,且都有绑定或资产」。如果其中一个还是游客,优先走 2.1 的转正:保留游客 playerId,给它加一个平台绑定即可,不需要合并。如果两个账号属于不同玩家,正确结果是拒绝,而不是「各退一步」把资产相加。

场景是否合并正确动作
游客换机后找不到进度否转正或在原设备恢复游客凭据
同一平台账号换设备否重新登录,签发新会话
安卓账号想在 iOS 继续玩视情况优先加绑定;两个正式账号才考虑合并
两个正式账号都有充值是进入合并计划与风控
一个正式账号加一个游客否游客绑定到正式账号或反向转正
无法证明两个账号属于同一人否拒绝并保留申诉记录

判断「同一人」不能只看玩家自述。至少要能对上平台凭据、设备历史、支付凭证或实名信息中的若干项。对不上时,宁可进入人工复核,也不要把两个账号的资产直接相加;错误合并的修复成本远高于多一次人工审核。

合并计划:选主、搬绑定、合资产

合并计划分三步,顺序不能反。先选主账号,再搬绑定,最后合资产。先合资产再选主,会出现「资产已经加到一个即将被弃用的账号上」的中间态;先搬绑定再选主,则可能把主账号的平台凭据搬到一个低价值账号上。

步骤规则输出
选主充值更高者优先;相同则创建更早者;再相同按 playerId 稳定排序primaryId 与 secondaryId
搬绑定两个账号的绑定取并集并去重合并后的绑定列表
合资产按币种策略与道具策略逐项计算合并后的货币与道具
风控按信号打分并分带自动、人工或拒绝
执行只对通过决策的计划落库审计记录与回滚点

选主不是「谁申诉就选谁」。如果玩家当前想保留低充值账号,但高充值账号才是主账号,资产仍然应该以高充值账号为主;玩家的偏好可以体现在「保留哪个昵称」这类非资产字段上,不能改变资产归属规则。

绑定取并集时要保留顺序与去重。两个账号可能绑定了同一个 TapTap 账号,这说明它们本来就可能属于同一人,但也可能是历史数据错误;合并后只能保留一份绑定,不能出现同一平台两个 openId 指向同一 playerId。

资产合并:先定策略,再谈自动化

资产合并不能用一个「相加」打天下。付费货币与免费货币的性质不同,道具与进度的冲突方式也不同。至少要给每个币种配置策略:sum 相加、max 取大、primary 只保留主账号。

资产类型推荐策略理由
付费货币sum玩家真实付过钱,合并时不应损失
免费货币max避免小号刷取后合并放大
游戏币sum 或 primary按经济系统是否允许跨账号转移决定
道具max同一道具按数量取大,避免重复叠加
进度/关卡取更高进度进度不能简单相加,保留更靠前的状态
限时资格以主账号为准资格与活动绑定,合并后可能产生双重权益

策略必须在合并前确定并写进配置,不能在执行时临时判断。临时判断会让同一类申诉得到不同结果,客服无法解释,测试也无法覆盖。策略确定后,合并计划只负责计算,不负责解释「为什么这个币种相加、那个币种取大」。

付费货币选择 sum 的代价是可能被小额充值刷号放大,所以它必须和风控一起用:如果两个账号的支付凭证高度重合、设备也相同,合并风险反而更低;如果两个账号来源完全不同,即使金额不大,也应该进入人工复核。

风控信号:把「像本人」拆开

「像本人」不是一个可以打分的直觉,而要拆成可观测信号。模型里用六类信号:同设备、同支付工具、近期改密、地理位置跳变、近 30 天申诉次数、账号年龄。每一类给一个权重,总分封顶 100。

信号权重(模型参数)说明
同设备15两个账号曾在同一设备登录
同支付工具20支付凭证或订单指纹重合
近期改密20短时间内修改过凭据,可能是盗号后操作
地理位置跳变25登录地跨区域,且与历史轨迹不符
近 30 天申诉次数每次 5,封顶 15高频申诉本身是风险信号
账号年龄小于 7 天15新号合并的套利空间更大

权重是模型参数,不是案例数据。它们的作用是把「像本人」拆成可讨论的项,而不是宣称某个分数等于某种概率。真实系统还要结合实名信息、设备指纹、支付渠道反馈和人工经验调整。

未实名的账号单独加 20 分并强制进入人工复核。实名信息是最强的归属证据之一;两个账号只要有一个没实名,就不应该自动合并,否则等于绕过了实名体系。

决策带:自动、人工、拒绝

总分决策带动作
0–29auto自动合并,记录审计
30–69review进入人工复核,冻结合并
70–100reject拒绝合并,保留申诉材料

未实名会把分数加 20 并强制进入 review,即使总分仍落在 0–29 区间;这张表描述的是未触发强制复核时的基础分带。

阈值同样是模型参数。auto 不是「没有风险」,而是「风险在可接受范围内」;review 不是「有罪」,而是「证据不足以自动处理」;reject 也不是永久拒绝,玩家可以补充材料后重新申诉。

决策带要和客服系统对齐。自动合并要能看到计划明细,人工复核要能看到信号列表与原始证据,拒绝要能给出可解释的理由。只返回一个分数,客服无法向玩家解释,也无法在误判时快速定位。

可运行模型:account-merge.ts

模型接收两个账号、一组风险信号和一份策略,输出合并计划:主账号、绑定并集、资产结果、风险分、决策带与原因列表。所有输入都是只读的,计划本身不落库,执行层拿到计划后再做事务与回滚。

  • choosePrimary 按充值、创建时间、playerId 依次排序,保证同样的输入永远得到同样的主账号。
  • scoreRisk 把六类信号转成分数与原因,方便客服看到「为什么是这个分」。
  • planMerge 合并绑定与资产,叠加未实名惩罚,再按阈值分带。
  • assertMergeAccount 在执行前拒绝空 playerId、负数资产等非法输入。
  • formatMergePlan 输出一行可读计划,便于日志与测试断言。
// file: src/biz/account-merge.ts
// tier: L1
// verify: node --test test/biz/account-merge.test.ts

export type CurrencyStrategy = "sum" | "max" | "primary";
export type ItemStrategy = "sum" | "max";
export type RiskBand = "auto" | "review" | "reject";
export type MergeDecision = "auto_merge" | "manual_review" | "reject";

export interface MergeAccount {
  readonly playerId: string;
  readonly createdAt: number;
  readonly lastLoginAt: number;
  readonly verified: boolean;
  readonly totalPaidCents: number;
  readonly bindings: readonly string[];
  readonly currencies: Readonly<Record<string, number>>;
  readonly items: Readonly<Record<string, number>>;
}

export interface MergePolicy {
  readonly currency: Readonly<Record<string, CurrencyStrategy>>;
  readonly itemStrategy: ItemStrategy;
  readonly reviewScore: number;
  readonly rejectScore: number;
}

export interface RiskSignals {
  readonly sameDevice: boolean;
  readonly samePaymentInstrument: boolean;
  readonly recentCredentialChange: boolean;
  readonly geoJump: boolean;
  readonly appealCount30d: number;
  readonly accountAgeDays: number;
}

export interface MergePlan {
  readonly primaryId: string;
  readonly secondaryId: string;
  readonly bindings: readonly string[];
  readonly currencies: Readonly<Record<string, number>>;
  readonly items: Readonly<Record<string, number>>;
  readonly riskScore: number;
  readonly band: RiskBand;
  readonly reasons: readonly string[];
  readonly decision: MergeDecision;
}

export const DEFAULT_POLICY: MergePolicy = {
  currency: { paid_gem: "sum", free_gem: "max", coin: "sum" },
  itemStrategy: "max",
  reviewScore: 30,
  rejectScore: 70,
};

export class MergeError extends Error {
  readonly code: string;

  constructor(code: string, message: string) {
    super(message);
    this.name = "MergeError";
    this.code = code;
  }
}

function fail(code: string, message: string): never {
  throw new MergeError(code, message);
}

export function assertMergeAccount(account: MergeAccount): void {
  if (account.playerId.trim().length === 0) {
    fail("INVALID_ACCOUNT", "playerId must not be empty");
  }
  if (!Number.isFinite(account.totalPaidCents) || account.totalPaidCents < 0) {
    fail("INVALID_ACCOUNT", "totalPaidCents must be a non-negative finite number");
  }
  for (const [key, value] of Object.entries(account.currencies)) {
    if (!Number.isFinite(value) || value < 0) {
      fail("INVALID_ASSET", `currency ${key} must be a non-negative finite number`);
    }
  }
  for (const [key, value] of Object.entries(account.items)) {
    if (!Number.isFinite(value) || value < 0) {
      fail("INVALID_ASSET", `item ${key} must be a non-negative finite number`);
    }
  }
}

export function choosePrimary(first: MergeAccount, second: MergeAccount): [MergeAccount, MergeAccount] {
  if (first.playerId === second.playerId) {
    fail("SAME_ACCOUNT", "cannot merge an account with itself");
  }
  if (first.totalPaidCents !== second.totalPaidCents) {
    return second.totalPaidCents > first.totalPaidCents ? [second, first] : [first, second];
  }
  if (first.createdAt !== second.createdAt) {
    return first.createdAt < second.createdAt ? [first, second] : [second, first];
  }
  return first.playerId < second.playerId ? [first, second] : [second, first];
}

export function scoreRisk(signals: RiskSignals): { score: number; reasons: string[] } {
  let score = 0;
  const reasons: string[] = [];
  if (signals.sameDevice) {
    score += 15;
    reasons.push("same_device");
  }
  if (signals.samePaymentInstrument) {
    score += 20;
    reasons.push("same_payment_instrument");
  }
  if (signals.recentCredentialChange) {
    score += 20;
    reasons.push("recent_credential_change");
  }
  if (signals.geoJump) {
    score += 25;
    reasons.push("geo_jump");
  }
  const appeals = Math.max(0, Math.floor(signals.appealCount30d));
  const appealScore = Math.min(15, appeals * 5);
  if (appealScore > 0) {
    score += appealScore;
    reasons.push("appeal_frequency");
  }
  if (signals.accountAgeDays < 7) {
    score += 15;
    reasons.push("young_account");
  }
  return { score: Math.min(100, score), reasons };
}

function mergeNumberMaps(
  primary: Readonly<Record<string, number>>,
  secondary: Readonly<Record<string, number>>,
  strategyFor: (key: string) => CurrencyStrategy | ItemStrategy,
): Record<string, number> {
  const keys = new Set([...Object.keys(primary), ...Object.keys(secondary)]);
  const merged: Record<string, number> = {};
  for (const key of [...keys].sort()) {
    const primaryValue = primary[key] ?? 0;
    const secondaryValue = secondary[key] ?? 0;
    const strategy = strategyFor(key);
    if (strategy === "sum") {
      merged[key] = primaryValue + secondaryValue;
    } else if (strategy === "primary") {
      merged[key] = primaryValue;
    } else {
      merged[key] = Math.max(primaryValue, secondaryValue);
    }
  }
  return merged;
}

function mergeBindings(primary: readonly string[], secondary: readonly string[]): string[] {
  return [...new Set([...primary, ...secondary])];
}

export function planMerge(
  first: MergeAccount,
  second: MergeAccount,
  signals: RiskSignals,
  policy: MergePolicy = DEFAULT_POLICY,
): MergePlan {
  assertMergeAccount(first);
  assertMergeAccount(second);
  const [primary, secondary] = choosePrimary(first, second);
  const bindings = mergeBindings(primary.bindings, secondary.bindings);
  const currencies = mergeNumberMaps(
    primary.currencies,
    secondary.currencies,
    (key) => policy.currency[key] ?? "max",
  );
  const items = mergeNumberMaps(primary.items, secondary.items, () => policy.itemStrategy);

  const risk = scoreRisk(signals);
  const reasons = [...risk.reasons];
  const unverified = !primary.verified || !secondary.verified;
  let riskScore = risk.score;
  if (unverified) {
    riskScore = Math.min(100, riskScore + 20);
    reasons.push("unverified_account");
  }

  let band: RiskBand;
  let decision: MergeDecision;
  if (riskScore >= policy.rejectScore) {
    band = "reject";
    decision = "reject";
  } else if (unverified || riskScore >= policy.reviewScore) {
    band = "review";
    decision = "manual_review";
  } else {
    band = "auto";
    decision = "auto_merge";
  }

  return {
    primaryId: primary.playerId,
    secondaryId: secondary.playerId,
    bindings,
    currencies,
    items,
    riskScore,
    band,
    reasons,
    decision,
  };
}

export function formatMergePlan(plan: MergePlan): string {
  const currencies = Object.entries(plan.currencies)
    .map(([key, value]) => `${key}=${value}`)
    .join(",");
  const bindings = plan.bindings.join("|") || "none";
  return `${plan.primaryId}<-${plan.secondaryId} band=${plan.band} score=${plan.riskScore} decision=${plan.decision} bindings=${bindings} currencies=${currencies}`;
}

模型不处理「如何证明两个账号属于同一人」,也不处理支付渠道的退款与对账。它只回答:给定两个账号和一组信号,合并计划长什么样、风险分落在哪个带、需要自动执行还是人工复核。

双案例:消消的申诉量与方舟的账号价值

《潮汐消消》案例设定中每月账号合并申诉 1,200 起,多端登录用户占 18%,月 ARPPU 为 ¥68。申诉量大、单账号价值相对低,更适合把低风险合并自动化,把人工资源留给高分与证据冲突的案件。

《潮汐方舟》每月账号合并申诉 400 起,多端登录占 34%,月 ARPPU 为 ¥420。申诉量小但单账号价值高,自动合并的容错空间更小;同样的风险分,方舟更适合进入人工复核,而不是直接自动执行。

代价与取舍:自动化省下的与赔掉的

自动合并省的是客服工时与玩家等待时间,赔的是误判后的修复成本。两个账号一旦合并,资产、订单、活动资格和排行榜记录都会指向同一个 playerId;如果事后发现是两个人,拆分不只是把数字减回去,还要处理已经消费的货币、已经参与的活动和已经产生的订单。

因此自动合并必须保留回滚点:合并前的两个账号快照、绑定列表、资产明细、风险信号与决策原因都要落审计。回滚窗口可以按产品设定,但窗口内要能暂停后续写操作,否则回滚会和新的充值、消费纠缠在一起。

人工复核的成本也不能低估。1,200 起申诉里即使只有一部分进入人工,每起都要看设备、支付、实名与历史记录;如果信号列表不可解释,复核就会退化成「凭感觉点通过或拒绝」。模型输出原因列表,正是为了让复核有据可查。

回滚与审计

合并执行前先写审计记录,再改数据;执行失败时按审计记录恢复。审计至少要包含:两个 playerId、主账号选择依据、绑定并集、每个资产的合并前后值、风险分与原因、决策带、操作人与时间。

回滚不是「再合并一次反向操作」。合并后可能已经产生新的充值、消费、活动参与,简单反向相减会得到负数或重复权益。正确做法是冻结相关写入、按快照恢复账号,再把合并后新增的合法资产单独挂账处理;这部分必须由人工确认,不能自动执行。

小结

  • 转正、迁移、合并是三件事:转正保留 playerId 加绑定,迁移重新登录,合并才涉及两个已有账号的资产。
  • 合并先选主账号,再搬绑定,最后合资产;主账号按充值、创建时间、playerId 稳定排序。
  • 资产按类型配置 sum、max、primary 策略,付费货币与免费货币不能用同一套规则。
  • 风控把「像本人」拆成设备、支付、改密、地理、申诉、账号年龄六类信号,未实名强制人工复核。
  • 自动合并必须保留快照、审计与回滚窗口;回滚不是反向相减,而是冻结写入后按快照恢复。

阅读导航:上一节:2.2 登录态、令牌刷新与多端互踢 · 下一节:3.1 渠道矩阵:国内安卓、TapTap 与海外商店 。

常见错误与边界

  1. 把游客转正当成账号合并。游客还没有第二个正式账号,走合并会凭空创建一个 playerId,进度与订单反而对不上。
  2. 谁申诉就选谁当主账号。资产归属会被玩家偏好左右,高充值账号可能被弃用,后续对账与客服解释都会失效。
  3. 先合资产再选主账号。资产已经加到低价值账号上,再改主账号就要二次搬运,中间态无法回滚。
  4. 所有资产都用相加。免费货币与限时资格被小号放大,经济系统和活动权益同时失控。
  5. 只看总分不看原因。客服无法解释为什么拒绝,误判时也找不到是哪条信号触发,复核退化成拍脑袋。
  6. 未实名账号也自动合并。实名是最强的归属证据之一,跳过它等于给盗号与套利留通道。
  7. 合并后不保留快照。发现误判时无法恢复原账号,只能人工补发,订单与排行榜记录无法复原。
  8. 回滚用反向相减。合并后新增的充值、消费与活动参与会让反向计算出现负数或重复权益,必须按快照恢复。
  9. 绑定并集不去重。同一平台两个 openId 指向同一 playerId,后续登录与刷新无法判断该用哪个凭据。
  10. 边界:本节不处理登录态与令牌。会话签发、刷新与互踢属于 2.2;合并计划只产出资产与绑定结果,不负责签发新令牌。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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