Merge pull request 'fix(ip-geo): real pconline + amap sig/errors; clearer admin test failures' (#300) from worktree-ip-location-display into main

Reviewed-on: CYL/wordpress-theme-aksrt#300
This commit is contained in:
2026-07-25 15:38:18 +08:00
2 changed files with 349 additions and 73 deletions
+30 -4
View File
@@ -311,26 +311,52 @@ class ExtensionController extends ControllerBase {
$ok = !empty($result['country']) || !empty($result['province']) || !empty($result['city']);
if (!$ok) {
$detail = trim((string) ($result['detail'] ?? ''));
switch ($reason) {
case 'private_ip':
$message = __('该地址为私有/保留 IP,无法查询归属地。请换公网 IP 测试。', 'aksrt');
$message = __('该地址为私有/保留 IP(如 127.0.0.1 / 内网),无法查询。请用公网 IP 测试,例如 114.114.114.114。', 'aksrt');
break;
case 'invalid_ip':
$message = __('IP 格式无效。', 'aksrt');
break;
case 'missing_key':
$message = __('当前服务商 AppKey 未填写,请在「扩展 → IP归属地」补全凭据。', 'aksrt');
break;
case 'provider_error':
// e.g. INVALID_USER_KEY from Amap
$message = $detail !== ''
? sprintf(
/* translators: %s: provider error code/message */
__('服务商拒绝请求:%s。请检查 Key 是否开通「IP定位」且平台类型匹配(Web服务)。', 'aksrt'),
$detail
)
: __('服务商返回错误,请检查 Key 权限与配额。', 'aksrt');
break;
case 'http_error':
$message = $detail !== ''
? sprintf(
/* translators: %s: transport error */
__('无法访问归属地接口:%s。请检查服务器出站网络/防火墙。', 'aksrt'),
$detail
)
: __('无法访问归属地接口,请检查服务器出站网络。', 'aksrt');
break;
case 'provider_empty':
if (!empty($status['missing_fields'])) {
$message = sprintf(
/* translators: %s: missing config field keys */
__('当前服务商凭据不完整(缺少:%s),请先在「扩展 → IP归属地」补全配置。', 'aksrt'),
__('当前服务商凭据不完整(缺少:%s)。', 'aksrt'),
implode(', ', (array) $status['missing_fields'])
);
} else {
$message = __('服务商未返回有效归属地,请检查凭据、网络出口或换一个 IP 重试。', 'aksrt');
$message = __('服务商未返回有效归属地,请换公网 IP 或检查 Key 权限。', 'aksrt');
}
break;
case 'disabled':
$message = __('IP 归属地功能未启用。', 'aksrt');
break;
default:
$message = __('未查询到归属地信息。', 'aksrt');
$message = $detail !== '' ? $detail : __('未查询到归属地信息。', 'aksrt');
}
}
+319 -69
View File
@@ -1,5 +1,7 @@
<?php
/**
* IP 归属地查询(腾讯 / 高德 / 太平洋 / 轮询)。
*
* @package AKSRT
*/
@@ -12,25 +14,31 @@ class IPGeo {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 查询 IP 归属地
* 查询 IP 归属地。
*
* @param string $ip IPv4 or IPv6
* @param bool $force 管理端测试用:跳过「启用」开关与缓存,仍拒绝私有 IP
* @return array{country: string, province: string, city: string, raw: array, reason?: string}
* @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');
}
// 本地/私有 IP 跳过查询
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');
}
@@ -47,7 +55,7 @@ class IPGeo {
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
$result = $this->query_provider($provider, $ip, $config);
if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) {
if ($this->has_location($result)) {
if (!$force) {
set_transient($cache_key, $result, $ttl);
}
@@ -61,9 +69,6 @@ class IPGeo {
return $result;
}
/**
* 获取服务运行时状态
*/
public function get_status(): array {
$config = $this->get_config();
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
@@ -71,12 +76,14 @@ class IPGeo {
$missing = [];
if ($enabled && $provider === 'qq') {
if (empty($config['ip_addr_sdk_qq_appkey'])) $missing[] = 'ip_addr_sdk_qq_appkey';
if (empty($config['ip_addr_sdk_qq_secretkey'])) $missing[] = 'ip_addr_sdk_qq_secretkey';
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';
if (empty($config['ip_addr_sdk_amap_secretkey'])) $missing[] = 'ip_addr_sdk_amap_secretkey';
if (empty($config['ip_addr_sdk_amap_appkey'])) {
$missing[] = 'ip_addr_sdk_amap_appkey';
}
}
return [
@@ -93,24 +100,25 @@ class IPGeo {
];
}
/**
* 格式化归属地显示文本
*/
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 $geo['province'] ?? '';
return $province !== '' ? $province : $city;
case 'country_province':
return trim(($geo['country'] ?? '') . ' ' . ($geo['province'] ?? ''));
return trim($country . ' ' . $province);
case 'province_city':
default:
$parts = array_filter([$geo['province'] ?? '', $geo['city'] ?? '']);
return implode(' ', $parts) ?: ($geo['country'] ?? '');
$parts = array_filter([$province, $city]);
return $parts ? implode(' ', $parts) : $country;
}
}
@@ -128,90 +136,294 @@ class IPGeo {
case 'amap':
return $this->query_amap($ip, $config);
case 'polling':
$last = $this->empty_result('provider_empty');
foreach (['qq', 'amap', 'null'] as $fallback) {
// Skip providers that clearly lack keys.
if ($fallback === 'qq' && empty($config['ip_addr_sdk_qq_appkey'])) {
continue;
}
if ($fallback === 'amap' && empty($config['ip_addr_sdk_amap_appkey'])) {
continue;
}
$result = $this->query_provider($fallback, $ip, $config);
if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) {
if ($this->has_location($result)) {
return $result;
}
$last = $result;
}
return $this->empty_result();
return $last;
case 'null':
default:
return $this->query_ip_api($ip);
// Canonical free provider: 太平洋 (pconline). Label matches admin UI.
return $this->query_pconline($ip);
}
}
private function query_qq(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_qq_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result();
return $this->empty_result('missing_key', ['provider' => 'qq', 'detail' => 'missing appkey']);
}
$url = add_query_arg([
'ip' => $ip,
'key' => $appkey,
], 'https://apis.map.qq.com/ws/location/v1/ip');
// 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);
}
$response = wp_remote_get($url, [
'timeout' => 8,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) return $this->empty_result();
$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 = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) return $this->empty_result();
$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 = $data['result']['ad_info'] ?? [];
$info = is_array($data['result']['ad_info'] ?? null) ? $data['result']['ad_info'] : [];
return [
'country' => '中国',
'province' => (string) ($info['province'] ?? ''),
'city' => (string) ($info['city'] ?? ''),
'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'] ?? ''));
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_amap_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result();
return $this->empty_result('missing_key', [
'provider' => 'amap',
'detail' => 'missing appkey',
]);
}
$url = add_query_arg([
'ip' => $ip,
'key' => $appkey,
], 'https://restapi.amap.com/v3/ip');
// 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);
}
$response = wp_remote_get($url, [
'timeout' => 8,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) return $this->empty_result();
$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 = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') return $this->empty_result();
$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' => '中国',
'province' => (string) ($data['province'] ?? ''),
'city' => (string) ($data['city'] ?? ''),
'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 {
$response = wp_remote_get("http://ip-api.com/json/{$ip}?lang=zh-CN", [
'timeout' => 5,
]);
if (is_wp_error($response)) return $this->empty_result();
$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',
];
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || ($data['status'] ?? '') !== 'success') return $this->empty_result();
/**
* @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 [
'country' => (string) ($data['country'] ?? ''),
'province' => (string) ($data['regionName'] ?? ''),
'city' => (string) ($data['city'] ?? ''),
'raw' => $data,
'ok' => true,
'code' => $code,
'error' => '',
'json' => is_array($json) ? $json : null,
'body_raw' => $body,
];
}
@@ -219,11 +431,49 @@ class IPGeo {
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
private function empty_result(string $reason = ''): array {
$out = ['country' => '', 'province' => '', 'city' => '', 'raw' => []];
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;
}
}