520 lines
19 KiB
PHP
520 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* IP 归属地查询(腾讯 / 高德 / 太平洋 / 轮询)。
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Extensions;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class IPGeo {
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
/**
|
||
* 查询 IP 归属地。
|
||
*
|
||
* @param string $ip IPv4 / IPv6
|
||
* @param bool $force 管理端测试:跳过启用开关与缓存;仍拒绝私有 IP
|
||
* @return array{country:string,province:string,city:string,raw:array,reason?:string,provider?:string,http_code?:int,detail?:string}
|
||
*/
|
||
public function lookup(string $ip, bool $force = false): array {
|
||
$config = $this->get_config();
|
||
$ip = trim($ip);
|
||
|
||
if (!$force && empty($config['ip_geo_enabled'])) {
|
||
return $this->empty_result('disabled');
|
||
}
|
||
|
||
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
|
||
return $this->empty_result('invalid_ip');
|
||
}
|
||
|
||
if ($this->is_private_ip($ip)) {
|
||
return $this->empty_result('private_ip');
|
||
}
|
||
|
||
$cache_key = 'aksrt_ip_geo_' . md5($ip);
|
||
$ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400));
|
||
if (!$force) {
|
||
$cached = get_transient($cache_key);
|
||
if ($cached !== false && is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
}
|
||
|
||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
|
||
$result = $this->query_provider($provider, $ip, $config);
|
||
|
||
if ($this->has_location($result)) {
|
||
if (!$force) {
|
||
set_transient($cache_key, $result, $ttl);
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
if (empty($result['reason'])) {
|
||
$result['reason'] = 'provider_empty';
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function get_status(): array {
|
||
$config = $this->get_config();
|
||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
|
||
$enabled = !empty($config['ip_geo_enabled']);
|
||
|
||
$missing = [];
|
||
if ($enabled && $provider === 'qq') {
|
||
if (empty($config['ip_addr_sdk_qq_appkey'])) {
|
||
$missing[] = 'ip_addr_sdk_qq_appkey';
|
||
}
|
||
}
|
||
if ($enabled && $provider === 'amap') {
|
||
if (empty($config['ip_addr_sdk_amap_appkey'])) {
|
||
$missing[] = 'ip_addr_sdk_amap_appkey';
|
||
}
|
||
}
|
||
|
||
return [
|
||
'enabled' => $enabled,
|
||
'provider' => $provider,
|
||
'configured' => $enabled && empty($missing),
|
||
'available' => $enabled,
|
||
'verified' => !empty($config['ip_geo_verified']),
|
||
'transport' => 'http_api',
|
||
'missing_fields' => $missing,
|
||
'cache_ttl' => (int) ($config['ip_geo_cache_ttl'] ?? 86400),
|
||
'display' => (array) ($config['ip_geo_display'] ?? ['comment', 'user_home', 'user_center', 'post']),
|
||
'format' => (string) ($config['ip_geo_format'] ?? 'province_city'),
|
||
];
|
||
}
|
||
|
||
public function format(array $geo, string $format = ''): string {
|
||
if ($format === '') {
|
||
$config = $this->get_config();
|
||
$format = (string) ($config['ip_geo_format'] ?? 'province_city');
|
||
}
|
||
|
||
$country = $this->string_field($geo['country'] ?? '');
|
||
$province = $this->string_field($geo['province'] ?? '');
|
||
$city = $this->string_field($geo['city'] ?? '');
|
||
|
||
switch ($format) {
|
||
case 'province':
|
||
return $province !== '' ? $province : $city;
|
||
case 'country_province':
|
||
return trim($country . ' ' . $province);
|
||
case 'province_city':
|
||
default:
|
||
$parts = array_filter([$province, $city]);
|
||
return $parts ? implode(' ', $parts) : $country;
|
||
}
|
||
}
|
||
|
||
private function get_config(): array {
|
||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||
return \AKSRT\Settings\Settings::get_ip_geo_settings();
|
||
}
|
||
return ['ip_geo_enabled' => false];
|
||
}
|
||
|
||
private function query_provider(string $provider, string $ip, array $config): array {
|
||
switch ($provider) {
|
||
case 'qq':
|
||
return $this->query_qq($ip, $config);
|
||
case 'amap':
|
||
$amap = $this->query_amap($ip, $config);
|
||
if ($this->has_location($amap)) {
|
||
return $amap;
|
||
}
|
||
// Credential / platform mismatch: free path so front still works.
|
||
if ($this->should_fallback_free($amap)) {
|
||
$free = $this->query_pconline($ip);
|
||
if ($this->has_location($free)) {
|
||
$free['detail'] = 'amap_failed_fallback_free:' . (string) ($amap['detail'] ?? $amap['reason'] ?? '');
|
||
return $free;
|
||
}
|
||
}
|
||
return $amap;
|
||
case 'polling':
|
||
$last = $this->empty_result('provider_empty');
|
||
foreach (['qq', 'amap', 'null'] as $fallback) {
|
||
if ($fallback === 'qq' && empty($config['ip_addr_sdk_qq_appkey'])) {
|
||
continue;
|
||
}
|
||
if ($fallback === 'amap' && empty($config['ip_addr_sdk_amap_appkey'])) {
|
||
continue;
|
||
}
|
||
// Avoid re-entering amap→free→… recursion: call leaf methods.
|
||
if ($fallback === 'amap') {
|
||
$result = $this->query_amap($ip, $config);
|
||
} elseif ($fallback === 'qq') {
|
||
$result = $this->query_qq($ip, $config);
|
||
} else {
|
||
$result = $this->query_pconline($ip);
|
||
}
|
||
if ($this->has_location($result)) {
|
||
return $result;
|
||
}
|
||
$last = $result;
|
||
}
|
||
// Last resort free path.
|
||
$free = $this->query_pconline($ip);
|
||
return $this->has_location($free) ? $free : $last;
|
||
case 'null':
|
||
default:
|
||
// Admin label「太平洋公共接口」: pconline, with ip-api if blocked.
|
||
return $this->query_pconline($ip);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a paid-provider failure should fall back to free IP lookup.
|
||
*/
|
||
private function should_fallback_free(array $result): bool {
|
||
$reason = (string) ($result['reason'] ?? '');
|
||
if (in_array($reason, ['missing_key', 'http_error'], true)) {
|
||
return true;
|
||
}
|
||
if ($reason !== 'provider_error') {
|
||
return false;
|
||
}
|
||
$detail = strtoupper((string) ($result['detail'] ?? ''));
|
||
foreach (['USERKEY_PLAT_NOMATCH', 'INVALID_USER_KEY', 'INVALID_USER_SCODE', 'DAILY_QUERY_OVER_LIMIT', 'ACCESS_TOO_FREQUENT', 'CUQPS_HAS_EXCEEDED_THE_LIMIT'] as $code) {
|
||
if (str_contains($detail, $code)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private function query_qq(string $ip, array $config): array {
|
||
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
|
||
$secretkey = trim((string) ($config['ip_addr_sdk_qq_secretkey'] ?? ''));
|
||
if ($appkey === '') {
|
||
return $this->empty_result('missing_key', ['provider' => 'qq', 'detail' => 'missing appkey']);
|
||
}
|
||
|
||
// Path used for Tencent signature (zibll parity).
|
||
$path = '/ws/location/v1/ip';
|
||
$query = 'ip=' . rawurlencode($ip) . '&key=' . rawurlencode($appkey);
|
||
$url = 'https://apis.map.qq.com' . $path . '?' . $query;
|
||
if ($secretkey !== '') {
|
||
$url .= '&sig=' . md5($path . '?' . $query . $secretkey);
|
||
}
|
||
|
||
$http = $this->http_get($url, 8);
|
||
if (!$http['ok']) {
|
||
return $this->empty_result('http_error', [
|
||
'provider' => 'qq',
|
||
'http_code' => $http['code'],
|
||
'detail' => $http['error'],
|
||
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
|
||
]);
|
||
}
|
||
|
||
$data = $http['json'];
|
||
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) {
|
||
$msg = is_array($data) ? (string) ($data['message'] ?? 'status_error') : 'invalid_json';
|
||
return $this->empty_result('provider_error', [
|
||
'provider' => 'qq',
|
||
'http_code' => $http['code'],
|
||
'detail' => $msg,
|
||
'raw' => is_array($data) ? $data : [],
|
||
]);
|
||
}
|
||
|
||
$info = is_array($data['result']['ad_info'] ?? null) ? $data['result']['ad_info'] : [];
|
||
return [
|
||
'country' => $this->string_field($info['nation'] ?? '中国'),
|
||
'province' => $this->string_field($info['province'] ?? ''),
|
||
'city' => $this->string_field($info['city'] ?? ''),
|
||
'raw' => $data,
|
||
'provider' => 'qq',
|
||
];
|
||
}
|
||
|
||
private function query_amap(string $ip, array $config): array {
|
||
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
|
||
$secretkey = trim((string) ($config['ip_addr_sdk_amap_secretkey'] ?? ''));
|
||
if ($appkey === '') {
|
||
return $this->empty_result('missing_key', [
|
||
'provider' => 'amap',
|
||
'detail' => 'missing appkey',
|
||
]);
|
||
}
|
||
|
||
// Amap sign: md5(sorted query without sig + secret) — zibll uses ip=&key= order.
|
||
$base_query = 'ip=' . $ip . '&key=' . $appkey;
|
||
$url = 'https://restapi.amap.com/v3/ip?' . $base_query;
|
||
if ($secretkey !== '') {
|
||
$url .= '&sig=' . md5($base_query . $secretkey);
|
||
}
|
||
|
||
$http = $this->http_get($url, 8);
|
||
if (!$http['ok']) {
|
||
return $this->empty_result('http_error', [
|
||
'provider' => 'amap',
|
||
'http_code' => $http['code'],
|
||
'detail' => $http['error'],
|
||
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
|
||
]);
|
||
}
|
||
|
||
$data = $http['json'];
|
||
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') {
|
||
$info = is_array($data) ? (string) ($data['info'] ?? 'status_error') : 'invalid_json';
|
||
// Common: INVALID_USER_KEY / DAILY_QUERY_OVER_LIMIT / USERKEY_PLAT_NOMATCH
|
||
return $this->empty_result('provider_error', [
|
||
'provider' => 'amap',
|
||
'http_code' => $http['code'],
|
||
'detail' => $info,
|
||
'raw' => is_array($data) ? $data : [],
|
||
]);
|
||
}
|
||
|
||
// Amap returns empty arrays for province/city on some IPs / overseas.
|
||
$province = $this->string_field($data['province'] ?? '');
|
||
$city = $this->string_field($data['city'] ?? '');
|
||
$country = '中国';
|
||
if ($province === '' && $city === '') {
|
||
return $this->empty_result('provider_empty', [
|
||
'provider' => 'amap',
|
||
'detail' => 'empty province/city',
|
||
'raw' => $data,
|
||
]);
|
||
}
|
||
|
||
return [
|
||
'country' => $country,
|
||
'province' => $province,
|
||
'city' => $city,
|
||
'raw' => $data,
|
||
'provider' => 'amap',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 太平洋网络 whois(GB2312 正文)。
|
||
*/
|
||
private function query_pconline(string $ip): array {
|
||
$url = 'http://whois.pconline.com.cn/ipJson.jsp?json=true&ip=' . rawurlencode($ip);
|
||
$http = $this->http_get($url, 8, [
|
||
'Accept' => '*/*',
|
||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Referer' => 'https://www.pconline.com.cn/',
|
||
]);
|
||
|
||
if (!$http['ok']) {
|
||
// Fallback: ip-api only when pconline network fails (not dual storage — runtime failover).
|
||
$fallback = $this->query_ip_api($ip);
|
||
if ($this->has_location($fallback)) {
|
||
$fallback['provider'] = 'ip-api';
|
||
$fallback['detail'] = 'pconline_failed_fallback_ip_api';
|
||
return $fallback;
|
||
}
|
||
return $this->empty_result('http_error', [
|
||
'provider' => 'pconline',
|
||
'http_code' => $http['code'],
|
||
'detail' => $http['error'] !== '' ? $http['error'] : 'pconline blocked or unreachable',
|
||
'raw' => $http['body_raw'] !== '' ? ['body' => mb_substr($http['body_raw'], 0, 500)] : [],
|
||
]);
|
||
}
|
||
|
||
$body = $http['body_raw'];
|
||
// pconline often serves GBK/GB2312.
|
||
if (function_exists('mb_convert_encoding')) {
|
||
$converted = @mb_convert_encoding($body, 'UTF-8', 'GBK,GB2312,UTF-8');
|
||
if (is_string($converted) && $converted !== '') {
|
||
$body = $converted;
|
||
}
|
||
}
|
||
|
||
$json = json_decode($body, true);
|
||
if (is_array($json)) {
|
||
$province = $this->string_field($json['pro'] ?? $json['province'] ?? '');
|
||
$city = $this->string_field($json['city'] ?? '');
|
||
$region = $this->string_field($json['region'] ?? '');
|
||
$addr = $this->string_field($json['addr'] ?? '');
|
||
$country = '';
|
||
if ($addr !== '' && preg_match('/^(\S+)/u', $addr, $m)) {
|
||
$country = $m[1];
|
||
}
|
||
if ($province !== '' || $city !== '') {
|
||
return [
|
||
'country' => $country !== '' ? $country : '中国',
|
||
'province' => $province,
|
||
'city' => $city !== '' ? $city : $region,
|
||
'raw' => $json,
|
||
'provider' => 'pconline',
|
||
];
|
||
}
|
||
}
|
||
|
||
// Regex fallback (zibll).
|
||
preg_match('/"pro":"(.*?)"/u', $body, $pro);
|
||
preg_match('/"city":"(.*?)"/u', $body, $city);
|
||
preg_match('/"region":"(.*?)"/u', $body, $region);
|
||
preg_match('/"addr":"(.*?)(\s)(.*?)"/u', $body, $addr);
|
||
|
||
$province = $this->string_field($pro[1] ?? '');
|
||
$city_s = $this->string_field($city[1] ?? '');
|
||
$country = $this->string_field($addr[1] ?? '');
|
||
|
||
if ($province === '' && $city_s === '') {
|
||
return $this->empty_result('provider_empty', [
|
||
'provider' => 'pconline',
|
||
'detail' => 'parse_failed',
|
||
'raw' => ['body' => mb_substr($body, 0, 500)],
|
||
]);
|
||
}
|
||
|
||
return [
|
||
'country' => $country !== '' ? $country : '中国',
|
||
'province' => $province,
|
||
'city' => $city_s !== '' ? $city_s : $this->string_field($region[1] ?? ''),
|
||
'raw' => ['body' => $body],
|
||
'provider' => 'pconline',
|
||
];
|
||
}
|
||
|
||
private function query_ip_api(string $ip): array {
|
||
$url = 'http://ip-api.com/json/' . rawurlencode($ip) . '?lang=zh-CN&fields=status,message,country,regionName,city,query';
|
||
$http = $this->http_get($url, 6);
|
||
if (!$http['ok']) {
|
||
return $this->empty_result('http_error', [
|
||
'provider' => 'ip-api',
|
||
'http_code' => $http['code'],
|
||
'detail' => $http['error'],
|
||
]);
|
||
}
|
||
$data = $http['json'];
|
||
if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
|
||
return $this->empty_result('provider_error', [
|
||
'provider' => 'ip-api',
|
||
'detail' => is_array($data) ? (string) ($data['message'] ?? 'fail') : 'invalid_json',
|
||
'raw' => is_array($data) ? $data : [],
|
||
]);
|
||
}
|
||
return [
|
||
'country' => $this->string_field($data['country'] ?? ''),
|
||
'province' => $this->string_field($data['regionName'] ?? ''),
|
||
'city' => $this->string_field($data['city'] ?? ''),
|
||
'raw' => $data,
|
||
'provider' => 'ip-api',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $extra_headers
|
||
* @return array{ok:bool,code:int,error:string,json:?array,body_raw:string}
|
||
*/
|
||
private function http_get(string $url, int $timeout = 8, array $extra_headers = []): array {
|
||
$headers = array_merge([
|
||
'Accept' => 'application/json,text/plain,*/*',
|
||
'User-Agent' => 'AKSRT-IPGeo/3.4 (+WordPress)',
|
||
], $extra_headers);
|
||
|
||
$response = wp_remote_get($url, [
|
||
'timeout' => $timeout,
|
||
'redirection' => 3,
|
||
'sslverify' => true,
|
||
'headers' => $headers,
|
||
]);
|
||
|
||
if (is_wp_error($response)) {
|
||
return [
|
||
'ok' => false,
|
||
'code' => 0,
|
||
'error' => $response->get_error_message(),
|
||
'json' => null,
|
||
'body_raw' => '',
|
||
];
|
||
}
|
||
|
||
$code = (int) wp_remote_retrieve_response_code($response);
|
||
$body = (string) wp_remote_retrieve_body($response);
|
||
if ($code < 200 || $code >= 300) {
|
||
return [
|
||
'ok' => false,
|
||
'code' => $code,
|
||
'error' => 'HTTP ' . $code,
|
||
'json' => null,
|
||
'body_raw' => $body,
|
||
];
|
||
}
|
||
|
||
$json = json_decode($body, true);
|
||
|
||
return [
|
||
'ok' => true,
|
||
'code' => $code,
|
||
'error' => '',
|
||
'json' => is_array($json) ? $json : null,
|
||
'body_raw' => $body,
|
||
];
|
||
}
|
||
|
||
private function is_private_ip(string $ip): bool {
|
||
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||
}
|
||
|
||
private function has_location(array $result): bool {
|
||
return $this->string_field($result['province'] ?? '') !== ''
|
||
|| $this->string_field($result['city'] ?? '') !== ''
|
||
|| $this->string_field($result['country'] ?? '') !== '';
|
||
}
|
||
|
||
/**
|
||
* Amap may return [] for empty province/city.
|
||
*
|
||
* @param mixed $value Raw field.
|
||
*/
|
||
private function string_field($value): string {
|
||
if (is_array($value)) {
|
||
if ($value === []) {
|
||
return '';
|
||
}
|
||
$value = reset($value);
|
||
}
|
||
if (!is_scalar($value) && $value !== null) {
|
||
return '';
|
||
}
|
||
return trim((string) $value);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $extra Extra fields.
|
||
* @return array{country:string,province:string,city:string,raw:array,reason?:string}
|
||
*/
|
||
private function empty_result(string $reason = '', array $extra = []): array {
|
||
$out = [
|
||
'country' => '',
|
||
'province' => '',
|
||
'city' => '',
|
||
'raw' => is_array($extra['raw'] ?? null) ? $extra['raw'] : [],
|
||
];
|
||
if ($reason !== '') {
|
||
$out['reason'] = $reason;
|
||
}
|
||
foreach (['provider', 'http_code', 'detail'] as $k) {
|
||
if (array_key_exists($k, $extra)) {
|
||
$out[$k] = $extra[$k];
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
}
|