自制 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->dbPath = $dbPath;
        if (!file_exists($dbPath)) {
            throw new Exception("数据库文件不存在: {$dbPath}");
        }
        $this->handle = fopen($dbPath, 'rb');
        if (!$this->handle) {
            throw new Exception("无法打开数据库文件: {$dbPath}");
        }
        $this->initHeader();
        $this->initVectorIndex();
    }
    
    private function initHeader()
    {
        fseek($this->handle, 0);
        $this->header = fread($this->handle, self::HeaderInfoLength);
        if (strlen($this->header) != self::HeaderInfoLength) {
            throw new Exception("读取头部信息失败");
        }
    }
    
    private function initVectorIndex()
    {
        $vectorIndexLength = self::VectorIndexRows * self::VectorIndexCols * self::VectorIndexSize;
        fseek($this->handle, self::HeaderInfoLength);
        $vIndex = fread($this->handle, $vectorIndexLength);
        if (strlen($vIndex) != $vectorIndexLength) {
            throw new Exception("读取向量索引失败");
        }
        $this->vectorIndex = $vIndex;
    }
    
    private function ip2uint($ip)
    {
        $ipArr = explode('.', $ip);
        if (count($ipArr) != 4) {
            return 0;
        }
        return (($ipArr[0] << 24) | ($ipArr[1] << 16) | ($ipArr[2] << 8) | $ipArr[3]) & 0xFFFFFFFF;
    }
    
    private function getLong($b, $offset)
    {
        return unpack('V', substr($b, $offset, 4))[1];
    }
    
    private function getShort($b, $offset)
    {
        return unpack('v', substr($b, $offset, 2))[1];
    }
    
    public function search($ip)
    {
        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            return "0|0|0|0|0";
        }
        
        $ipInt = $this->ip2uint($ip);
        $il0 = ($ipInt >> 24) & 0xFF;
        $il1 = ($ipInt >> 16) & 0xFF;
        
        $vIndex = $il0 * self::VectorIndexCols * self::VectorIndexSize + $il1 * self::VectorIndexSize;
        $sPtr = $this->getLong($this->vectorIndex, $vIndex);
        $ePtr = $this->getLong($this->vectorIndex, $vIndex + 4);
        
        $dataLen = -1;
        $dataPos = -1;
        $l = 0;
        $h = ($ePtr - $sPtr) / self::SegmentIndexSize;
        
        while ($l <= $h) {
            $m = ($l + $h) >> 1;
            $p = $sPtr + $m * self::SegmentIndexSize;
            
            fseek($this->handle, $p);
            $buffer = fread($this->handle, self::SegmentIndexSize);
            if (strlen($buffer) < self::SegmentIndexSize) {
                break;
            }
            
            $sip = $this->getLong($buffer, 0);
            if ($ipInt < $sip) {
                $h = $m - 1;
            } else {
                $eip = $this->getLong($buffer, 4);
                if ($ipInt > $eip) {
                    $l = $m + 1;
                } else {
                    $dataLen = $this->getShort($buffer, 8);
                    $dataPos = $this->getLong($buffer, 10);
                    break;
                }
            }
        }
        
        if ($dataLen == -1 || $dataPos == -1) {
            return "0|0|0|0|0";
        }
        
        fseek($this->handle, $dataPos);
        $regionBuff = fread($this->handle, $dataLen);
        if (strlen($regionBuff) != $dataLen) {
            return "0|0|0|0|0";
        }
        
        return $regionBuff;
    }
    
    public function close()
    {
        if ($this->handle) {
            fclose($this->handle);
        }
    }
    
    public function __destruct()
    {
        $this->close();
    }
}

/**
 * 获取客户端真实 IP(支持代理、CDN)
 */
function getClientIp()
{
    $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    
    $headers = [
        'HTTP_CF_CONNECTING_IP',
        'HTTP_X_FORWARDED_FOR',
        'HTTP_X_REAL_IP',
        'HTTP_CLIENT_IP',
        'HTTP_X_FORWARDED'
    ];
    
    foreach ($headers as $header) {
        if (isset($_SERVER[$header]) && $_SERVER[$header]) {
            $ipList = explode(',', $_SERVER[$header]);
            $firstIp = trim($ipList[0]);
            if (filter_var($firstIp, FILTER_VALIDATE_IP)) {
                $ip = $firstIp;
                break;
            }
        }
    }
    
    // IPv6 映射的 IPv4 处理
    if (strpos($ip, '::ffff:') === 0) {
        $ip = substr($ip, 7);
    }
    
    // 私有地址过滤(必要时从 X-Real-IP 重新获取)
    $privateRanges = [
        '10.0.0.0/8',
        '172.16.0.0/12',
        '192.168.0.0/16',
        '127.0.0.0/8'
    ];
    
    foreach ($privateRanges as $range) {
        if (ip_in_range($ip, $range)) {
            if (isset($_SERVER['HTTP_X_REAL_IP'])) {
                $realIp = $_SERVER['HTTP_X_REAL_IP'];
                if (filter_var($realIp, FILTER_VALIDATE_IP)) {
                    return $realIp;
                }
            }
            break;
        }
    }
    
    return $ip;
}

/**
 * 检查 IP 是否在 CIDR 范围内
 */
function ip_in_range($ip, $range)
{
    list($subnet, $mask) = explode('/', $range);
    $ipLong = ip2long($ip);
    $subnetLong = ip2long($subnet);
    $maskLong = ~((1 << (32 - $mask)) - 1);
    return ($ipLong & $maskLong) == ($subnetLong & $maskLong);
}

/**
 * 解析归属地原始字符串
 */
function parseLocation($region)
{
    $parts = explode('|', $region);
    while (count($parts) < 5) {
        $parts[] = '';
    }
    
    $result = [];
    foreach ($parts as $part) {
        $part = trim($part);
        $result[] = ($part === '0' || $part === '') ? '' : $part;
    }
    
    list($country, $regionArea, $province, $city, $isp) = $result;
    
    $addressParts = [];
    if ($country && $country !== '0') $addressParts[] = $country;
    if ($province && $province !== '0' && $province !== $country) $addressParts[] = $province;
    if ($city && $city !== '0' && $city !== $province) $addressParts[] = $city;
    if ($isp && $isp !== '0') $addressParts[] = $isp;
    
    $full = implode(' ', $addressParts);
    
    return [
        'country'  => $country ?: '未知',
        'region'   => $regionArea ?: '',
        'province' => $province ?: '',
        'city'     => $city ?: '',
        'isp'      => $isp ?: '',
        'full'     => $full ?: '未知'
    ];
}

// ----- 主流程 -----
try {
    $dbPath = __DIR__ . '/ip2region.xdb';
    
    if (!file_exists($dbPath)) {
        throw new Exception("数据库文件不存在: {$dbPath}");
    }
    
    $fileSize = filesize($dbPath);
    if ($fileSize < 1024 * 1024) {
        throw new Exception("数据库文件大小异常: {$fileSize} 字节");
    }
    
    $searcher = new XdbSearcher($dbPath);
    $clientIp = getClientIp();
    $region = $searcher->search($clientIp);
    $searcher->close();
    
    $location = parseLocation($region);
    
    echo json_encode([
        'success'  => true,
        'ip'       => $clientIp,
        'location' => $location,
        'raw'      => $region,
        'timestamp'=> time()
    ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    
} catch (Exception $e) {
    echo json_encode([
        'success'  => false,
        'error'    => $e->getMessage(),
        'timestamp'=> time()
    ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}

使用说明

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

返回示例

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

注意事项

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

相关链接

评论 (0)