Appearance
律所网站的咨询线索怎么按地域分派?用 IP 归属地给案源打城市标签(PHP 实现)
一个中型律所的网络案源通常来自四五个入口:官网「免费咨询」表单、公众号文章的留言、投放落地页、地图商家页。线索进来之后,运营的第一件事是「这条该给谁」。分所按城市划片,接案要跨省开庭的话,差旅成本和沟通成本都会翻倍,所以「客户在哪个城市」是分派的核心字段。
麻烦在于这个字段是用户自己填的。实际情况是:有人图快,城市那一栏随手选个省会;有人担心隐私,填个隔壁城市;还有同行来探价、黑产刷表单批量提交,城市字段全是假的。运营只能打电话一条条问,一天问几十条,效率很低。
这篇文章讲一个轻量做法:访客打开页面那一刻,用出口 IP 给他打个城市标签存进会话,表单提交时和用户手填的城市放在一起对比,一致率高的一路走快车道,冲突的单独进人工队列。
一、律所线索里的三个具体问题
分派没人认领的「模糊线索」。 用户填的城市不在任何分所覆盖范围(比如填了「其他」),或者填的城市和手机号归属地完全对不上,这种线索要么被压在池底没人跟,要么被几个分所来回推。给一条线索标上「IP 城市 = 武汉,表单城市 = 空」,运营一眼就知道该给武汉分所。
无效线索淹没有效线索。 律所网站的咨询表单是黑产眼里的好东西——一条真实联系方式能卖钱,所以刷单、探价、同行收集客户名单都会来。IP 归属地能提供一组很有用的特征:短时间同一个出口 IP 提交多条、IP 城市与手机号归属地长期不符、出口是 IDC 机房网段(正常个人用户很少从机房 IP 提交表单)。这些特征不能单独判死,但足以把它们从快车道挪到待审核。
落地页的本地化文案没吃到红利。 投放落地页如果知道访客在苏州,标题里写「苏州地区婚姻家事法律咨询」,转化率通常比通用文案高一截。这个动作和上面的分派用的是同一份数据,顺手就能做。
二、方案设计
链路设计成三段,注意全部是「软判断」:
访客打开页面 → 取真实客户端 IP → 查归属地(带文件缓存)
→ 写进 session(城市 / 省 / 大区 / 是否机房)
表单提交 → 取 session 里的 IP 城市 + 表单城市
→ 一致 / 冲突 / 缺失 三态 → 分流(分所直接派 / 人工队列 / 无效标记)几个必须做对的点:
取真实 IP 是第一步,也是最容易错的一步。 律所站基本都会套 CDN 或 Nginx 反代,$_SERVER['REMOTE_ADDR'] 拿到的经常是 CDN 回源 IP。要按可信代理链取 X-Forwarded-For 的第一个非内网地址,同时把 CDN 回源 IP 段列成白名单,避免有人伪造请求头把城市刷成任意值。
归属地查询不能挡在请求主链路上。 表单提交必须永远成功——查接口超时、限速、返回异常,都不能让用户看到报错页。做法是查不到就用空值,照样入库,只是这条线索标记为「无位置信号」。
会话缓存 + 文件缓存双层。 同一个访客打开五个页面只查一次;同一个 IP 在缓存有效期内复用结果。IP9 免费版 60 次/分钟,一个中小型律所站日均 UV 几千,加上缓存完全够用。
判「冲突」而不是判「假」。 IP 城市和表单城市不一致的原因太多了:用户正在出差、用公司 VPN、手机流量出口跨省、填的是老家地址。所以冲突只意味着「需要人工确认」,绝不能直接丢弃。
三、PHP 实现
下面这份可以直接放进 inc/lead_geo.php,原生 PHP 就能跑,不需要 Composer 依赖。
php
<?php
/**
* 律所线索属地标签:访客 IP → 城市标签 → 分所分派
* 依赖:PHP 7.4+,开启 curl 扩展
*/
const IP9_API = 'https://ip9.com.cn/get';
const GEO_CACHE_DIR = __DIR__ . '/cache/geo';
const GEO_TTL = 604800; // 7 天
const API_TIMEOUT = 2; // 秒,最多等 2 秒
// 可信代理:CDN / 负载均衡回源 IP 段(换成自家的)
$TRUSTED_PROXIES = ['127.0.0.1', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
// 城市 → 分所(按自家组织结构维护)
$BRANCH_BY_CITY = [
'北京' => 'bj', '上海' => 'sh', '广州' => 'gz', '深圳' => 'sz',
'杭州' => 'hz', '成都' => 'cd', '武汉' => 'wh', '西安' => 'xa',
];
/**
* 取真实客户端 IP:优先 REMOTE_ADDR,只有来自可信代理才解析 XFF
*/
function client_ip(): string
{
global $TRUSTED_PROXIES;
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
$trusted = false;
foreach ($TRUSTED_PROXIES as $net) {
if (ip_in_net($remote, $net)) { $trusted = true; break; }
}
if (!$trusted) {
return $remote;
}
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
foreach (array_reverse(array_map('trim', explode(',', $xff))) as $candidate) {
if ($candidate !== '' && !ip_in_net($candidate, '10.0.0.0/8')
&& !ip_in_net($candidate, '172.16.0.0/12')
&& !ip_in_net($candidate, '192.168.0.0/16')) {
return $candidate;
}
}
return $remote;
}
function ip_in_net(string $ip, string $net): bool
{
if (strpos($net, '/') === false) {
return $ip === $net;
}
[$subnet, $bits] = explode('/', $net);
$ipLong = ip2long($ip);
if ($ipLong === false) { return false; } // IPv6 走原值比较
$mask = -1 << (32 - (int)$bits);
return ($ipLong & $mask) === (ip2long($subnet) & $mask);
}
/**
* 查归属地:文件缓存优先,失败返回 null(不阻塞业务)
*/
function geo_lookup(string $ip): ?array
{
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return null; // 非法 IP 不浪费一次请求
}
if (!is_dir(GEO_CACHE_DIR)) { @mkdir(GEO_CACHE_DIR, 0755, true); }
$file = GEO_CACHE_DIR . '/' . md5($ip) . '.json';
if (is_file($file) && (time() - filemtime($file)) < GEO_TTL) {
$cached = json_decode((string)file_get_contents($file), true);
if (is_array($cached)) { return $cached; }
}
$url = IP9_API . '?ip=' . urlencode($ip);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => API_TIMEOUT,
CURLOPT_TIMEOUT => API_TIMEOUT,
CURLOPT_USERAGENT => 'lawfirm-lead-geo/1.0',
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$raw) { return null; }
$body = json_decode($raw, true);
if (!is_array($body) || ($body['ret'] ?? 0) !== 200 || empty($body['data'])) {
return null; // ret=400 非法 IP / ret=429 限速,都当缺失
}
$data = [
'country' => $body['data']['country'] ?? '',
'prov' => $body['data']['prov'] ?? '',
'city' => $body['data']['city'] ?? '',
'area' => $body['data']['big_area'] ?? '',
'isp' => $body['data']['isp'] ?? '',
'ip' => $ip,
];
file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE), LOCK_EX);
return $data;
}
/**
* 给访客挂属地标签(放页面顶部 include,会话内只查一次)
*/
function attach_geo_tag(): array
{
if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); }
if (!empty($_SESSION['geo'])) { return $_SESSION['geo']; }
$geo = geo_lookup(client_ip()) ?? [];
$_SESSION['geo'] = $geo;
return $geo;
}
/**
* 表单提交时的分派判定
* @return array{branch:string, status:string, note:string}
*/
function dispatch_lead(string $formCity, array $geo): array
{
global $BRANCH_BY_CITY;
$ipCity = $geo['city'] ?? '';
$ipProv = $geo['prov'] ?? '';
if ($ipCity === '') {
// 无位置信号:按用户手填的城市派,但要留痕
$branch = $BRANCH_BY_CITY[$formCity] ?? 'headquarters';
return ['branch' => $branch, 'status' => 'no_geo', 'note' => '缺少 IP 位置信号'];
}
if ($formCity === '') {
$branch = $BRANCH_BY_CITY[$ipCity] ?? 'headquarters';
return ['branch' => $branch, 'status' => 'geo_only', 'note' => "按 IP 城市派:{$ipCity}"];
}
// 手填城市与 IP 城市一致,或同省相邻城市,视为可信
if ($formCity === $ipCity) {
$branch = $BRANCH_BY_CITY[$ipCity] ?? 'headquarters';
return ['branch' => $branch, 'status' => 'match', 'note' => '城市一致'];
}
return [
'branch' => 'review_queue',
'status' => 'conflict',
'note' => "表单城市={$formCity},IP 城市={$ipCity}({$ipProv}),待人工确认",
];
}浏览页面的地方加一行就完成打标:
php
<?php
require __DIR__ . '/inc/lead_geo.php';
$geo = attach_geo_tag();
// $geo['city'] 可以直接拿去做落地页文案:「{$geo['city']}地区法律咨询」表单处理里接上分派逻辑:
php
<?php
require __DIR__ . '/inc/lead_geo.php';
$geo = attach_geo_tag();
$plan = dispatch_lead(trim($_POST['city'] ?? ''), $geo);
// 落库:城市标签、分派结果、状态一起存,方便运营复盘
$stmt = $pdo->prepare(
'INSERT INTO leads (name, phone, form_city, ip_city, ip_prov, ip_isp,
branch, geo_status, geo_note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([
$_POST['name'], $_POST['phone'], $_POST['city'],
$geo['city'] ?? '', $geo['prov'] ?? '', $geo['isp'] ?? '',
$plan['branch'], $plan['status'], $plan['note'],
]);跑起来之后,运营后台按 geo_status 分组看一眼就能判断质量:
match : 3,124 条 城市一致,直接派分所
geo_only : 486 条 用户没填城市,按 IP 城市派
no_geo : 217 条 没有位置信号,按手填城市派
conflict : 193 条 两者冲突,进人工复核队列四、上线前要处理的边界
表单一定能提交成功。 上面所有函数里,位置相关的路径都不抛异常,查不到就返回空数组,dispatch_lead 会把线索送进 no_geo 分支。反过来做——查不到归属地就不让提交——是这类功能最常见的翻车方式。
冲突别直接判假。 从后台看 conflict 那一组,你会看到很多真实客户:出差中的、用公司专线的、人在深圳但家在武汉的。这组数据的用法是「优先电话确认」,不是「标记无效」。
机房 IP 是黑产的最强特征。 普通个人用户从 IDC 网段提交咨询表单的概率极低。免费版的返回字段里没有 IP 类型,判断机房需要 VIP 版的 ip_type(值为 IDC / ISP / BUS):命中 IDC 且短时间多次提交的,可以直接进黑名单观察。这条规则比「城市冲突」精准得多。
同一出口 IP 的高频提交要限流。 一条 IP → 最近 N 次提交时间 的简单计数表就够。注意公司、园区、律协办公网的出口 IP 是共享的,限流阈值别设成一小时两条这种,容易误伤。
IP 是个人信息,留存要克制。 律所处理的本来就是敏感信息,IP 地址作为个人信息,采集和留存范围按站内《IP 地址是个人信息吗》那篇的口径收敛:只留城市级聚合字段、明确告知、定期清理,别把完整 IP 长期躺在数据库里。如果作为纠纷证据使用,按《IP 地址能作为证据吗》的流程固定。
手机端会带来一波「假冲突」。 4G/5G 出口经常跨省市,用户在广州填广州、IP 落到佛山是常事。实践中的做法是把判断放宽到「同省即算一致」,只把跨大区的冲突挑出来复核。
总结
律所案源的属地分派,价值不在于把每条线索都分得精准,而在于给运营一个可解释的分组依据:哪些可以直接派、哪些要确认、哪些该盯一盯。落地建议从最小一步开始——只在表单处理里加一列 ip_city,跑两周看 match 和 conflict 的比例,再决定要不要接分所路由和落地页本地化文案。
查归属地这一步用 IP9 免费接口就够:https://ip9.com.cn/get?ip=<IP>,不传参数返回请求方自己的归属地,IPv4/IPv6 都支持,返回国家、省市、邮编、区号、运营商、大区(big_area)、城市中心经纬度等字段,免注册、无需鉴权,免费版 60 次/分钟,记得套一层文件缓存再上线。官网:https://www.ip9.com.cn