问题:转正、迁移、合并是三件事
玩家说「我账号没了」,实际可能是三种完全不同的情况。第一种是游客还没绑定平台,换机后找不到进度,这是转正问题;第二种是同一个平台账号换了设备,或者从安卓换到 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–29 | auto | 自动合并,记录审计 |
| 30–69 | review | 进入人工复核,冻结合并 |
| 70–100 | reject | 拒绝合并,保留申诉材料 |
未实名会把分数加 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 与海外商店 。
常见错误与边界
- 把游客转正当成账号合并。游客还没有第二个正式账号,走合并会凭空创建一个 playerId,进度与订单反而对不上。
- 谁申诉就选谁当主账号。资产归属会被玩家偏好左右,高充值账号可能被弃用,后续对账与客服解释都会失效。
- 先合资产再选主账号。资产已经加到低价值账号上,再改主账号就要二次搬运,中间态无法回滚。
- 所有资产都用相加。免费货币与限时资格被小号放大,经济系统和活动权益同时失控。
- 只看总分不看原因。客服无法解释为什么拒绝,误判时也找不到是哪条信号触发,复核退化成拍脑袋。
- 未实名账号也自动合并。实名是最强的归属证据之一,跳过它等于给盗号与套利留通道。
- 合并后不保留快照。发现误判时无法恢复原账号,只能人工补发,订单与排行榜记录无法复原。
- 回滚用反向相减。合并后新增的充值、消费与活动参与会让反向计算出现负数或重复权益,必须按快照恢复。
- 绑定并集不去重。同一平台两个 openId 指向同一 playerId,后续登录与刷新无法判断该用哪个凭据。
- 边界:本节不处理登录态与令牌。会话签发、刷新与互踢属于 2.2;合并计划只产出资产与绑定结果,不负责签发新令牌。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。