Files
superlink/api/common/phone_geo.php
T

228 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 手机号码归属地识别接口 GET /api/common/phone_geo.php?phone=13812345678
* 需求:录入手机号码时自动识别归属地
* - 国内号码:识别"国家-省-市"(如 中国-广东省-深圳市)
* - 港澳台: 标注为"中国香港"/"中国澳门"/"中国台湾"
* - 海外号码:识别"国籍"(如 美国)
* 实现方案(参考 DeepSeek 方案一:phonenumbers + phone 双库组合):
* - 国内省市:使用 phone.dat 数据库(Google libphonenumber 同源号段数据,见 data/phone.dat)
* - 国际号码:内置国家区号表识别国籍
* 返回:{ country, province, city, label, found }
* label 示例:"中国-广东省-深圳市" / "中国香港" / "美国"
*/
require_once __DIR__ . '/Api.php';
$pdo = Api::boot();
/**
* 接口入口:仅当作为 HTTP 接口直接访问时执行(便于函数被 require 复用/测试)
*/
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
$phone = trim($_REQUEST['phone'] ?? '');
if ($phone === '') {
Response::error('参数错误:缺少 phone', 400);
}
Response::success(detectPhoneLocation($phone));
}
/* ==================== 归属地识别核心 ==================== */
/**
* 解析号码归属地
* @param string $phone 原始号码(可含 + / 空格 / 短横线)
* @return array
*/
function detectPhoneLocation($phone)
{
$digits = preg_replace('/[^\d]/', '', $phone);
$hasPlus = strpos($phone, '+') === 0;
// 处理国际前缀:+86 / 0086 / 86 开头统一识别
// 0086 开头 → 转为 86 开头
if (strpos($digits, '00') === 0) {
$digits = substr($digits, 2);
$hasPlus = true;
}
// 1) 港澳台特殊标注
$special = specialRegion($digits);
if ($special !== null) {
return ['country' => $special, 'province' => '', 'city' => '', 'label' => $special, 'found' => true];
}
// 2) 中国大陆号码:+86/0086 开头 或 11位以 1 开头
if (isChinaMobile($digits, $hasPlus)) {
$geo = lookupChinaPhoneDat($digits);
if ($geo !== null) {
return [
'country' => '中国',
'province' => $geo['province'],
'city' => $geo['city'],
'label' => '中国-' . $geo['province'] . ($geo['city'] && $geo['city'] !== $geo['province'] ? '-' . $geo['city'] : ''),
'found' => true,
];
}
return ['country' => '中国', 'province' => '', 'city' => '', 'label' => '中国', 'found' => false];
}
// 3) 海外号码:识别国籍(国家区号表)
$country = lookupCountryByCode($digits);
if ($country !== null) {
return ['country' => $country, 'province' => '', 'city' => '', 'label' => $country, 'found' => true];
}
return ['country' => '', 'province' => '', 'city' => '', 'label' => '未知', 'found' => false];
}
/** 港澳台特殊识别 */
function specialRegion($digits)
{
// 852 香港 / 853 澳门 / 886 台湾(号码 8-9 位)
if (strpos($digits, '852') === 0 && strlen($digits) >= 11) return '中国香港';
if (strpos($digits, '853') === 0 && strlen($digits) >= 11) return '中国澳门';
if (strpos($digits, '886') === 0 && strlen($digits) >= 12) return '中国台湾';
return null;
}
/** 是否中国大陆手机号 */
function isChinaMobile($digits, $hasPlus)
{
// +86 或 0086 开头(去除前缀后为 11 位 1 开头)
if (strpos($digits, '86') === 0 && strlen($digits) === 13) {
return preg_match('/^861[3-9]\d{9}$/', $digits) === 1;
}
// 纯 11 位 1 开头(无国际前缀)
if (!$hasPlus && strlen($digits) === 11 && $digits[0] === '1') {
return preg_match('/^1[3-9]\d{9}$/', $digits) === 1;
}
return false;
}
/** 使用 phone.dat 查询国内省市 */
function lookupChinaPhoneDat($digits)
{
static $loader = null;
if ($loader === null) {
$loader = new PhoneDatLoader(__DIR__ . '/../../data/phone.dat');
}
// 提取 11 位号码中的前 7 位号段(如 1381234);带 86 前缀先去前缀
$local = preg_replace('/^86/', '', $digits);
$prefix7 = substr($local, 0, 7);
if (strlen($prefix7) !== 7 || !preg_match('/^\d{7}$/', $prefix7)) {
return null;
}
return $loader->find($prefix7);
}
/** 海外号码:国家区号表识别国籍 */
function lookupCountryByCode($digits)
{
static $map = null;
if ($map === null) {
$map = require __DIR__ . '/country_codes.php';
}
// 依次尝试 3 位 / 2 位 / 1 位区号
for ($len = 3; $len >= 1; $len--) {
$code = substr($digits, 0, $len);
if (isset($map[$code])) {
return $map[$code];
}
}
return null;
}
/* ==================== phone.dat 解析器(PHP 移植) ==================== */
/**
* phone.dat 文件解析器
* 文件格式(与 Python phone 库一致):
* - 头部:4字节版本号 + 4字节首条记录偏移
* - 记录:每条 9 字节 = 4字节号段(int) + 4字节数据偏移(int) + 1字节号码类型
* - 数据区:省份|城市|邮编|区号 (竖线分隔,\0 结尾)
*/
class PhoneDatLoader
{
private $buf;
private $firstOffset;
private $count;
public function __construct($file)
{
$this->buf = file_get_contents($file);
if ($this->buf === false || strlen($this->buf) < 8) {
$this->buf = '';
$this->firstOffset = 0;
$this->count = 0;
return;
}
$head = unpack('a4version/ioffset', substr($this->buf, 0, 8));
$this->firstOffset = $head['offset'];
$this->count = (int)((strlen($this->buf) - $this->firstOffset) / 9);
}
/**
* 按 7 位号段查询
* @param string $prefix7 如 "1381234"
* @return array|null ['province'=>, 'city'=>, 'zip_code'=>, 'area_code'=>, 'phone_type'=>]
*/
public function find($prefix7)
{
if ($this->count <= 0 || !preg_match('/^\d{7}$/', (string)$prefix7)) {
return null;
}
$intPhone = (int)$prefix7;
$left = 0;
$right = $this->count;
$buflen = strlen($this->buf);
while ($left <= $right) {
$middle = (int)(($left + $right) / 2);
$offset = $this->firstOffset + $middle * 9;
if ($offset >= $buflen) {
return null;
}
$rec = unpack('iprefix/irecord_offset/Ctype', substr($this->buf, $offset, 9));
if ($rec['prefix'] > $intPhone) {
$right = $middle - 1;
} elseif ($rec['prefix'] < $intPhone) {
$left = $middle + 1;
} else {
$content = $this->readRecord($rec['record_offset']);
if ($content === null) {
return null;
}
$parts = explode('|', $content);
return [
'province' => $parts[0] ?? '',
'city' => $parts[1] ?? '',
'zip_code' => $parts[2] ?? '',
'area_code' => $parts[3] ?? '',
'phone_type' => $this->typeName($rec['type']),
];
}
}
return null;
}
private function readRecord($offset)
{
$end = strpos($this->buf, "\x00", $offset);
if ($end === false) {
return null;
}
return substr($this->buf, $offset, $end - $offset);
}
private function typeName($no)
{
$map = [
1 => '移动', 2 => '联通', 3 => '电信',
4 => '电信虚拟运营商', 5 => '联通虚拟运营商', 6 => '移动虚拟运营商',
7 => '广电', 8 => '广电虚拟运营商',
];
return $map[$no] ?? '未知';
}
}