c86f08c325
Co-Authored-By: Claude Code <noreply@anthropic.com>
228 lines
7.8 KiB
PHP
228 lines
7.8 KiB
PHP
<?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] ?? '未知';
|
||
}
|
||
}
|