Appearance
收款码被异地盗刷?用 IP 归属地给商户收单加一道校验(PHP 实现)
开网店的老板半夜收到提示:一笔 4800 元的订单来自千里之外,收货地址却在自己店铺旁边,买家账号刚注册 10 分钟。这种单子,要么是盗刷(钱是偷来的卡付的),要么是刷单洗钱——货发出去,钱迟早被追回,亏的还是自己。
平台会做收单风控,但商户自己的系统(私域小程序、自建商城、聚合支付回调对接)一样该有层校验。最便宜有效的一招:下单或支付时记录买家 IP 的归属地,和商户经营地、常用收货地交叉比对,明显不符就转人工审核。这篇用 PHP 把这套规则实现出来:取 IP → 查归属地 → 规则打分 → 放行/复核/拦截,全程用免费接口 + 文件缓存,不用引入重型组件。
一、为什么「异地」是收单风控的强信号
正常买家消费有天然的地域惯性:人在哪,收货地址大概率在哪,付款网络也在哪。而盗刷、刷单、洗钱这几类异常,几乎都带着「支付网络位置和交易信息对不上」的特征:
- 盗刷:持卡人在 A 城,卡片信息被盗后在 B 城(甚至境外)下单;
- 刷单/虚假交易:商家自己或雇人反复下单,用的代理 IP、云主机 IP 和「真实买家」身份对不上;
- 撞库登录后购物:账号是偷来的,买家 IP 和账号历史登录地完全无关。
IP 归属地给不了「这个人是谁」,但能高效回答「这笔交易的网络位置和它声称的身份匹配吗」。匹配不上,就是复核信号。它在链路里的位置是前置过滤:先花一次免费的接口调用把明显异常的拦下来,把精力留给真正需要人工判断的单子。
二、方案设计:三层规则
规则按「从松到严」排,避免误伤:
- 归属地一致性:买家 IP 归属省份 vs 收货地址省份。一致 → 放行倾向;跨省但省内大市距离近(邻省边界城市)→ 不扣分;跨大区 + 收货地址和 IP 相距很远 → 加分嫌疑;
- 经营地比对:和商户营业执照经营地比对。买家 IP 长期落在「既不是收货地、也不是经营地」的第三地,特别是境外或 IDC 机房 IP,强嫌疑;
- 频次与组合信号:同一 IP/同省份短时间内多笔不同收货地址、新账号 + 异地 + 大额 → 直接拦截。
代码里把规则做成打分制:每命中一条嫌疑加权重分,总分过阈值进复核,再高直接拦截。阈值做成可配置,上线先跑「仅记录」模式观察一周再收紧,别一上来就真拦截。
三、PHP 实现:归属地查询 + 规则打分
用 cURL 调 https://ip9.com.cn/get?ip=xxx 拿国家/省/市/运营商,结果写进文件缓存(同 IP 一小时查一次,省免费额度),然后跑打分规则。可以直接放进支付回调或下单接口里用。
php
<?php
/**
* 收单地域风控:买家 IP 归属地和订单信息交叉校验
* 用法:require 后调用 check_order(['ip'=>..., 'province'=>..., 'amount'=>...])
*/
// 商户经营地(省),按营业执照填
const MERCHANT_PROVINCE = '广东';
function geo_query(string $ip): ?array {
// 文件缓存:同 IP 60 分钟内不重复查接口
$dir = sys_get_temp_dir() . '/ipgeo/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$file = $dir . md5($ip) . '.json';
if (is_file($file) && time() - filemtime($file) < 3600) {
return json_decode(file_get_contents($file), true);
}
$ch = curl_init("https://ip9.com.cn/get?ip=" . urlencode($ip));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_USERAGENT => 'Mozilla/5.0 (merchant-risk-check)',
]);
$body = curl_exec($ch);
curl_close($ch);
if ($body === false) return null;
$json = json_decode($body, true);
if (($json['ret'] ?? 0) !== 200) return null; // 限速(429)/非法IP(400) 都走降级
$d = $json['data'] ?? [];
$geo = [
'ip' => $d['ip'] ?? $ip,
'country' => $d['country'] ?? '',
'prov' => $d['prov'] ?? '',
'city' => $d['city'] ?? '',
'isp' => $d['isp'] ?? '',
];
file_put_contents($file, json_encode($geo, JSON_UNESCAPED_UNICODE));
return $geo;
}
/**
* @param array $o 订单信息: ip=买家IP, address_province=收货省, amount=金额(元),
* is_new_user=是否新注册, order_count_1h=该IP近1小时订单数
* @return array ['decision'=>allow|review|deny, 'score'=>int, 'reasons'=>string[]]
*/
function check_order(array $o): array {
$score = 0;
$reasons = [];
$geo = geo_query($o['ip']);
if ($geo === null) {
// 查询失败:降级放行 + 记录,别把正常订单卡死
return ['decision' => 'allow', 'score' => 0, 'reasons' => ['归属地查询失败,降级放行']];
}
// 规则1:境外 IP 直接高嫌疑(境内经营商户几乎不会有境外真实买家订单)
if ($geo['country'] !== '中国') {
$score += 60;
$reasons[] = "买家IP在境外({$geo['country']})";
}
// 规则2:归属地省份 vs 收货省份
if ($geo['prov'] && $o['address_province'] && $geo['prov'] !== $o['address_province']) {
$score += 30;
$reasons[] = "IP归属地({$geo['prov']})与收货地({$o['address_province']})跨省";
}
// 规则3:IP 归属地既不在收货省、也不在商户经营省 -> 第三地,扣重分
$known = [$o['address_province'], MERCHANT_PROVINCE];
if ($geo['prov'] && !in_array($geo['prov'], $known, true)) {
$score += 25;
$reasons[] = "IP落在与交易无关的第三地({$geo['prov']})";
}
// 规则4:运营商含 IDC/数据中心字样(云主机/代理特征)加分
if (stripos($geo['isp'], 'IDC') !== false || stripos($geo['isp'], '数据中心') !== false) {
$score += 20;
$reasons[] = "IP来自机房线路({$geo['isp']})";
}
// 规则5:组合信号
if (!empty($o['is_new_user'])) {
$score += 10;
$reasons[] = '新注册账号';
}
if (($o['order_count_1h'] ?? 0) >= 3) {
$score += 15;
$reasons[] = "同IP近1小时订单数≥3";
}
if (($o['amount'] ?? 0) >= 2000) {
$score += 10;
$reasons[] = '大额订单';
}
if ($score >= 80) {
return ['decision' => 'deny', 'score' => $score, 'reasons' => $reasons];
}
if ($score >= 40) {
return ['decision' => 'review','score' => $score, 'reasons' => $reasons];
}
return ['decision' => 'allow', 'score' => $score, 'reasons' => $reasons];
}
// ---- 调用示例(支付回调/下单接口里)----
$result = check_order([
'ip' => $_SERVER['REMOTE_ADDR'], // 反代场景记得取真实IP,见站内《PHP怎么获取访客真实IP》
'address_province' => '浙江',
'amount' => 4800,
'is_new_user' => true,
'order_count_1h' => 1,
]);
if ($result['decision'] === 'deny') {
// 拦截:订单标记异常,通知商户人工确认后再发货
exit(json_encode(['code' => 403, 'msg' => '订单异常,请与客服核实']));
}
if ($result['decision'] === 'review') {
// 复核:进入人工审核队列,延迟发货
}这段代码有四个点对应到真实风控系统的关键位置:
- 查询失败要降级放行。接口限速、超时都不能让正常订单挂掉——风控是「加一道闸」,不是「单点故障」。所以
geo_query返回 null 时直接放行并记录,事后用日志回看是哪里的问题; - 文件缓存挡免费额度。免费版 60 次/分钟/IP,同 IP 一小时缓存一次,一个中小商城一天的查询量砍到个位数。量再大换成 Redis,逻辑不变;
- 打分制优于一刀切。单一规则(比如「跨省就拦截」)会把出差买家、公司专线出口全误伤,多个弱信号累加成强信号才是风控的正路;
- 决策阈值从松到严。先只记录评分不执行动作,跑一周看分布,再定 40/80 这两个阈值,避免上线即误伤。
四、落地注意事项
共享出口 IP 是误伤重灾区。 公司专线、校园网、商场 WiFi 出口是同一个 IP,里面几百个真实买家。这种 IP 的归属地往往和「收货地」一致但和「账号历史」完全无关——规则里一定要有「出口 IP 豁免」:同一 IP 付款人数多、且归属地和收货地一致时,降低嫌疑权重。判断出口 IP 可以看运营商和企业线路特征,VIP 版的 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)在这里很有用,家里宽带和企业宽带的行为模式完全两码事。
动态 IP 会漂移。 家庭宽带拨号重连,IP 归属地可能从广州跳到深圳,真买家被误判「跨省」很常见。所以异地规则看「省份级」就够,别精细到城市,更别用经纬度算距离——接口返回的经纬度本来就是城市中心点,不是精确位置。
合规边界:这是自查工具,不是绕风控工具。 平台对「异地收款」的限制(电子围栏之类)是支付风控的正常机制,商户要做的是识别异常交易保护自己,不是想方设法规避平台规则。文章中所有逻辑都站在「识别盗刷/刷单/洗钱」一侧,别反过来用。
留痕。 每次风控判定把 IP、归属地、打分明细、决策落日志(或订单表字段),后续纠纷和复盘全靠它。取真实 IP 记得处理反代/CDN(见站内《PHP 怎么获取访客真实 IP 和归属地》),用 $_SERVER['REMOTE_ADDR'] 在有 Nginx 反代时会拿错。
总结
异地收单风控不复杂,核心就一句话:让每笔订单的网络位置接受「身份一致性」检查。IP 归属地 + 收货地 + 经营地三方比对,加上机房线路、频次、新号这几个弱信号,打分制累加,过了阈值转人工。PHP 实现整条链路(取 IP → 查归属地 → 文件缓存 → 规则打分)不到两百行,接进支付回调即可用。归属地查询用 https://ip9.com.cn/get?ip=买家IP(官网 https://www.ip9.com.cn,免注册、免费版 60 次/分钟),配合一小时缓存完全撑得住中小商户的体量;订单量大需要更细的区县和 ip_type 判断时,再考虑 VIP 版。先跑「只记录」模式收集一周数据,再放开拦截,别给自己找误伤。