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
+227
View File
@@ -0,0 +1,227 @@
<?php
/**
* 国际电话国家区号表(区号 => 中文国籍/地区名)
* 用于海外手机号码归属地识别(对应 DeepSeek 方案一中 phonenumbers 库的国家识别能力)。
* 覆盖常用国家/地区;未收录区号将返回"未知"。
*/
return [
// 1 区:北美
'1' => '美国/加拿大',
// 7 区:俄罗斯/哈萨克斯坦
'7' => '俄罗斯',
// 20 区
'20' => '埃及',
// 27 区:南非
'27' => '南非',
// 30-39 区:欧洲
'30' => '希腊',
'31' => '荷兰',
'32' => '比利时',
'33' => '法国',
'34' => '西班牙',
'36' => '匈牙利',
'39' => '意大利',
// 40-49 区
'40' => '罗马尼亚',
'41' => '瑞士',
'43' => '奥地利',
'44' => '英国',
'45' => '丹麦',
'46' => '瑞典',
'47' => '挪威',
'48' => '波兰',
'49' => '德国',
// 50-59 区
'51' => '秘鲁',
'52' => '墨西哥',
'53' => '古巴',
'54' => '阿根廷',
'55' => '巴西',
'56' => '智利',
'57' => '哥伦比亚',
'58' => '委内瑞拉',
// 60-69 区
'60' => '马来西亚',
'61' => '澳大利亚',
'62' => '印度尼西亚',
'63' => '菲律宾',
'64' => '新西兰',
'65' => '新加坡',
'66' => '泰国',
// 7x 区:俄罗斯等(已在上方)
// 80-89 区
'81' => '日本',
'82' => '韩国',
'84' => '越南',
'86' => '中国',
// 90-99 区
'90' => '土耳其',
'91' => '印度',
'92' => '巴基斯坦',
'93' => '阿富汗',
'94' => '斯里兰卡',
'95' => '缅甸',
'96' => '约旦',
'97' => '叙利亚',
'98' => '伊朗',
// 其他常见
'211' => '南苏丹',
'212' => '摩洛哥',
'213' => '阿尔及利亚',
'216' => '突尼斯',
'218' => '利比亚',
'220' => '冈比亚',
'221' => '塞内加尔',
'222' => '毛里塔尼亚',
'223' => '马里',
'224' => '几内亚',
'225' => '科特迪瓦',
'226' => '布基纳法索',
'227' => '尼日尔',
'228' => '多哥',
'229' => '贝宁',
'230' => '毛里求斯',
'231' => '利比里亚',
'232' => '塞拉利昂',
'233' => '加纳',
'234' => '尼日利亚',
'235' => '乍得',
'236' => '中非',
'237' => '喀麦隆',
'238' => '佛得角',
'239' => '圣多美和普林西比',
'240' => '赤道几内亚',
'241' => '加蓬',
'242' => '刚果(布)',
'243' => '刚果(金)',
'244' => '安哥拉',
'245' => '几内亚比绍',
'246' => '迪戈加西亚',
'248' => '塞舌尔',
'249' => '苏丹',
'250' => '卢旺达',
'251' => '埃塞俄比亚',
'252' => '索马里',
'253' => '吉布提',
'254' => '肯尼亚',
'255' => '坦桑尼亚',
'256' => '乌干达',
'257' => '布隆迪',
'258' => '莫桑比克',
'260' => '赞比亚',
'261' => '马达加斯加',
'262' => '留尼汪/马约特',
'263' => '津巴布韦',
'264' => '纳米比亚',
'265' => '马拉维',
'266' => '莱索托',
'267' => '博茨瓦纳',
'268' => '斯威士兰',
'269' => '科摩罗',
'290' => '圣赫勒拿',
'291' => '厄立特里亚',
'297' => '阿鲁巴',
'298' => '法罗群岛',
'299' => '格陵兰',
'350' => '直布罗陀',
'351' => '葡萄牙',
'352' => '卢森堡',
'353' => '爱尔兰',
'354' => '冰岛',
'355' => '阿尔巴尼亚',
'356' => '马耳他',
'357' => '塞浦路斯',
'358' => '芬兰',
'359' => '保加利亚',
'370' => '立陶宛',
'371' => '拉脱维亚',
'372' => '爱沙尼亚',
'373' => '摩尔多瓦',
'374' => '亚美尼亚',
'375' => '白俄罗斯',
'376' => '安道尔',
'377' => '摩纳哥',
'378' => '圣马力诺',
'380' => '乌克兰',
'381' => '塞尔维亚',
'382' => '黑山',
'383' => '科索沃',
'385' => '克罗地亚',
'386' => '斯洛文尼亚',
'387' => '波黑',
'389' => '北马其顿',
'420' => '捷克',
'421' => '斯洛伐克',
'423' => '列支敦士登',
'500' => '福克兰群岛',
'501' => '伯利兹',
'502' => '危地马拉',
'503' => '萨尔瓦多',
'504' => '洪都拉斯',
'505' => '尼加拉瓜',
'506' => '哥斯达黎加',
'507' => '巴拿马',
'508' => '圣皮埃尔和密克隆',
'509' => '海地',
'590' => '瓜德罗普',
'591' => '玻利维亚',
'592' => '圭亚那',
'593' => '厄瓜多尔',
'594' => '法属圭亚那',
'595' => '巴拉圭',
'596' => '马提尼克',
'597' => '苏里南',
'598' => '乌拉圭',
'599' => '加勒比荷兰',
'670' => '东帝汶',
'672' => '南极领地',
'673' => '文莱',
'674' => '瑙鲁',
'675' => '巴布亚新几内亚',
'676' => '汤加',
'677' => '所罗门群岛',
'678' => '瓦努阿图',
'679' => '斐济',
'680' => '帕劳',
'681' => '瓦利斯和富图纳',
'682' => '库克群岛',
'683' => '纽埃',
'685' => '萨摩亚',
'686' => '基里巴斯',
'687' => '新喀里多尼亚',
'688' => '图瓦卢',
'689' => '法属波利尼西亚',
'690' => '托克劳',
'691' => '密克罗尼西亚',
'692' => '马绍尔群岛',
'850' => '朝鲜',
'852' => '中国香港',
'853' => '中国澳门',
'855' => '柬埔寨',
'856' => '老挝',
'880' => '孟加拉国',
'886' => '中国台湾',
'960' => '马尔代夫',
'961' => '黎巴嫩',
'962' => '约旦',
'963' => '叙利亚',
'964' => '伊拉克',
'965' => '科威特',
'966' => '沙特阿拉伯',
'967' => '也门',
'968' => '阿曼',
'970' => '巴勒斯坦',
'971' => '阿联酋',
'972' => '以色列',
'973' => '巴林',
'974' => '卡塔尔',
'975' => '不丹',
'976' => '蒙古',
'977' => '尼泊尔',
'992' => '塔吉克斯坦',
'993' => '土库曼斯坦',
'994' => '阿塞拜疆',
'995' => '格鲁吉亚',
'996' => '吉尔吉斯斯坦',
'998' => '乌兹别克斯坦',
];
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* 数据字典接口 GET /api/common/dicts.php
* 参数:type=country|area|industry|certificate|source_channel|all(默认 all)
* 返回各字典表启用中的数据,供前端下拉/联动使用。
* 数据来源:官方 Excel 生成的种子数据(见 sql/dict_seed.sql),不依赖任何第三方接口。
*/
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/response.php';
require_once __DIR__ . '/auth.php';
requireLogin();
$type = trim($_REQUEST['type'] ?? 'all');
$pdo = DB::getInstance()->getPdo();
$data = [];
if ($type === 'all' || $type === 'country') {
$data['countries'] = $pdo->query(
"SELECT id, code, name FROM date_dict_country WHERE is_active = 1 ORDER BY sort_order, id"
)->fetchAll();
}
if ($type === 'all' || $type === 'area') {
$data['areas'] = $pdo->query(
"SELECT id, code, name, parent_code FROM date_dict_area WHERE is_active = 1 ORDER BY sort_order, id"
)->fetchAll();
}
if ($type === 'all' || $type === 'industry') {
$data['industries'] = $pdo->query(
"SELECT id, code, name, parent_code FROM date_dict_industry WHERE is_active = 1 ORDER BY sort_order, id"
)->fetchAll();
}
if ($type === 'all' || $type === 'certificate') {
$data['certificates'] = $pdo->query(
"SELECT id, code, name FROM date_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id"
)->fetchAll();
}
if ($type === 'all' || $type === 'source_channel') {
$data['source_channels'] = $pdo->query(
"SELECT id, name FROM source_channels WHERE is_active = 1 ORDER BY sort_order, id"
)->fetchAll();
}
Response::success($data);
+4 -2
View File
@@ -14,6 +14,7 @@ const COMPANY_FIELDS = [
'industry' => 's', 'industry_subdivision' => 's',
'latest_employee_count' => 'i', 'latest_annual_revenue' => 's',
'is_listed' => 'i', 'stock_code' => 's', 'website' => 's',
'source_channel' => 's', 'source_detail' => 's',
];
/** 人员表可写入字段白名单 */
@@ -21,12 +22,13 @@ const PERSON_FIELDS = [
'union_id' => 's', 'full_name' => 's', 'gender' => 's', 'nationality' => 's',
'id_type' => 's', 'id_number' => 's', 'education' => 's', 'graduated_from' => 's',
'hometown' => 's', 'work_location' => 's',
'source_channel' => 's', 'source_detail' => 's',
];
/** 媒体(社交账号)表可写入字段白名单 */
const MEDIA_FIELDS = [
'owner_type' => 's', 'owner_id' => 'i', 'platform' => 's', 'account_id' => 's',
'profile_url' => 's', 'remark' => 's', 'is_primary' => 'i',
'profile_url' => 's', 'remark' => 's', 'is_primary' => 'i', 'is_defult' => 'i',
];
/** 媒体商业属性表可写入字段白名单 */
@@ -173,7 +175,7 @@ function saveCompanyCertifications($pdo, $companyId, $certifications)
return;
}
$valid = $pdo->prepare("SELECT id FROM certification_types WHERE id = ?");
$valid = $pdo->prepare("SELECT id FROM date_dict_certificate WHERE id = ?");
$ins = $pdo->prepare(
"INSERT INTO company_certifications
(company_id, certification_type_id, certificate_number, issue_date, expiry_date)
+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] ?? '未知';
}
}