This commit is contained in:
nanguaboss
2026-08-06 00:50:23 +08:00
parent 595520822a
commit 2cb688ed46
29 changed files with 5227 additions and 186 deletions
+229
View File
@@ -0,0 +1,229 @@
<?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__ . '/db.php';
require_once __DIR__ . '/response.php';
require_once __DIR__ . '/auth.php';
/**
* 接口入口:仅当作为 HTTP 接口直接访问时执行(便于函数被 require 复用/测试)
*/
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
requireLogin();
$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] ?? '未知';
}
}