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