自制 IPv4 归属地查询接口(基于 IP2Region)

项目背景

最近在一个项目中需要获取访客的 IP 归属地信息。尝试了几个公开的 API,要么无法访问,要么限制频次,要么开始收费。索性自己动手,基于 IP2Region 数据库实现了一个轻量级的本地查询接口。

注意:本方案仅适用于 IPv4 环境。IP2Region 对国内 IPv4 地址的覆盖率和准确度都比较理想,且数据持续更新。

为什么不用 Composer?

IP2Region 官方提供了 PHP 扩展和 Composer 包,但很多虚拟主机环境并不支持 Composer。因此本文采用纯 PHP + 本地 xdb 数据文件的方式,无需任何依赖,上传即可使用。

实现步骤

1. 下载数据库文件

/ip 目录下创建 download_db.php,用于从 GitHub 拉取最新的 ip2region.xdb 数据文件(约 10MB)。数据库更新时,重新访问该文件即可完成更新。

<?php
// 文件: /ip/download_db.php
header('Content-Type: text/plain; charset=utf-8');

$dbUrl = 'https://raw.githubusercontent.com/adysec/IP_database/main/ip2region/ip2region.xdb';
$dbFile = __DIR__ . '/ip2region.xdb';

echo "开始下载数据库文件...\n";
echo "源地址: $dbUrl\n";
echo "保存到: $dbFile\n\n";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $dbUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$data = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode === 200 && $data && strlen($data) > 1024 * 1024) {
    file_put_contents($dbFile, $data);
    echo "✓ 下载成功!\n";
    echo "文件大小: " . number_format(filesize($dbFile)) . " 字节\n";
    echo "文件位置: $dbFile\n";
} else {
    echo "✗ 下载失败!HTTP状态码: $httpCode\n";
    echo "请手动下载: $dbUrl\n";
}

访问 download_db.php 即可自动下载并保存数据库文件到当前目录。

2. 编写查询接口

/ip 目录下创建 index.php,用于接收请求、解析客户端 IP、查询归属地并返回 JSON 数据。

<?php

/**

  • IP 归属地查询接口
  • 基于 IP2Region xdb 数据库(官方推荐算法)
  • 返回 JSON 格式:IP、国家、省份、城市、ISP 等
    */

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');

/**

  • XdbSearcher 类 - 官方 xdb 搜索实现
    */

class XdbSearcher
{

const HeaderInfoLength = 256;
const VectorIndexRows = 256;
const VectorIndexCols = 256;
const VectorIndexSize = 8;
const SegmentIndexSize = 14;

private $dbPath = null;
private $handle = null;
private $header = null;
private $ioCount = 0;
private $vectorIndex = null;
private $cachePolicy = 1; // 1: vector index cache, 2: all cache

public function __construct($dbPath)
{
    $this-&gt;dbPath = $dbPath;
    if (!file_exists($dbPath)) {
        throw new Exception(&quot;数据库文件不存在: {$dbPath}&quot;);
    }
    $this-&gt;handle = fopen($dbPath, &#039;rb&#039;);
    if (!$this-&gt;handle) {
        throw new Exception(&quot;无法打开数据库文件: {$dbPath}&quot;);
    }
    $this-&gt;initHeader();
    $this-&gt;initVectorIndex();
}

private function initHeader()
{
    fseek($this-&gt;handle, 0);
    $this-&gt;header = fread($this-&gt;handle, self::HeaderInfoLength);
    if (strlen($this-&gt;header) != self::HeaderInfoLength) {
        throw new Exception(&quot;读取头部信息失败&quot;);
    }
}

private function initVectorIndex()
{
    $vectorIndexLength = self::VectorIndexRows * self::VectorIndexCols * self::VectorIndexSize;
    fseek($this-&gt;handle, self::HeaderInfoLength);
    $vIndex = fread($this-&gt;handle, $vectorIndexLength);
    if (strlen($vIndex) != $vectorIndexLength) {
        throw new Exception(&quot;读取向量索引失败&quot;);
    }
    $this-&gt;vectorIndex = $vIndex;
}

private function ip2uint($ip)
{
    $ipArr = explode(&#039;.&#039;, $ip);
    if (count($ipArr) != 4) {
        return 0;
    }
    return (($ipArr[0] &lt;&lt; 24) | ($ipArr[1] &lt;&lt; 16) | ($ipArr[2] &lt;&lt; 8) | $ipArr[3]) &amp; 0xFFFFFFFF;
}

private function getLong($b, $offset)
{
    return unpack(&#039;V&#039;, substr($b, $offset, 4))[1];
}

private function getShort($b, $offset)
{
    return unpack(&#039;v&#039;, substr($b, $offset, 2))[1];
}

public function search($ip)
{
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
        return &quot;0|0|0|0|0&quot;;
    }
    
    $ipInt = $this-&gt;ip2uint($ip);
    $il0 = ($ipInt &gt;&gt; 24) &amp; 0xFF;
    $il1 = ($ipInt &gt;&gt; 16) &amp; 0xFF;
    
    $vIndex = $il0 * self::VectorIndexCols * self::VectorIndexSize + $il1 * self::VectorIndexSize;
    $sPtr = $this-&gt;getLong($this-&gt;vectorIndex, $vIndex);
    $ePtr = $this-&gt;getLong($this-&gt;vectorIndex, $vIndex + 4);
    
    $dataLen = -1;
    $dataPos = -1;
    $l = 0;
    $h = ($ePtr - $sPtr) / self::SegmentIndexSize;
    
    while ($l &lt;= $h) {
        $m = ($l + $h) &gt;&gt; 1;
        $p = $sPtr + $m * self::SegmentIndexSize;
        
        fseek($this-&gt;handle, $p);
        $buffer = fread($this-&gt;handle, self::SegmentIndexSize);
        if (strlen($buffer) &lt; self::SegmentIndexSize) {
            break;
        }
        
        $sip = $this-&gt;getLong($buffer, 0);
        if ($ipInt &lt; $sip) {
            $h = $m - 1;
        } else {
            $eip = $this-&gt;getLong($buffer, 4);
            if ($ipInt &gt; $eip) {
                $l = $m + 1;
            } else {
                $dataLen = $this-&gt;getShort($buffer, 8);
                $dataPos = $this-&gt;getLong($buffer, 10);
                break;
            }
        }
    }
    
    if ($dataLen == -1 || $dataPos == -1) {
        return &quot;0|0|0|0|0&quot;;
    }
    
    fseek($this-&gt;handle, $dataPos);
    $regionBuff = fread($this-&gt;handle, $dataLen);
    if (strlen($regionBuff) != $dataLen) {
        return &quot;0|0|0|0|0&quot;;
    }
    
    return $regionBuff;
}

public function close()
{
    if ($this-&gt;handle) {
        fclose($this-&gt;handle);
    }
}

public function __destruct()
{
    $this-&gt;close();
}

}

/**

  • 获取客户端真实 IP(支持代理、CDN)
    */

function getClientIp()
{

$ip = $_SERVER[&#039;REMOTE_ADDR&#039;] ?? &#039;0.0.0.0&#039;;

$headers = [
    &#039;HTTP_CF_CONNECTING_IP&#039;,
    &#039;HTTP_X_FORWARDED_FOR&#039;,
    &#039;HTTP_X_REAL_IP&#039;,
    &#039;HTTP_CLIENT_IP&#039;,
    &#039;HTTP_X_FORWARDED&#039;
];

foreach ($headers as $header) {
    if (isset($_SERVER[$header]) &amp;&amp; $_SERVER[$header]) {
        $ipList = explode(&#039;,&#039;, $_SERVER[$header]);
        $firstIp = trim($ipList[0]);
        if (filter_var($firstIp, FILTER_VALIDATE_IP)) {
            $ip = $firstIp;
            break;
        }
    }
}

// IPv6 映射的 IPv4 处理
if (strpos($ip, &#039;::ffff:&#039;) === 0) {
    $ip = substr($ip, 7);
}

// 私有地址过滤(必要时从 X-Real-IP 重新获取)
$privateRanges = [
    &#039;10.0.0.0/8&#039;,
    &#039;172.16.0.0/12&#039;,
    &#039;192.168.0.0/16&#039;,
    &#039;127.0.0.0/8&#039;
];

foreach ($privateRanges as $range) {
    if (ip_in_range($ip, $range)) {
        if (isset($_SERVER[&#039;HTTP_X_REAL_IP&#039;])) {
            $realIp = $_SERVER[&#039;HTTP_X_REAL_IP&#039;];
            if (filter_var($realIp, FILTER_VALIDATE_IP)) {
                return $realIp;
            }
        }
        break;
    }
}

return $ip;

}

/**

  • 检查 IP 是否在 CIDR 范围内
    */

function ip_in_range($ip, $range)
{

list($subnet, $mask) = explode(&#039;/&#039;, $range);
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
$maskLong = ~((1 &lt;&lt; (32 - $mask)) - 1);
return ($ipLong &amp; $maskLong) == ($subnetLong &amp; $maskLong);

}

/**

  • 解析归属地原始字符串
    */

function parseLocation($region)
{

$parts = explode(&#039;|&#039;, $region);
while (count($parts) &lt; 5) {
    $parts[] = &#039;&#039;;
}

$result = [];
foreach ($parts as $part) {
    $part = trim($part);
    $result[] = ($part === &#039;0&#039; || $part === &#039;&#039;) ? &#039;&#039; : $part;
}

list($country, $regionArea, $province, $city, $isp) = $result;

$addressParts = [];
if ($country &amp;&amp; $country !== &#039;0&#039;) $addressParts[] = $country;
if ($province &amp;&amp; $province !== &#039;0&#039; &amp;&amp; $province !== $country) $addressParts[] = $province;
if ($city &amp;&amp; $city !== &#039;0&#039; &amp;&amp; $city !== $province) $addressParts[] = $city;
if ($isp &amp;&amp; $isp !== &#039;0&#039;) $addressParts[] = $isp;

$full = implode(&#039; &#039;, $addressParts);

return [
    &#039;country&#039;  =&gt; $country ?: &#039;未知&#039;,
    &#039;region&#039;   =&gt; $regionArea ?: &#039;&#039;,
    &#039;province&#039; =&gt; $province ?: &#039;&#039;,
    &#039;city&#039;     =&gt; $city ?: &#039;&#039;,
    &#039;isp&#039;      =&gt; $isp ?: &#039;&#039;,
    &#039;full&#039;     =&gt; $full ?: &#039;未知&#039;
];

}

// ----- 主流程 -----
try {

$dbPath = __DIR__ . &#039;/ip2region.xdb&#039;;

if (!file_exists($dbPath)) {
    throw new Exception(&quot;数据库文件不存在: {$dbPath}&quot;);
}

$fileSize = filesize($dbPath);
if ($fileSize &lt; 1024 * 1024) {
    throw new Exception(&quot;数据库文件大小异常: {$fileSize} 字节&quot;);
}

$searcher = new XdbSearcher($dbPath);
$clientIp = getClientIp();
$region = $searcher-&gt;search($clientIp);
$searcher-&gt;close();

$location = parseLocation($region);

echo json_encode([
    &#039;success&#039;  =&gt; true,
    &#039;ip&#039;       =&gt; $clientIp,
    &#039;location&#039; =&gt; $location,
    &#039;raw&#039;      =&gt; $region,
    &#039;timestamp&#039;=&gt; time()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);

} catch (Exception $e) {

echo json_encode([
    &#039;success&#039;  =&gt; false,
    &#039;error&#039;    =&gt; $e-&gt;getMessage(),
    &#039;timestamp&#039;=&gt; time()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);

}

使用说明

  1. 将以上两个文件上传至服务器 /ip 目录。
  2. 浏览器访问 http://你的域名/ip/download_db.php 下载数据库文件(只需执行一次,后续数据更新可再次访问)。
  3. 访问 http://你的域名/ip/index.php 即可看到当前访问 IP 的归属地 JSON 结果。
  4. 其他应用可通过 AJAX 或后端请求该接口获取归属地信息。

返回示例

{

&quot;success&quot;: true,
&quot;ip&quot;: &quot;113.109.216.75&quot;,
&quot;location&quot;: {
    &quot;country&quot;: &quot;中国&quot;,
    &quot;region&quot;: &quot;&quot;,
    &quot;province&quot;: &quot;广东&quot;,
    &quot;city&quot;: &quot;深圳&quot;,
    &quot;isp&quot;: &quot;电信&quot;,
    &quot;full&quot;: &quot;中国 广东 深圳 电信&quot;
},
&quot;raw&quot;: &quot;中国|0|广东|深圳|电信&quot;,
&quot;timestamp&quot;: 1746000000

}

注意事项

  • 本方案仅支持 IPv4,IPv6 地址会返回 0|0|0|0|0 或提示无效。
  • 数据库文件约 10MB,请确保服务器磁盘空间充足。
  • 如果数据库更新,重新访问 download_db.php 即可覆盖旧文件。
  • 若 GitHub 原始地址失效,可自行替换为其他可访问的 ip2region.xdb 下载链接。

相关链接

评论 (2)

James
James 2026-05-25 09:22

我是直接部署了一个umami,博客的那篇文章被谁访问,IP来源归属地都可以看到,也是下载了一个IP库放到指定目录下。

花非花
花非花 2026-05-23 13:34

我用AI给我的Typecho博客程序做了个插件,用来统计流量的,调用的就是一个免费的IP库,好像有限制,1分钟40次查询还是1秒钟来着