Appearance
顺风车「代叫车」怎么识别?用 IP 归属地校验下单地和上车点(PHP 实现)
有个做区域顺风车平台的朋友跟我吐槽:客服每周都能收到几单「我没坐车,订单却完成了」的投诉。查下去基本都是同一类玩法 —— 黄牛用一个账号帮别人叫车,收现金,出了纠纷就甩锅给平台。金额不大,但投诉率一高,监管和保险那边都不好交代。
代叫本身不是罪,帮爸妈叫车、帮同事叫车都很正常。麻烦的是有人把它做成生意:一个人一天叫六单,用不同手机号领新客券,全在上车点城市之外的 IP 下单。这类订单在 GPS 上看不出任何异常(司机在正确的位置接了人),但下单瞬间的 IP 归属地和他们声称的上车城市对不上。
这篇给一个 PHP 脚本,把这条线索做成可落地的规则打分。
一、场景问题:代叫车的三种形态
顺着客服工单看,出问题的订单基本落在三类:
跨城下单。 乘客 IP 在 A 城,订单上车点在 B 城,两地隔着几百公里。正经用户偶尔也这样(出差前先叫好车、帮家人叫车),但同一批账号反复跨城就很不自然了。真实案例里有个账号一周跨城十一次,每次都用当时能领到的那张优惠券。
一个人两个手机自刷。 司机端接单 IP 和乘客端下单 IP 一模一样。正常情况下一名司机和一个乘客不可能共用一个出口 IP,除非是同一个人开着两个 App,或者司机把乘客的账号给了自己的设备。
同一出口 IP 关联大量乘客账号。 校园网、公司网、某些运营商的 CGNAT 出口都会让很多人共享一个 IP,所以「一个 IP 多个账号」本身不稀奇,稀奇的是一个 IP 当天关联十几个新注册账号,每单都用券。这种形态更像工作室批量操作。
需要说明的是,这三条都是线索,不是结论。IP 归属地只能回答「这次请求从哪个城市发出」,回答不了「这个人是不是上车了」。所以规则的设计目标不是自动拒单,而是把可疑订单挑出来交给人看。
二、方案设计
整体分三层,跟大多数风控脚本一样,重点在「别误伤」:
第一层:数据获取。 订单信息来自业务库,IP 归属地实时查 GET https://ip9.com.cn/get?ip=<ip>,用文件缓存兜住重复 IP(乘客 IP 的重复率通常很高),请求之间限速 1 秒 1 次,守住免费版 60 次/分钟。
第二层:规则打分。 每条规则只回答「命中/不命中 + 分值」,命中就累加,最后跟阈值比。规则和阈值分开,改规则不用动评分逻辑。当前权重:
| 规则 | 条件 | 分值 |
|---|---|---|
| R1 | 乘客 IP 城市 ≠ 上车点城市(且归属中国) | 30 |
| R2 | 乘客端与司机端同一个出口 IP | 40 |
| R3 | 同一 IP 当天关联 ≥3 个乘客账号 | 25 |
| R4 | 乘客 IP 归属境外,下单境内城市 | 20 |
阈值定在 60 分 —— 单条规则顶多触发一次提醒,两条撞上就转人工。
第三层:拿不到数据时的处理。 这条经常被忽略:IP 是内网地址、非法格式、接口被限速,这些情况下不能加分,也不能拒单,只记录原因。把「采集失败」当「风险高」,是风控脚本最常见的一种错误。
三、PHP 实现
完整脚本(ridecheck.php,只依赖 PHP 标准库,HTTPS 走 openssl,不需要 curl 扩展):
php
<?php
/**
* 顺风车/网约车「代叫车、自刷单」的 IP 归属地校验
* 用法:php ridecheck.php orders.json
*/
const IP9_API = 'https://ip9.com.cn/get';
const CACHE_FILE = __DIR__ . '/ip_cache.json';
const CACHE_TTL = 7 * 86400; // IP 归属地变化不快,缓存 7 天足够
const RATE_USLEEP = 1000000; // 免费版 60 次/分钟/IP → 1 秒 1 次
function ip9_lookup(string $ip): array
{
$cache = file_exists(CACHE_FILE) ? json_decode(file_get_contents(CACHE_FILE), true) : [];
if (!is_array($cache)) {
$cache = [];
}
if (isset($cache[$ip]) && time() - $cache[$ip]['ts'] < CACHE_TTL) {
$cache[$ip]['hit'] = true;
return $cache[$ip];
}
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 6,
'ignore_errors' => true, // 400/429 也要读到 body,才能看 ret
'header' => "User-Agent: ridecheck/1.0\r\nAccept: application/json\r\n",
],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$body = @file_get_contents(IP9_API . '?ip=' . rawurlencode($ip), false, $ctx);
$rec = ['ts' => time(), 'hit' => false, 'ok' => false, 'country' => '', 'prov' => '',
'city' => '', 'isp' => '', 'big_area' => '', 'err' => ''];
if ($body === false) {
$rec['err'] = '请求失败或超时';
} else {
$j = json_decode($body, true);
if (!is_array($j)) {
$rec['err'] = '响应不是 JSON';
} elseif ((int)($j['ret'] ?? 0) !== 200) {
$rec['err'] = 'ret=' . ($j['ret'] ?? '?'); // 非法 IP=400,限速=429
} else {
$d = $j['data'] ?? [];
$rec['ok'] = true;
$rec['country'] = $d['country'] ?? '';
$rec['prov'] = $d['prov'] ?? '';
$rec['city'] = $d['city'] ?? '';
$rec['isp'] = $d['isp'] ?? '';
$rec['big_area'] = $d['big_area'] ?? '';
}
}
$cache[$ip] = $rec;
file_put_contents(CACHE_FILE, json_encode($cache, JSON_UNESCAPED_UNICODE));
usleep(RATE_USLEEP); // 限速集中在这一层,业务代码不用管
return $rec;
}
/** 规则引擎:只回答「命中/不命中 + 分值」,怎么用由业务定 */
function check_order(array $o, array $pGeo, array $ipUsers): array
{
$hits = [];
$score = 0;
// R1 乘客下单 IP 城市 与 上车点城市不一致
$pickup = $o['pickup_city'];
if ($pGeo['ok'] && $pGeo['country'] === '中国'
&& $pGeo['city'] !== '' && $pGeo['city'] !== $pickup) {
$score += 30;
$hits[] = "乘客 IP 在{$pGeo['prov']}{$pGeo['city']},上车点在{$pickup}(跨城)";
}
// R2 乘客端与司机端同一个出口 IP
if ($o['passenger_ip'] === $o['driver_ip']) {
$score += 40;
$hits[] = '乘客与司机同一出口 IP';
}
// R3 同一 IP 当天关联多个乘客账号
$n = $ipUsers[$o['passenger_ip']] ?? 1;
if ($n >= 3) {
$score += 25;
$hits[] = "同一 IP 当天关联 {$n} 个乘客账号";
}
// R4 境外 IP 下境内订单
if ($pGeo['ok'] && $pGeo['country'] !== ''
&& $pGeo['country'] !== '中国' && $pGeo['country'] !== '保留') {
$score += 20;
$hits[] = "乘客 IP 归属{$pGeo['country']},非境内";
}
// 拿不到数据:只记录,不加分、不拒单
if (!$pGeo['ok']) {
$hits[] = '乘客 IP 无法定位:' . ($pGeo['err'] ?: '未知');
} elseif ($pGeo['country'] === '保留') {
$hits[] = '乘客 IP 是内网/保留地址(' . $pGeo['isp'] . '),不做地域校验';
}
return ['score' => $score, 'hits' => $hits];
}
$orders = json_decode(file_get_contents($argv[1] ?? 'orders.json'), true);
if (!is_array($orders)) {
fwrite(STDERR, "orders.json 读不出来\n");
exit(1);
}
// 同一 IP 关联的乘客账号数(真实项目里由 Redis/SQL 聚合,这里从订单里现算)
$ipUsers = [];
foreach ($orders as $o) {
$ipUsers[$o['passenger_ip']][$o['passenger_id']] = true;
}
$ipUsers = array_map('count', $ipUsers);
printf("订单 %d 笔,涉及 %d 个乘客 IP\n\n", count($orders), count($ipUsers));
$alerts = 0;
foreach ($orders as $o) {
$pGeo = ip9_lookup($o['passenger_ip']);
$r = check_order($o, $pGeo, $ipUsers);
printf("%-10s 乘客IP %-16s -> %s%-6s %s\n", $o['order_id'], $o['passenger_ip'],
$pGeo['prov'], $pGeo['city'], $pGeo['hit'] ? '[缓存]' : '');
printf(" 上车点 %s | 优惠券 %s 元 | 风险分 %d\n",
$o['pickup_city'], $o['coupon'], $r['score']);
foreach ($r['hits'] as $h) {
printf(" · %s\n", $h);
}
if ($r['score'] >= 60) {
$alerts++;
printf(" => 转人工复核\n");
}
echo "\n";
}
printf("高风险订单 %d 笔(阈值 60)\n", $alerts);喂进去的订单样例(orders.json,这里只摘了 3 笔,完整测试跑了 8 笔):
json
[
{"order_id":"O-9001","passenger_id":"P-01","passenger_ip":"114.114.114.114","driver_ip":"223.5.5.5","pickup_city":"南京","coupon":0},
{"order_id":"O-9003","passenger_id":"P-03","passenger_ip":"114.114.114.114","driver_ip":"114.114.114.114","pickup_city":"南京","coupon":20},
{"order_id":"O-9006","passenger_id":"P-06","passenger_ip":"45.153.160.2","driver_ip":"180.101.49.12","pickup_city":"南京","coupon":30}
]四、实测输出
拿 8 笔订单(6 个乘客 IP)跑了一遍:
订单 8 笔,涉及 6 个乘客 IP
O-9001 乘客IP 114.114.114.114 -> 江苏南京
上车点 南京 | 优惠券 0 元 | 风险分 25
· 同一 IP 当天关联 3 个乘客账号
O-9002 乘客IP 58.213.1.1 -> 江苏南京
上车点 上海 | 优惠券 15 元 | 风险分 30
· 乘客 IP 在江苏南京,上车点在上海(跨城)
O-9003 乘客IP 114.114.114.114 -> 江苏南京 [缓存]
上车点 南京 | 优惠券 20 元 | 风险分 65
· 乘客与司机同一出口 IP
· 同一 IP 当天关联 3 个乘客账号
=> 转人工复核
O-9006 乘客IP 45.153.160.2 ->
上车点 南京 | 优惠券 30 元 | 风险分 20
· 乘客 IP 归属捷克,非境内
O-9007 乘客IP 192.168.1.1 ->
上车点 南京 | 优惠券 0 元 | 风险分 0
· 乘客 IP 是内网/保留地址(内网地址),不做地域校验
O-9008 乘客IP 999.1.1.1 ->
上车点 南京 | 优惠券 0 元 | 风险分 0
· 乘客 IP 无法定位:ret=400
高风险订单 1 笔(阈值 60)首跑 8.5 秒(8 次请求,1 秒 1 次),全缓存二跑 0.016 秒。几个判定值得解释:
O-9003 是典型案例。 乘客和司机同一个 IP,再加上这个 IP 当天关联了 3 个乘客账号,两分值相加 65 分过线。单看任何一条都不够 —— 校园网里很多人共用一个出口 IP 很正常,但"同一个 IP 既下单又接单"就不正常了。
O-9002 只提醒不拦截。 南京的 IP 在上海上车,30 分。这完全可能是出差的人提前叫车,也可能是学生帮室友叫。所以它只是把订单标出来,客服回访一句就能确认。
O-9007 和 O-9008 都不加分。 192.168.1.1 接口返回的是 country=保留、isp=内网地址;999.1.1.1 直接 ret=400。这两种情况在风控脚本里最容易被写成"查询失败 → 高风险",结果每次网络抖动都刷一堆告警。
五、上线前要调整的几件事
别自动拒单,尤其别拒海外用户的单。 平台上有留学生、外贸客户,用境外网络下单是日常。IP 归属地是事后研判的材料,不是事前拦截的依据。要做拦截也得叠上实名、设备指纹、支付方式这些维度,单靠一条 IP 规则封号,客诉成本比黄牛带来的损失高得多。
R3 的"同一 IP 关联多账号"必须配合时间窗和业务聚合。 脚本里为了自洽是从订单里现算的,真实系统应该由 Redis 或数仓按「IP + 自然日」聚合,并且把注册时间、券的领取记录一起看。校园网出口在一小时内关联几十个账号太正常了,只有"新注册 + 领券 + 短时间多账号"这个组合才有信号价值。
识别脚本下单要靠 ip_type,这是 VIP 版字段。 免费版能告诉你 IP 在哪个城市、哪家运营商,但告诉不了你这个 IP 是家庭宽带还是 IDC 机房。黄牛工作室常用云主机跑脚本,出口是机房 IP —— 这个判断需要用 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和 ip_asn,只有 VIP 版提供。真要做这一层,建议先跑一段时间数据统计一下自己的订单里机房 IP 占比,再决定值不值。
缓存和限速不只是省钱,也是稳定性。 顺风车平台的高峰是早晚通勤,下单请求集中在两小时内。如果每个订单都实时查一遍,免费版 60 次/分钟会被瞬间打满,触发 429。缓存 7 天 + 1 秒 1 次的限速能把这个尖峰摊平;订单量真上去了,用 VIP 版(18 万次/分钟)或者把查询挪到订单创建后的异步队列里,主链路只记 IP 不算分。
合规上少存一点没坏处。 风控脚本只需要「城市 + 是否命中规则」的结论,原始 IP 属于个人信息,能不留就不留。日志里写 IP 时做掩码(114.114.114.*),研判记录定期清理,这条在做数据出境自查时会直接用上。
思路可以照搬到别的「线上订单、线下履约」场景:跑腿代购、上门维修、家政预约,判断逻辑都是「下单时的网络位置」和「履约地点」对不对得上。接口 GET https://ip9.com.cn/get?ip=<ip> 免费版不用注册、60 次/分钟/IP,返回 prov、city、isp、big_area、经纬度(城市中心点)这些字段;区县、ip_type、ip_asn 是 VIP 版能力,具体口径以官网 https://www.ip9.com.cn 为准。