diff --git a/src/AKSRT/Admin/ExtensionController.php b/src/AKSRT/Admin/ExtensionController.php index 4e8a036a..44a3c267 100644 --- a/src/AKSRT/Admin/ExtensionController.php +++ b/src/AKSRT/Admin/ExtensionController.php @@ -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'); } } diff --git a/src/AKSRT/Extensions/IPGeo.php b/src/AKSRT/Extensions/IPGeo.php index e00ee7f2..a3ddabf5 100644 --- a/src/AKSRT/Extensions/IPGeo.php +++ b/src/AKSRT/Extensions/IPGeo.php @@ -1,5 +1,7 @@ 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 $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 $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; } }