《移动游戏产品的架构·商业化与发行工程》8.1 支付作弊与退款滥用

支付作弊、退款滥用与拒付是三条不同的账。本节以《潮汐消消》月订单 42 万笔、退款率 1.2%、支付欺诈率 0.08% 为背景,区分三类问题的证据链、判定权与处置方式,把账户历史压成可复核的分数,并给出冻结、复核与放行之间的边界,以及双案例下复核优先级的差异。

支付成功之后,风险才刚开始

4.3 讨论的是「这笔钱有没有真的付」。凭证校验通过,订单进入账本,发货完成,那条链路就结束了。本节从账本之后开始:这笔真实发生的支付,是否来自一个正常玩家;退款请求是合理售后,还是被当成提款机;渠道发来的拒付,应该由谁承担。

把问题往后挪一步,会发现三种完全不同的风险混在一起:

  • 支付作弊:用伪造、盗用或篡改的支付手段,让一笔不存在的钱变成道具。
  • 退款滥用:钱确实付过,但玩家反复购买、使用、再退款,把售后政策变成免费试用。
  • 拒付与坏账:玩家向发卡行或平台发起争议,渠道先把钱划走,游戏再被扣一笔争议处理费。

它们的共同点是都发生在支付链路上,区别在于证据在哪里、谁有判定权、钱能不能追回。把三者塞进同一个「风控分」,最后往往既抓不到作弊,也拦不住滥用。

三类问题,不要混成一本账

问题核心证据判定权资金结果常见误判
支付作弊凭证、设备、账号关联、商品交付记录游戏服务端 + 渠道未结算或可申诉追回把正常大额玩家当成盗刷
退款滥用订单、退款原因、消耗记录、时间间隔游戏服务端 + 商店售后已结算后退回把合理售后当成恶意退款
拒付与坏账发卡行争议材料、渠道通知发卡行 / 平台强制划扣,可能加收费用把渠道规则当成玩家行为

先看支付作弊。它的本质是「支付凭证与真实付款人不一致」,可能来自盗刷、黑卡、代充洗钱,也可能来自渠道侧的伪造回调。4.3 的五道校验负责挡住伪造报文;本节关心的是通过校验之后,账号行为是否仍然异常。

再看退款滥用。它的本质是「利用售后规则套利」,典型模式是同一账号在短时间内多次购买同一档位、使用后申请退款,或者把退款当成免费抽卡。退款本身是合法权利,单笔退款不能说明问题,必须看账户在窗口内的退款密度与金额。

最后是拒付。拒付的判定权不在游戏手里,玩家向发卡行发起争议后,渠道通常先扣款,游戏再提交证据申诉。申诉成功率取决于订单凭证、交付记录和沟通记录是否完整。拒付率上升,往往不是玩家突然变坏,而是支付方式、地区或商品描述发生了变化。

比例乘上订单量才是工作量

事实表里的比例都很小,但乘上月订单量之后,没有任何一条可以忽略:

指标消消(42 万笔/月)方舟(15 万笔/月)
掉单率 0.35% / 0.20%约 1,470 笔约 300 笔
退款率 1.2% / 0.8%约 5,040 笔约 1,200 笔
支付欺诈率 0.08% / 0.03%约 336 笔约 45 笔

消消的欺诈笔数是方舟的七倍多,但方舟的单笔金额更大。把笔数乘上平均客单价,会得到另一个排序:

指标消消方舟
平均客单价¥14.5¥182
月欺诈订单约 336 笔约 45 笔
月欺诈金额(按客单价估算)约 ¥4,872约 ¥8,190
月退款订单约 5,040 笔约 1,200 笔
月退款金额(按客单价估算)约 ¥73,080约 ¥218,400

这组数字解释了一个常见误判:团队看到消消的欺诈笔数更多,就把风控资源全压到休闲游戏;实际上方舟用七分之一的笔数,产生了更高的金额暴露。笔数决定人工工单量,金额决定复核优先级,两者不能互相替代。

还要注意「按客单价估算」这几个字。真实退款金额取决于具体商品档位:消消的主力档位是 ¥18,占订单 46%;方舟的主力档位是 ¥128,占订单 38%。用平均客单价只能做量级判断,不能直接拿去对账。对账必须回到订单明细。

判定权在服务端,证据在渠道

支付风控最常见的架构错误,是让客户端参与判定。客户端能提供设备指纹、登录态和本地行为,但它不能证明支付凭证真实,也不能证明退款是否合理。更危险的是,客户端上报的「异常信号」可以被改包工具重放或伪造,把它当成证据,等于让攻击者决定自己是否可疑。

合理的分工是:

  • 客户端只上报事实:设备标识、安装来源、登录账号、页面停留、订单发起时间。上报要带签名,但不参与最终判定。
  • 服务端做关联:把订单、账号、设备、支付方式、退款记录放在同一个时间窗口里看,计算密度和金额。
  • 渠道提供支付侧事实:凭证状态、退款状态、拒付通知、争议结果。渠道不提供「这个玩家是否作弊」的结论,只提供资金事实。
  • 人工复核做最终处置:分数只负责排序,冻结、封禁、退款驳回都必须有人确认或可申诉。

这条分工也决定了证据的保存方式。订单快照、退款原因、设备关联、客服沟通记录要能按账号和时间检索;否则等到拒付申诉时,只能提交一张截图。证据链不完整,比例再低也会变成实际损失。

单笔金额改变复核顺序

风控系统的第一版通常按「命中规则就封」设计,上线后很快会遇到两个问题:误杀正常玩家,以及客服被工单淹没。更可用的做法是先分级,再决定动作。

本节示例把账户级评估分成三个动作:

动作含义适用场景玩家感受
allow正常放行分数低于复核线无感
review进入人工队列,支付与发货照常分数在复核区间可能收到验证请求
freeze暂停新支付,已有订单不动分数超过冻结线,或出现拒付需要客服介入

freeze 不是封号。封号影响登录、社交和已有资产,误杀成本极高;冻结新支付只影响增量,玩家仍能登录、消费已有道具,客服也有时间核实。把「停止继续损失」和「惩罚」分开,是支付风控最基本的边界。

分数由几个信号相加,每个信号都要能单独解释:

  • 窗口内退款笔数达到上限。
  • 退款率超过策略上限。
  • 退款率高于基线两倍,且订单数足够。
  • 退款金额达到上限。
  • 出现拒付,按笔数加权。
  • 短时间内的订单密度异常。
  • 同一订单被重复退款。

这些信号里,拒付最重,因为它已经进入渠道争议流程;重复退款次之,因为它通常意味着系统或人工出现了漏洞;订单密度再次之,因为它可能是正常的大额消费,也可能是盗刷测试。

把账户历史压成一个可复核的分数

下面的实现只做三件事:按窗口截取历史、计算信号、给出动作。它不查数据库、不调用渠道、不发货,因此可以用固定输入做确定性测试。生产实现负责取数,纯函数负责判断。

// file: src/biz/payment-fraud.ts
// tier: L1
// verify: node --test test/biz/payment-fraud.test.ts

export type FraudAction = "allow" | "review" | "freeze";
export type RiskLevel = "low" | "medium" | "high";

export interface PaymentOrder {
  readonly orderId: string;
  readonly amountCents: number;
  readonly paidAtMs: number;
  readonly productId: string;
}

export interface RefundRecord {
  readonly orderId: string;
  readonly amountCents: number;
  readonly refundedAtMs: number;
  readonly reason: "player-request" | "chargeback" | "channel-forced";
}

export interface AccountHistory {
  readonly accountId: string;
  readonly orders: readonly PaymentOrder[];
  readonly refunds: readonly RefundRecord[];
}

export interface FraudPolicy {
  readonly windowMs: number;
  readonly nowMs: number;
  readonly baselineRefundRate: number;
  readonly maxRefundRate: number;
  readonly maxRefundCount: number;
  readonly maxRefundAmountCents: number;
  readonly rapidWindowMs: number;
  readonly rapidOrderCount: number;
  readonly reviewScore: number;
  readonly freezeScore: number;
}

export interface Signal {
  readonly code: string;
  readonly points: number;
  readonly detail: string;
}

export interface FraudAssessment {
  readonly accountId: string;
  readonly orderCount: number;
  readonly refundCount: number;
  readonly refundRate: number;
  readonly refundedAmountCents: number;
  readonly score: number;
  readonly level: RiskLevel;
  readonly action: FraudAction;
  readonly signals: readonly Signal[];
}

export interface FraudExposure {
  readonly fraudOrders: number;
  readonly exposureCents: number;
}

export function estimateFraudExposure(
  ordersPerMonth: number,
  fraudRate: number,
  averageOrderCents: number,
): FraudExposure {
  const fraudOrders = Math.round(ordersPerMonth * fraudRate);
  return { fraudOrders, exposureCents: fraudOrders * averageOrderCents };
}

function inWindow(atMs: number, policy: FraudPolicy): boolean {
  return atMs <= policy.nowMs && policy.nowMs - atMs <= policy.windowMs;
}

function countRapidOrders(orders: readonly PaymentOrder[], policy: FraudPolicy): number {
  const recent = orders
    .filter((order) => inWindow(order.paidAtMs, policy))
    .map((order) => order.paidAtMs)
    .sort((left, right) => left - right);
  let best = 0;
  let start = 0;
  for (let end = 0; end < recent.length; end += 1) {
    while (recent[end]! - recent[start]! > policy.rapidWindowMs) {
      start += 1;
    }
    best = Math.max(best, end - start + 1);
  }
  return best;
}

export function assessRefundAbuse(
  history: AccountHistory,
  policy: FraudPolicy,
): FraudAssessment {
  const orders = history.orders.filter((order) => inWindow(order.paidAtMs, policy));
  const refunds = history.refunds.filter((refund) => inWindow(refund.refundedAtMs, policy));
  const refundedAmountCents = refunds.reduce((sum, refund) => sum + refund.amountCents, 0);
  const refundRate = orders.length === 0 ? 0 : refunds.length / orders.length;
  const signals: Signal[] = [];

  if (refunds.length >= policy.maxRefundCount) {
    signals.push({
      code: "refund-count",
      points: 25,
      detail: `${refunds.length} refunds in window, limit ${policy.maxRefundCount}`,
    });
  }
  if (refundRate > policy.maxRefundRate) {
    signals.push({
      code: "refund-rate",
      points: 25,
      detail: `refund rate ${(refundRate * 100).toFixed(2)}% exceeds ${(policy.maxRefundRate * 100).toFixed(2)}%`,
    });
  }
  if (orders.length >= 4 && refundRate > policy.baselineRefundRate * 2) {
    signals.push({
      code: "above-baseline",
      points: 10,
      detail: `refund rate ${(refundRate * 100).toFixed(2)}% is more than twice the baseline`,
    });
  }
  if (refundedAmountCents >= policy.maxRefundAmountCents) {
    signals.push({
      code: "refund-amount",
      points: 20,
      detail: `refunded ${refundedAmountCents} cents, limit ${policy.maxRefundAmountCents}`,
    });
  }
  const chargebacks = refunds.filter((refund) => refund.reason === "chargeback").length;
  if (chargebacks > 0) {
    signals.push({
      code: "chargeback",
      points: Math.min(30, chargebacks * 15),
      detail: `${chargebacks} chargeback(s) recorded`,
    });
  }
  const rapidOrders = countRapidOrders(orders, policy);
  if (rapidOrders >= policy.rapidOrderCount) {
    signals.push({
      code: "rapid-orders",
      points: 15,
      detail: `${rapidOrders} orders within ${policy.rapidWindowMs}ms`,
    });
  }
  const perOrder = new Map<string, number>();
  for (const refund of refunds) {
    perOrder.set(refund.orderId, (perOrder.get(refund.orderId) ?? 0) + 1);
  }
  const duplicated = [...perOrder.values()].filter((count) => count > 1).length;
  if (duplicated > 0) {
    signals.push({
      code: "duplicate-refund",
      points: 20,
      detail: `${duplicated} order(s) refunded more than once`,
    });
  }

  const score = Math.min(100, signals.reduce((sum, signal) => sum + signal.points, 0));
  const level: RiskLevel = score >= policy.freezeScore ? "high" : score >= policy.reviewScore ? "medium" : "low";
  const action: FraudAction = level === "high" ? "freeze" : level === "medium" ? "review" : "allow";
  return {
    accountId: history.accountId,
    orderCount: orders.length,
    refundCount: refunds.length,
    refundRate,
    refundedAmountCents,
    score,
    level,
    action,
    signals,
  };
}

export function formatFraudAssessment(assessment: FraudAssessment): string {
  const percent = (assessment.refundRate * 100).toFixed(2);
  const codes = assessment.signals.map((signal) => signal.code).join(",") || "none";
  return `${assessment.accountId} ${assessment.action} score=${assessment.score} refundRate=${percent}% signals=${codes}`;
}

代码里有几个刻意的选择。

第一,windowMs 是显式参数,不写死 30 天。退款政策的窗口可能随渠道变化,消消和方舟的售后规则也不完全相同;把窗口放进策略,才能在配置层调整。

第二,退款率用「窗口内退款笔数 / 窗口内订单笔数」,而不是「历史总退款 / 历史总订单」。后者会被老玩家的大量正常订单稀释,让新注册的套利账号看起来更可疑,也让长期玩家的一次异常难以暴露。

第三,maxRefundRate 用严格大于,maxRefundCount 和 maxRefundAmountCents 用大于等于。这不是随意选择:笔数和金额是硬上限,达到就触发;比率是统计量,正好等于阈值时留一点余量,避免边界抖动。

第四,重复退款单独计分。同一订单被退款两次,通常不是玩家行为,而是回调重复、客服误操作或渠道重试。它应该触发的是系统排查,而不是简单把账号拉黑。

第五,formatFraudAssessment 只输出一行稳定文本,供日志、告警和客服工具使用。它不面向玩家;玩家看到的应该是「支付处理中」或「请联系客服」,而不是 refund-rate 这种内部代码。

分数只负责排序,不负责定罪

一个常见的误解是:分数到了 60 就应该自动封禁。实际上,分数的作用是把有限的人工复核能力分配到最需要的地方,而不是替代判断。

以消消为例,月欺诈订单约 336 笔,退款订单约 5,040 笔。如果所有退款都进人工,按每单 12 分钟计算,每天需要处理约 240 笔,占用大量客服工时;如果只把高分账户送进复核,工单量会降到可管理范围,同时保留对高风险账户的覆盖。

方舟的情况相反:月退款订单约 1,200 笔,笔数少,但客单价高。同样的分数阈值,方舟更适合「低阈值、多复核」,因为放过一笔 ¥648 的欺诈,损失接近消消的四十笔小额订单。

因此阈值不应该跨产品复用。消消可以把复核线设在 30、冻结线设在 60;方舟可以把复核线降到 20,冻结线仍设在 60,但冻结后的客服响应时限更短。这些数字是策略参数,不是行业标准。

还有一个边界:分数只基于支付与退款历史,不包含广告作弊、登录异常和内容违规。跨业务域的信号可以进入同一个风控中台,但不能直接拼成一个分数,否则一个广告点击异常可能导致玩家无法充值。8.3 会讨论规则引擎如何把不同来源的信号拆成独立规则。

取舍:严格阈值把成本推给客服

每收紧一次阈值,都会同时减少欺诈和增加误杀。把退款率上限从 6% 降到 3%,能更早拦住套利账号,但也会把一批正常售后的玩家送进复核;把冻结线从 60 降到 40,能减少资金损失,但客服工单和申诉量会上升。

案例里的做法是「默认复核,谨慎冻结」:

  • 新账号、新设备、首次退款,优先进入复核,不直接冻结。
  • 出现拒付、重复退款、设备聚集,才允许自动冻结。
  • 冻结必须带到期时间,默认 24 小时,到期自动解除,除非人工续期。
  • 每次冻结都记录规则 id、分数和信号明细,供申诉时复核。

代价是系统更复杂:需要队列、SLA、申诉入口和审计日志。收益是误杀可逆。支付风控里,最贵的不是多花几分钟复核,而是把一个正常大额玩家永久挡在门外,再花几倍成本把他请回来。

小结

  • 支付作弊、退款滥用、拒付是三条账,证据、判定权和资金结果都不同,不能用一个分数处理。
  • 比例要乘上订单量看工作量,再乘上客单价看金额暴露;消消的欺诈笔数多,方舟的欺诈金额更高。
  • 客户端只上报事实,服务端做关联,渠道提供资金事实,人工复核做最终处置。
  • 账户评分按窗口计算退款笔数、退款率、退款金额、拒付、订单密度和重复退款,输出 allow、review、freeze 三种动作。
  • 分数只负责排序;冻结只停增量支付,必须可到期、可申诉、可审计。

阅读导航:上一节:7.3 买量 ROI 与回收周期 · 下一节:8.2 广告作弊与点击农场 。

常见错误与边界

  1. 把支付作弊和退款滥用放进同一个规则。两者的证据链和处置权不同,混在一起会让正常退款被当成盗刷,也会让套利账号绕过退款专项规则。
  2. 让客户端上报「是否可疑」并参与判定。客户端信号可被改包和重放,只能作为事实输入,不能作为结论。
  3. 用历史总退款率代替窗口退款率。老玩家的正常订单会稀释比率,新注册的套利账号反而更容易漏过。
  4. 退款率阈值用大于等于。统计量在边界上会抖动,正好等于阈值时容易产生大量临界告警;笔数和金额才适合用硬上限。
  5. 把冻结当成封号。封号影响登录和已有资产,误杀成本不可逆;冻结新支付只停增量,应该默认带到期时间。
  6. 只按笔数分配风控资源。消消的欺诈笔数多,方舟的单笔金额高,笔数和金额要分别看待。
  7. 退款金额直接用平均客单价对账。平均客单价只能估量级,真实退款要看订单明细和商品档位。
  8. 同一订单重复退款只处理玩家。重复退款往往来自回调重试、客服误操作或渠道重试,应该同时排查系统。
  9. 拒付发生后才发现没有证据。订单快照、交付记录和沟通记录要在支付时就留存,申诉时补不出来。
  10. 阈值跨产品复制。消消和方舟的客单价、退款率、订单量都不同,同一组阈值会产生完全不同的误杀率。
  11. 分数到了就自动永久封禁。分数只负责排序,永久处置必须有人工确认或可申诉通道。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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