Skip to content

客服系统怎么按访客 IP 属地分配坐席?PHP 实现方言路由 ​

一个做本地生活平台的读者问过我一个问题:他们平台在 6 个省份运营,客服坐席集中在总部,结果广东客户一句粤语开场,普通话客服愣了三秒,然后开始「您请说普通话」——客户扭头就走。他想要的是:访客进线时,系统自动看他从哪来,把会话路由给能说当地方言的坐席,或者本地城市的坐席。这个需求用 IP 归属地就能实现,而且不依赖定位权限、不需要访客填任何表单。本文用 PHP 写一个完整的分配示例。

一、为什么要按 IP 属地分配坐席 ​

「在线客服显示访客 IP 属地」只是展示层,分配是更进一步的动作——把属地从「看」变成「用」:

  • 方言匹配:广东、四川、东北的客户,本地坐席接单转化率肉眼可见地高;
  • 本地业务:医美、房产、教培这类强地域属性行业,客户只信本地门店,坐席得会说本地政策、本地价格;
  • 时区与话术:跨时区业务(比如海外华人市场)按地区分配坐席,避免凌晨三点把客户转给刚醒来的值班员;
  • 合规属地:部分行业要求咨询必须由具备当地资质的坐席承接,属地路由是硬需求。

难点在于怎么低成本拿到访客城市。前端定位要授权弹窗、会被用户拒绝;服务端查 IP 归属地零感知、零权限,是这类场景的第一选择。

二、方案设计 ​

访客发起会话,PHP 后端做四步:

  1. 取真实客户端 IP——Nginx/CDN 后面要小心,X-Forwarded-For 可以被伪造,取最右一个非可信代理的 IP;
  2. 调 IP9 免费接口 https://ip9.com.cn/get?ip=<ip>,拿到省份、城市、运营商(prov/city/isp 字段);
  3. 拿城市去匹配坐席表:坐席记录里带 prov、city、group_id 字段,命中同城/同省坐席组就分配过去,没命中落默认组;
  4. 把地区标签写进会话上下文,客服工作台直接展示「客户来自广东-广州」,话术上来就能切。

接口调用注意两点:免费版 60 次/分钟,必须加缓存(同一个 IP 一天内查一次就够);接口偶发失败时降级到默认坐席组,别让客服系统整个挂掉。

三、PHP 实现 ​

下面是一个完整可运行的分配脚本:查归属地(带文件缓存)→ 按省市匹配坐席组 → 返回分配结果。跑起来只需要一个 PHP 环境和一张坐席表。

php
<?php
// 客服按 IP 属地分配坐席:assign_by_geo.php
// 依赖:PHP 7.4+,启用 curl 扩展

const GEO_API = 'https://ip9.com.cn/get?ip=%s';
const CACHE_TTL = 86400; // 归属地缓存 24 小时

/** 取访客真实 IP:直连取 REMOTE_ADDR,反代场景取 XFF 最右一个可信值 */
function client_ip(): string {
    $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
    if ($xff !== '') {
        // XFF 可伪造,这里用「从右往左第一个非内网」的简化策略
        $parts = array_reverse(array_map('trim', explode(',', $xff)));
        foreach ($parts as $p) {
            if (filter_var($p, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return $p;
            }
        }
    }
    return $_SERVER['REMOTE_ADDR'] ?? '';
}

/** 查 IP 归属地,带文件缓存;失败返回 null(降级用) */
function geo_lookup(string $ip): ?array {
    $cacheFile = sys_get_temp_dir() . '/geo_' . md5($ip) . '.json';
    if (is_file($cacheFile) && filemtime($cacheFile) > time() - CACHE_TTL) {
        $cached = json_decode(file_get_contents($cacheFile), true);
        if (is_array($cached)) return $cached;
    }
    $url = sprintf(GEO_API, $ip);
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
        CURLOPT_USERAGENT      => 'support-router/1.0',
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    $data = json_decode((string)$body, true);
    if (!is_array($data) || ($data['ret'] ?? 0) !== 200) {
        return null;
    }
    $info = ['prov' => $data['data']['prov'] ?? '', 'city' => $data['data']['city'] ?? ''];
    file_put_contents($cacheFile, json_encode($info)); // 同一 IP 只查一次
    return $info;
}

/** 坐席表:字段 group_id / name / prov / city(空 city = 覆盖全省) */
function load_agents(): array {
    return [
        ['group_id' => 1, 'name' => '广佛组',   'prov' => '广东', 'city' => '广州'],
        ['group_id' => 1, 'name' => '广佛组',   'prov' => '广东', 'city' => '佛山'],
        ['group_id' => 2, 'name' => '川渝组',   'prov' => '四川', 'city' => ''],
        ['group_id' => 2, 'name' => '川渝组',   'prov' => '重庆', 'city' => ''],
        ['group_id' => 3, 'name' => '东北组',   'prov' => '辽宁', 'city' => ''],
        ['group_id' => 9, 'name' => '默认组',   'prov' => '',     'city' => ''],
    ];
}

/** 按省市匹配坐席组:先精确到城市,再放宽到省,最后默认组 */
function assign_group(string $prov, string $city): int {
    $default = 9;
    foreach (load_agents() as $a) {
        if ($a['prov'] === $prov && $a['city'] === $city) return $a['group_id'];
    }
    foreach (load_agents() as $a) {
        if ($a['prov'] === $prov && $a['city'] === '') return $a['group_id'];
    }
    return $default;
}

// --- 入口 ---
$ip = client_ip();
$geo = geo_lookup($ip);
if ($geo === null) {
    // 接口故障降级:默认组,不阻塞会话
    $group = assign_group('', '');
    $region = 'unknown';
} else {
    $group = assign_group($geo['prov'], $geo['city']);
    $region = $geo['prov'] . '-' . $geo['city'];
}

echo json_encode([
    'visitor_ip' => $ip,
    'region'     => $region,
    'group_id'   => $group,
    'agents'     => array_values(array_filter(load_agents(), fn($a) => $a['group_id'] === $group)),
], JSON_UNESCAPED_UNICODE);

在真实项目里,把 load_agents() 换成查数据库或接口、assign_group 的结果写进会话表即可。客服工作台拿到 region 字段,进线第一时间就能看到「广东-广州」,方言打招呼不再是碰运气。

四、落地注意事项 ​

  • 代理和加速器会带偏属地。VPN、游戏加速器会把访客 IP 显示到别的城市,导致错配。两类兜底:允许客户在会话里手动选择城市并记住;或者干脆把属地路由当「第一选择」,人工可改。
  • 机房流量要识别。很多咨询机器人跑在机房 IP 上,把它们路由给真坐席是浪费人力。IP9 免费版能看运营商(isp 字段),VIP 版还能返回 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和区县 area,把 IDC 来源的会话标记出来单独处理,效率高很多。
  • 别把缓存做成定时炸弹。文件缓存在单机够用,多实例部署就换 Redis;TTL 别设太短,否则免费额度很容易被高峰流量吃满。
  • 隐私合规:记录访客 IP 用于路由,属于正当业务目的,但要在隐私政策里写明用途和保留期限。

总结 ​

按 IP 属地分配客服坐席,本质是给「进线」这个动作提前加上地域上下文。PHP 实现不超过 80 行:取 IP → 查 IP9 免费接口 https://ip9.com.cn/get?ip=xxx(无注册、60 次/分钟,缓存后量级可忽略)→ 省市匹配坐席组 → 降级兜底。先让客户听到熟悉的方言,再谈转化率。官网:https://www.ip9.com.cn 。