Kaynağa Gözat

Add Unit Tests & Add Support for PingAgent

Add Unit Tests
- For  IP info
- For IP Connect detection
- For Currency Exchange

Add Support for PingAgent
- https://github.com/ProxyPanel/PingAgent
BrettonYe 1 hafta önce
ebeveyn
işleme
32a40d7013

+ 9 - 2
.env.example

@@ -42,13 +42,20 @@ MAIL_ENCRYPTION=ssl
 [email protected]
 [email protected]
 MAIL_FROM_NAME=ProxyPanel
 MAIL_FROM_NAME=ProxyPanel
 # 强制HTTPS (默认 true)
 # 强制HTTPS (默认 true)
-SESSION_SECURE_COOKIE=
+FORCE_HTTPS=
+
 #IP查询相关
 #IP查询相关
 BAIDU_APP_AK=
 BAIDU_APP_AK=
 IPINFO_ACCESS_TOKEN=
 IPINFO_ACCESS_TOKEN=
 IP2LOCATION_API_KEY=
 IP2LOCATION_API_KEY=
-IPDATA_API_KEY=
+IP_API_ACCESS_KEY=
 IP_API_KEY=ipapiq9SFY1Ic4
 IP_API_KEY=ipapiq9SFY1Ic4
+IPDATA_API_KEY=
+BJJII_KEY=
 
 
 API_LAYER_API_KEY=
 API_LAYER_API_KEY=
 CONTACT_TELEGRAM=https://t.me/+nW8AwsPPUsliYzg1
 CONTACT_TELEGRAM=https://t.me/+nW8AwsPPUsliYzg1
+
+# 自建探针服务器配置 Self Host Probe Servers
+PROBE_SERVERS_DOMESTIC=
+PROBE_SERVERS_FOREIGN=

+ 2 - 2
app/Console/Commands/NodeStatusDetection.php

@@ -85,7 +85,7 @@ class NodeStatusDetection extends Command
             // 使用DDNS的node先通过gethostbyname获取ipv4地址
             // 使用DDNS的node先通过gethostbyname获取ipv4地址
             foreach ($node->ips() as $ip) {
             foreach ($node->ips() as $ip) {
                 if ($node->detection_type) {
                 if ($node->detection_type) {
-                    $status = (new NetworkDetection)->networkStatus($ip, $node->port ?? 22);
+                    $status = NetworkDetection::networkStatus($ip, $node->port ?? 22);
 
 
                     if ($node->detection_type !== 1 && $status['icmp'] !== 1) {
                     if ($node->detection_type !== 1 && $status['icmp'] !== 1) {
                         $data[$node_id][$ip]['icmp'] = trans("admin.network_status.{$status['icmp']}");
                         $data[$node_id][$ip]['icmp'] = trans("admin.network_status.{$status['icmp']}");
@@ -136,7 +136,7 @@ class NodeStatusDetection extends Command
 
 
             foreach ($ips as $ip) {
             foreach ($ips as $ip) {
                 if ($node->detection_type) {
                 if ($node->detection_type) {
-                    $status = (new NetworkDetection)->networkStatus($ip, $node->port ?? 22);
+                    $status = NetworkDetection::networkStatus($ip, $node->port ?? 22);
 
 
                     if (($node->detection_type === 1 && $status['tcp'] === 1) || ($node->detection_type === 2 && $status['icmp'] === 1) || ($status['tcp'] === 1 && $status['icmp'] === 1)) {
                     if (($node->detection_type === 1 && $status['tcp'] === 1) || ($node->detection_type === 2 && $status['icmp'] === 1) || ($status['tcp'] === 1 && $status['icmp'] === 1)) {
                         $reachableIPs++;
                         $reachableIPs++;

+ 1 - 1
app/Http/Controllers/Admin/NodeController.php

@@ -244,7 +244,7 @@ class NodeController extends Controller
     public function checkNode(Node $node): JsonResponse
     public function checkNode(Node $node): JsonResponse
     { // 节点IP阻断检测
     { // 节点IP阻断检测
         foreach ($node->ips() as $ip) {
         foreach ($node->ips() as $ip) {
-            $status = (new NetworkDetection)->networkStatus($ip, $node->port ?? 22);
+            $status = NetworkDetection::networkStatus($ip, $n->port ?? 22);
             $data[$ip] = [trans("admin.network_status.{$status['icmp']}"), trans("admin.network_status.{$status['tcp']}")];
             $data[$ip] = [trans("admin.network_status.{$status['icmp']}"), trans("admin.network_status.{$status['tcp']}")];
         }
         }
 
 

+ 7 - 3
app/Providers/AppServiceProvider.php

@@ -19,12 +19,16 @@ class AppServiceProvider extends ServiceProvider
     public function register(): void
     public function register(): void
     {
     {
         if ($this->app->isLocal() && config('app.debug')) {
         if ($this->app->isLocal() && config('app.debug')) {
-            $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
-            $this->app->register(TelescopeServiceProvider::class);
             $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
             $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
             $this->app->register(\Barryvdh\Debugbar\ServiceProvider::class);
             $this->app->register(\Barryvdh\Debugbar\ServiceProvider::class);
+
+            if (class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) {
+                $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
+                $this->app->register(TelescopeServiceProvider::class);
+            }
         }
         }
-        if (File::exists(base_path().'/.env') && Schema::hasTable('config') && DB::table('config')->exists()) {
+
+        if (Schema::hasTable('config') && File::exists(base_path().'/.env') && DB::table('config')->exists()) {
             $this->app->register(SettingServiceProvider::class);
             $this->app->register(SettingServiceProvider::class);
         }
         }
     }
     }

+ 2 - 2
app/Providers/TelescopeServiceProvider.php

@@ -22,7 +22,7 @@ class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
         $this->hideSensitiveRequestDetails();
         $this->hideSensitiveRequestDetails();
 
 
         Telescope::filter(function (IncomingEntry $entry) {
         Telescope::filter(function (IncomingEntry $entry) {
-            if ($this->app->environment('local')) {
+            if ($this->app->islocal()) {
                 return true;
                 return true;
             }
             }
 
 
@@ -39,7 +39,7 @@ class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
      */
      */
     protected function hideSensitiveRequestDetails(): void
     protected function hideSensitiveRequestDetails(): void
     {
     {
-        if ($this->app->environment('local')) {
+        if ($this->app->islocal()) {
             return;
             return;
         }
         }
 
 

+ 2 - 2
app/Utils/Avatar.php

@@ -32,7 +32,7 @@ class Avatar
 
 
     public static function getQQAvatar(string $qq): ?string
     public static function getQQAvatar(string $qq): ?string
     {
     {
-        self::$basicRequest = Http::timeout(15)->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36');
+        self::$basicRequest = Http::timeout(5)->withOptions(['http_errors' => false])->withoutVerifying()->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36');
         $ret = null;
         $ret = null;
         $source = 1;
         $source = 1;
 
 
@@ -77,7 +77,7 @@ class Avatar
     {
     {
         // 'https://api.sretna.cn/kind/ar.php','https://api.qjqq.cn/api/MiYouShe',
         // 'https://api.sretna.cn/kind/ar.php','https://api.qjqq.cn/api/MiYouShe',
         // 'https://api.uomg.com/api/rand.avatar?sort=%E5%8A%A8%E6%BC%AB%E5%A5%B3&format=images','https://api.uomg.com/api/rand.avatar?sort=%E5%8A%A8%E6%BC%AB%E7%94%B7&format=images',
         // 'https://api.uomg.com/api/rand.avatar?sort=%E5%8A%A8%E6%BC%AB%E5%A5%B3&format=images','https://api.uomg.com/api/rand.avatar?sort=%E5%8A%A8%E6%BC%AB%E7%94%B7&format=images',
-        // 'https://zt.sanzhixiongnet.cn/api.php','https://api.vvhan.com/api/avatar/dm',
+        // 'https://zt.sanzhixiongnet.cn/api.php'
         $apiUrls = [
         $apiUrls = [
             'https://www.loliapi.com/acg/pp/',
             'https://www.loliapi.com/acg/pp/',
             'https://api.dicebear.com/9.x/thumbs/svg?seed='.$username.'&radius=50',
             'https://api.dicebear.com/9.x/thumbs/svg?seed='.$username.'&radius=50',

+ 134 - 166
app/Utils/CurrencyExchange.php

@@ -10,35 +10,46 @@ use Log;
 
 
 class CurrencyExchange
 class CurrencyExchange
 {
 {
-    private static PendingRequest $basicRequest;
-
     private static array $apis = ['fixer', 'exchangerateApi', 'wise', 'currencyData', 'exchangeRatesData', 'duckduckgo', 'wsj', 'xRates', 'valutafx', 'baidu', 'unionpay', 'jsdelivrFile', 'it120', 'k780'];
     private static array $apis = ['fixer', 'exchangerateApi', 'wise', 'currencyData', 'exchangeRatesData', 'duckduckgo', 'wsj', 'xRates', 'valutafx', 'baidu', 'unionpay', 'jsdelivrFile', 'it120', 'k780'];
 
 
+    private static ?PendingRequest $basicRequest;
+
     /**
     /**
      * @param  string  $target  target Currency
      * @param  string  $target  target Currency
      * @param  float|int  $amount  exchange amount
      * @param  float|int  $amount  exchange amount
      * @param  string|null  $base  Base Currency
      * @param  string|null  $base  Base Currency
+     * @param  string|null  $source  API source
      * @return float|null amount in target currency
      * @return float|null amount in target currency
      */
      */
-    public static function convert(string $target, float|int $amount, ?string $base = null): ?float
+    public static function convert(string $target, float|int $amount, ?string $base = null, ?string $source = null): ?float
+    {
+        $rate = self::getCurrencyRate($target, $base, $source);
+
+        return $rate === null ? null : round($amount * $rate, 2);
+    }
+
+    public static function getCurrencyRate(string $target, ?string $base = null, ?string $source = null): ?float
     {
     {
         $base = $base ?? (string) sysConfig('standard_currency');
         $base = $base ?? (string) sysConfig('standard_currency');
         $cacheKey = "Currency_{$base}_{$target}_ExRate";
         $cacheKey = "Currency_{$base}_{$target}_ExRate";
 
 
-        if (Cache::has($cacheKey)) {
-            return round($amount * Cache::get($cacheKey), 2);
+        if (! $source && Cache::has($cacheKey)) {
+            return Cache::get($cacheKey);
         }
         }
-        self::setClient();
 
 
-        foreach (self::$apis as $api) {
+        self::$basicRequest = Http::timeout(5)->retry(2)->withOptions(['http_errors' => false])->withoutVerifying()->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36');
+
+        foreach ($source ? [$source] : self::$apis as $api) {
+            if (! method_exists(self::class, $api)) {
+                continue;
+            }
+
             try {
             try {
-                if (method_exists(self::class, $api)) {
-                    $rate = self::$api($base, $target);
-                    if ($rate !== null) {
-                        Cache::put($cacheKey, $rate, Day);
+                $rate = self::$api($base, $target);
+                if ($rate !== null) {
+                    Cache::put($cacheKey, $rate, Day);
 
 
-                        return round($amount * $rate, 2);
-                    }
+                    return $rate;
                 }
                 }
             } catch (Exception $e) {
             } catch (Exception $e) {
                 Log::error("[$api] 币种汇率信息获取报错: ".$e->getMessage());
                 Log::error("[$api] 币种汇率信息获取报错: ".$e->getMessage());
@@ -48,42 +59,45 @@ class CurrencyExchange
         return null;
         return null;
     }
     }
 
 
-    private static function setClient(): void
-    {
-        self::$basicRequest = Http::timeout(15)->withOptions(['http_errors' => false])->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36');
-    }
+    private static function exchangerateApi(string $base, string $target): ?float
+    { // Reference: https://www.exchangerate-api.com/docs/php-currency-api
+        $key = config('services.currency.exchangerate-api_key');
+        $url = $key ? "https://v6.exchangerate-api.com/v6/$key/pair/$base/$target" : "https://open.er-api.com/v6/latest/$base";
 
 
-    public static function unionTest(string $target, ?string $base = null): void
-    {
-        $base = $base ?? (string) sysConfig('standard_currency');
-        self::setClient();
-        foreach (self::$apis as $api) {
-            try {
-                echo $api.': '.self::$api($base, $target).PHP_EOL;
-            } catch (Exception $e) {
-                echo $api.': error'.PHP_EOL;
-                Log::error("[$api] 币种汇率信息获取报错: ".$e->getMessage());
+        return self::callApi($url, static function ($data) use ($key, $target) {
+            if ($data['result'] === 'success') {
+                if ($key && isset($data['conversion_rate'])) {
+                    return $data['conversion_rate'];
+                }
 
 
-                continue;
+                if (isset($data['rates'][$target])) {
+                    return $data['rates'][$target];
+                }
             }
             }
-        }
+            Log::error('[CurrencyExchange]exchangerateApi exchange failed with following message: '.$data['error-type'] ?? '');
+
+            return null;
+        });
     }
     }
 
 
-    private static function exchangerateApi(string $base, string $target): ?float
-    { // Reference: https://www.exchangerate-api.com/docs/php-currency-api
-        $key = config('services.currency.exchangerate-api_key');
-        $url = $key ? "https://v6.exchangerate-api.com/v6/$key/pair/$base/$target" : "https://open.er-api.com/v6/latest/$base";
+    private static function callApi(string $url, callable $extractor, array $headers = []): ?float
+    {
+        try {
+            $request = self::$basicRequest;
+            if (! empty($headers)) {
+                $request = $request->withHeaders($headers);
+            }
 
 
-        $response = self::$basicRequest->get($url);
-        if ($response->ok()) {
-            $data = $response->json();
+            $response = $request->get($url);
+            if ($response->ok()) {
+                $data = $response->json();
 
 
-            if ($data['result'] === 'success') {
-                return $key ? $data['conversion_rate'] : $data['rates'][$target];
+                return $extractor($data);
             }
             }
-            Log::emergency('[CurrencyExchange]exchangerateApi exchange failed with following message: '.$data['error-type'] ?? '');
-        } else {
-            Log::emergency('[CurrencyExchange]exchangerateApi request failed '.var_export($response, true));
+
+            Log::warning('[CurrencyExchange] API request failed: '.$url.' Response: '.var_export($response, true));
+        } catch (Exception $e) {
+            Log::warning("[CurrencyExchange] API $url request exception: ".$e->getMessage());
         }
         }
 
 
         return null;
         return null;
@@ -91,160 +105,117 @@ class CurrencyExchange
 
 
     private static function k780(string $base, string $target): ?float
     private static function k780(string $base, string $target): ?float
     { // Reference: https://www.nowapi.com/api/finance.rate
     { // Reference: https://www.nowapi.com/api/finance.rate
-        $response = self::$basicRequest->get("https://sapi.k780.com/?app=finance.rate&scur=$base&tcur=$target&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json");
-        if ($response->ok()) {
-            $data = $response->json();
-
+        return self::callApi("https://sapi.k780.com/?app=finance.rate&scur=$base&tcur=$target&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json", static function ($data) {
             if ($data['success'] === '1') {
             if ($data['success'] === '1') {
-                return $data['result']['rate'];
+                return $data['result']['rate'] ?? null;
             }
             }
             Log::emergency('[CurrencyExchange]Nowapi exchange failed with following message: '.$data['msg']);
             Log::emergency('[CurrencyExchange]Nowapi exchange failed with following message: '.$data['msg']);
-        } else {
-            Log::emergency('[CurrencyExchange]Nowapi request failed'.var_export($response, true));
-        }
 
 
-        return null;
+            return null;
+        });
     }
     }
 
 
     private static function it120(string $base, string $target): ?float
     private static function it120(string $base, string $target): ?float
     { // Reference: https://www.it120.cc/help/fnun8g.html
     { // Reference: https://www.it120.cc/help/fnun8g.html
         $key = config('services.currency.it120_key');
         $key = config('services.currency.it120_key');
-        if ($key) {
-            $response = self::$basicRequest->get("https://api.it120.cc/$key/forex/rate?fromCode=$target&toCode=$base");
-            if ($response->ok()) {
-                $data = $response->json();
+        if (! $key) {
+            return null;
+        }
 
 
-                if ($data['code'] === 0) {
-                    return $data['data']['rate'];
-                }
-                Log::emergency('[CurrencyExchange]it120 exchange failed with following message: '.$data['msg']);
-            } else {
-                Log::emergency('[CurrencyExchange]it120 request failed'.var_export($response, true));
+        return self::callApi("https://api.it120.cc/$key/forex/rate?fromCode=$target&toCode=$base", static function ($data) {
+            if ($data['code'] === 0) {
+                return $data['data']['rate'] ?? null;
             }
             }
-        }
+            Log::emergency('[CurrencyExchange]it120 exchange failed with following message: '.$data['msg']);
 
 
-        return null;
+            return null;
+        });
     }
     }
 
 
     private static function fixer(string $base, string $target): ?float
     private static function fixer(string $base, string $target): ?float
     { // Reference: https://apilayer.com/marketplace/fixer-api RATE LIMIT: 100 Requests / Monthly!!!!
     { // Reference: https://apilayer.com/marketplace/fixer-api RATE LIMIT: 100 Requests / Monthly!!!!
         $key = config('services.currency.apiLayer_key');
         $key = config('services.currency.apiLayer_key');
-        if ($key) {
-            $response = self::$basicRequest->withHeader('apikey', $key)->get("https://api.apilayer.com/fixer/latest?symbols=$target&base=$base");
-            if ($response->ok()) {
-                $data = $response->json();
-
-                if ($data['success']) {
-                    return $data['rates'][$target];
-                }
+        if (! $key) {
+            return null;
+        }
 
 
-                Log::emergency('[CurrencyExchange]Fixer exchange failed with following message: '.$data['error']['type'] ?? '');
-            } else {
-                Log::emergency('[CurrencyExchange]Fixer request failed'.var_export($response, true));
+        return self::callApi("https://api.apilayer.com/fixer/latest?symbols=$target&base=$base", static function ($data) use ($target) {
+            if ($data['success']) {
+                return $data['rates'][$target] ?? null;
             }
             }
-        }
+            Log::emergency('[CurrencyExchange]Fixer exchange failed with following message: '.$data['error']['type'] ?? '');
 
 
-        return null;
+            return null;
+        }, ['apikey' => $key]);
     }
     }
 
 
     private static function currencyData(string $base, string $target): ?float
     private static function currencyData(string $base, string $target): ?float
     { // Reference: https://apilayer.com/marketplace/currency_data-api RATE LIMIT: 100 Requests / Monthly
     { // Reference: https://apilayer.com/marketplace/currency_data-api RATE LIMIT: 100 Requests / Monthly
         $key = config('services.currency.apiLayer_key');
         $key = config('services.currency.apiLayer_key');
-        if ($key) {
-            $response = self::$basicRequest->withHeader('apikey', $key)->get("https://api.apilayer.com/currency_data/live?source=$base&currencies=$target");
-            if ($response->ok()) {
-                $data = $response->json();
-
-                if ($data['success']) {
-                    return $data['quotes'][$base.$target];
-                }
+        if (! $key) {
+            return null;
+        }
 
 
-                Log::emergency('[CurrencyExchange]Currency Data exchange failed with following message: '.$data['error']['info'] ?? '');
-            } else {
-                Log::emergency('[CurrencyExchange]Currency Data request failed'.var_export($response, true));
+        return self::callApi("https://api.apilayer.com/currency_data/live?source=$base&currencies=$target", static function ($data) use ($base, $target) {
+            if ($data['success']) {
+                return $data['quotes'][$base.$target] ?? null;
             }
             }
-        }
+            Log::emergency('[CurrencyExchange]Currency Data exchange failed with following message: '.$data['error']['info'] ?? '');
 
 
-        return null;
+            return null;
+        }, ['apikey' => $key]);
     }
     }
 
 
     private static function exchangeRatesData(string $base, string $target): ?float
     private static function exchangeRatesData(string $base, string $target): ?float
     { // Reference: https://apilayer.com/marketplace/exchangerates_data-api RATE LIMIT: 250 Requests / Monthly
     { // Reference: https://apilayer.com/marketplace/exchangerates_data-api RATE LIMIT: 250 Requests / Monthly
         $key = config('services.currency.apiLayer_key');
         $key = config('services.currency.apiLayer_key');
-        if ($key) {
-            $response = self::$basicRequest->withHeader('apikey', $key)->get("https://api.apilayer.com/exchangerates_data/latest?symbols=$target&base=$base");
-            if ($response->ok()) {
-                $data = $response->json();
+        if (! $key) {
+            return null;
+        }
 
 
-                if ($data['success']) {
-                    return $data['rates'][$target];
-                }
-                Log::emergency('[CurrencyExchange]Exchange Rates Data exchange failed with following message: '.$data['error']['message'] ?? '');
-            } else {
-                Log::emergency('[CurrencyExchange]Exchange Rates Data request failed'.var_export($response, true));
+        return self::callApi("https://api.apilayer.com/exchangerates_data/latest?symbols=$target&base=$base", static function ($data) use ($target) {
+            if ($data['success']) {
+                return $data['rates'][$target];
             }
             }
-        }
+            Log::emergency('[CurrencyExchange]Exchange Rates Data exchange failed with following message: '.$data['error']['message'] ?? '');
 
 
-        return null;
+            return null;
+        }, ['apikey' => $key]);
     }
     }
 
 
     private static function jsdelivrFile(string $base, string $target): ?float
     private static function jsdelivrFile(string $base, string $target): ?float
     { // Reference: https://github.com/fawazahmed0/currency-api
     { // Reference: https://github.com/fawazahmed0/currency-api
-        $response = self::$basicRequest->get('https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/'.strtolower($base).'.min.json');
-        if ($response->ok()) {
-            $data = $response->json();
-
-            return $data[strtolower($base)][strtolower($target)];
-        }
-
-        return null;
+        return self::callApi('https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/'.strtolower($base).'.min.json', static function ($data) use ($base, $target) {
+            return $data[strtolower($base)][strtolower($target)] ?? null;
+        });
     }
     }
 
 
     private static function duckduckgo(string $base, string $target): ?float
     private static function duckduckgo(string $base, string $target): ?float
     { // Reference: https://duckduckgo.com  http://www.xe.com/
     { // Reference: https://duckduckgo.com  http://www.xe.com/
-        $response = self::$basicRequest->get("https://duckduckgo.com/js/spice/currency_convert/1/$base/$target");
-        if ($response->ok()) {
-            $data = $response->json();
-
-            return $data['to'][0]['mid'];
-        }
-
-        return null;
+        return self::callApi("https://duckduckgo.com/js/spice/currency_convert/1/$base/$target", static function ($data) {
+            return $data['to'][0]['mid'] ?? null;
+        });
     }
     }
 
 
     private static function wise(string $base, string $target): ?float
     private static function wise(string $base, string $target): ?float
     { // Reference: https://wise.com/zh-cn/currency-converter/
     { // Reference: https://wise.com/zh-cn/currency-converter/
-        $response = self::$basicRequest->withHeader('Authorization', 'Basic OGNhN2FlMjUtOTNjNS00MmFlLThhYjQtMzlkZTFlOTQzZDEwOjliN2UzNmZkLWRjYjgtNDEwZS1hYzc3LTQ5NGRmYmEyZGJjZA==')->get("https://api.wise.com/v1/rates?source=$base&target=$target");
-        if ($response->ok()) {
-            $data = $response->json();
-
-            return $data[0]['rate'];
-        }
-
-        return null;
-    }
-
-    private static function wsj(string $base, string $target): ?float
-    { // Reference: https://www.wsj.com/market-data/quotes/fx/USDCNY
-        $response = self::$basicRequest->get("https://www.wsj.com/market-data/quotes/ajax/fx/9/$base$target?source=$base&target=$base$target&value=1");
-        if ($response->ok()) {
-            $data = $response->body();
-            preg_match('/<span[^>]*>([\d\.]+)<\/span>/', $data, $matches);
-
-            return $matches[1];
-        }
-
-        return null;
+        return self::callApi("https://api.wise.com/v1/rates?source=$base&target=$target", static function ($data) {
+            return $data[0]['rate'] ?? null;
+        }, ['Authorization' => 'Basic OGNhN2FlMjUtOTNjNS00MmFlLThhYjQtMzlkZTFlOTQzZDEwOjliN2UzNmZkLWRjYjgtNDEwZS1hYzc3LTQ5NGRmYmEyZGJjZA==']);
     }
     }
 
 
     private static function xRates(string $base, string $target): ?float
     private static function xRates(string $base, string $target): ?float
     { // Reference: https://www.x-rates.com/
     { // Reference: https://www.x-rates.com/
-        $response = self::$basicRequest->get("https://www.x-rates.com/calculator/?from=$base&to=$target&amount=1");
-        if ($response->ok()) {
-            $data = $response->body();
-            preg_match('/<span class="ccOutputRslt">([\d.]+)/', $data, $matches);
+        try {
+            $response = self::$basicRequest->get("https://www.x-rates.com/calculator/?from=$base&to=$target&amount=1");
 
 
-            return $matches[1];
+            if ($response->ok()) {
+                preg_match('/<span class="ccOutputRslt">([\d.]+)</', $response->body(), $matches);
+
+                return $matches[1] ?? null;
+            }
+        } catch (Exception $e) {
+            Log::warning('[CurrencyExchange] xRates request failed: '.$e->getMessage());
         }
         }
 
 
         return null;
         return null;
@@ -252,29 +223,30 @@ class CurrencyExchange
 
 
     private static function valutafx(string $base, string $target): ?float
     private static function valutafx(string $base, string $target): ?float
     { // Reference: https://www.valutafx.com/convert/
     { // Reference: https://www.valutafx.com/convert/
-        $response = self::$basicRequest->get("https://www.valutafx.com/api/v2/rates/lookup?isoTo=$target&isoFrom=$base&amount=1");
-        if ($response->ok()) {
-            $data = $response->json();
-
-            if (! $data['ErrorMessage']) {
-                return $data['Rate'];
+        return self::callApi("https://www.valutafx.com/api/v2/rates/lookup?isoTo=$target&isoFrom=$base&amount=1", static function ($data) {
+            if ($data['ErrorMessage'] === null) {
+                return $data['Rate'] ?? null;
             }
             }
-        }
 
 
-        return null;
+            return null;
+        });
     }
     }
 
 
     private static function unionpay(string $base, string $target): ?float
     private static function unionpay(string $base, string $target): ?float
     { // Reference: https://www.unionpayintl.com/cn/rate/
     { // Reference: https://www.unionpayintl.com/cn/rate/
-        $response = self::$basicRequest->get('https://www.unionpayintl.com/upload/jfimg/'.date('Ymd').'.json');
-        if (! $response->ok()) {
-            $response = self::$basicRequest->get('https://www.unionpayintl.com/upload/jfimg/'.date('Ymd', strtotime('-1 day')).'.json');
-        }
+        try {
+            $response = self::$basicRequest->get('https://www.unionpayintl.com/upload/jfimg/'.date('Ymd').'.json');
+            if (! $response->ok()) {
+                $response = self::$basicRequest->get('https://www.unionpayintl.com/upload/jfimg/'.date('Ymd', strtotime('-1 day')).'.json');
+            }
 
 
-        if ($response->ok()) {
-            $data = $response->json();
+            if ($response->ok()) {
+                $data = $response->json();
 
 
-            return collect($data['exchangeRateJson'])->where('baseCur', $target)->where('transCur', $base)->pluck('rateData')->first();
+                return collect($data['exchangeRateJson'])->where('baseCur', $target)->where('transCur', $base)->pluck('rateData')->first();
+            }
+        } catch (Exception $e) {
+            Log::warning('[CurrencyExchange] Unionpay request failed: '.$e->getMessage());
         }
         }
 
 
         return null;
         return null;
@@ -282,16 +254,12 @@ class CurrencyExchange
 
 
     private static function baidu(string $base, string $target): ?float
     private static function baidu(string $base, string $target): ?float
     {
     {
-        $response = self::$basicRequest->get("https://finance.pae.baidu.com/vapi/async/v1?from_money=$base&to_money=$target&srcid=5293");
-
-        if ($response->ok()) {
-            $data = $response->json();
-
-            if ($data['ResultCode'] !== -1) {
-                return $data['Result'][0]['DisplayData']['resultData']['tplData']['money2_num'];
+        return self::callApi("https://finance.pae.baidu.com/vapi/async/v1?from_money=$base&to_money=$target&srcid=5293", static function ($data) {
+            if ($data['ResultCode'] === 0) {
+                return $data['Result'][0]['DisplayData']['resultData']['tplData']['money2_num'] ?? null;
             }
             }
-        }
 
 
-        return null;
+            return null;
+        });
     }
     }
 }
 }

Dosya farkı çok büyük olduğundan ihmal edildi
+ 408 - 343
app/Utils/IP.php


+ 186 - 222
app/Utils/NetworkDetection.php

@@ -9,302 +9,266 @@ use Log;
 
 
 class NetworkDetection
 class NetworkDetection
 {
 {
-    private static PendingRequest $basicRequest;
+    private const PROTOCOLS = ['icmp', 'tcp'];
 
 
-    public function networkStatus(string $ip, int $port): ?array
-    {
-        $status = $this->networkCheck($ip, $port);
-        if ($status) {
-            $ret = [];
-            foreach (['icmp', 'tcp'] as $protocol) {
-                if ($status['in'][$protocol] && $status['out'][$protocol]) {
-                    $check = 1; // 正常
-                }
+    private const STATUS_OK = 1; // 正常
 
 
-                if ($status['in'][$protocol] && ! $status['out'][$protocol]) {
-                    $check = 2; // 国外访问异常
-                }
+    private const STATUS_ABROAD = 2; // 国外访问异常
 
 
-                if (! $status['in'][$protocol] && $status['out'][$protocol]) {
-                    $check = 3; // 被墙
-                }
+    private const STATUS_BLOCKED = 3; // 被墙
 
 
-                $ret[$protocol] = $check ?? 4; // 服务器宕机
-            }
+    private const STATUS_DOWN = 4; // 宕机
+
+    private static array $apis = ['selfHost', 'vps234', 'idcoffer', 'ip112', 'upx8', 'rss', 'vps1352'];
 
 
-            return $ret;
+    private static ?PendingRequest $basicRequest;
+
+    public static function networkStatus(string $ip, int $port = 22, ?string $source = null): ?array
+    {
+        $status = self::checkNetworkStatus($ip, $port, $source);
+
+        if (! $status) {
+            return null;
         }
         }
 
 
-        return null;
+        $result = [];
+        foreach (self::PROTOCOLS as $protocol) {
+            [$in, $out] = [$status['in'][$protocol], $status['out'][$protocol]];
+
+            $result[$protocol] = match (true) {
+                $in && $out => self::STATUS_OK,
+                $in && ! $out => self::STATUS_ABROAD,
+                ! $in && $out => self::STATUS_BLOCKED,
+                default => self::STATUS_DOWN,
+            };
+        }
+
+        return $result;
     }
     }
 
 
-    private function networkCheck(string $ip, int $port): ?array
+    private static function checkNetworkStatus(string $ip, int $port, ?string $source = null): ?array
     { // 通过众多API进行节点阻断检测.
     { // 通过众多API进行节点阻断检测.
-        $checkers = ['flyzy2005', 'toolsdaquan', 'vps234', 'idcoffer', 'ip112', 'upx8', 'rss', 'gd', 'vps1352', 'akile'];
-        self::$basicRequest = Http::timeout(15)->retry(2)->withOptions(['http_errors' => false])->withoutVerifying()->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36');
+        self::$basicRequest = Http::timeout(5)->retry(2)->withOptions(['http_errors' => false])->withoutVerifying()->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36');
+
+        foreach ($source ? [$source] : self::$apis as $api) {
+            if (! method_exists(self::class, $api)) {
+                continue;
+            }
 
 
-        foreach ($checkers as $checker) {
             try {
             try {
-                if (method_exists(self::class, $checker)) {
-                    $result = $this->$checker($ip, $port);
-                    if ($result !== null) {
-                        return $result;
-                    }
+                $result = self::$api($ip, $port);
+                if ($result !== null) {
+                    return $result;
                 }
                 }
             } catch (Exception $e) {
             } catch (Exception $e) {
-                Log::error("[$checker] 网络阻断测试报错: ".$e->getMessage());
-
-                continue;
+                Log::error("[$api] 网络阻断测试报错: ".$e->getMessage());
             }
             }
         }
         }
 
 
         return null;
         return null;
     }
     }
 
 
-    private function toolsdaquan(string $ip, int $port): ?array
-    { // 开发依据: https://www.toolsdaquan.com/ipcheck/
-        $response_inner = self::$basicRequest->withHeader('Referer', 'https://www.toolsdaquan.com/ipcheck/')->get("https://www.toolsdaquan.com/toolapi/public/ipchecking/$ip/$port");
-        $response_outer = self::$basicRequest->withHeader('Referer', 'https://www.toolsdaquan.com/ipcheck/')->get("https://www.toolsdaquan.com/toolapi/public/ipchecking2/$ip/$port");
-
-        if ($response_inner->ok() && $response_outer->ok()) {
-            return $this->common_detection($response_inner->json(), $response_outer->json(), $ip);
-        }
-
-        return null;
-    }
-
-    private function common_detection(array $inner, array $outer, string $ip): ?array
-    {
-        if (empty($inner) || empty($outer)) {
-            Log::warning("【阻断检测】检测{$ip}时,接口返回异常");
+    private static function toolsdaquan(string $ip, int $port): ?array
+    { // deprecated, 开发依据: https://www.toolsdaquan.com/ipcheck/
+        $data = self::fetchJson(static fn () => self::$basicRequest->withHeader('Referer', 'https://www.toolsdaquan.com/ipcheck/')
+            ->get("https://www.toolsdaquan.com/toolapi/public/ipchecking?ip=$ip&port=$port"), $ip, 'toolsdaquan');
 
 
+        if (! $data || $data['success'] !== 1) {
             return null;
             return null;
         }
         }
 
 
+        $data = $data['data'];
+
         return [
         return [
-            'in' => [
-                'icmp' => $inner['icmp'] === 'success',
-                'tcp' => $inner['tcp'] === 'success',
-            ],
-            'out' => [
-                'icmp' => $outer['outside_icmp'] === 'success',
-                'tcp' => $outer['outside_tcp'] === 'success',
-            ],
+            'in' => ['icmp' => $data['icmp'] === 'success', 'tcp' => $data['tcp'] === 'success'],
+            'out' => ['icmp' => $data['outside_icmp'] === 'success', 'tcp' => $data['outside_tcp'] === 'success'],
         ];
         ];
     }
     }
 
 
-    private function akile(string $ip, int $port): ?array
-    { // 开发依据: https://tools.akile.io/
-        $response = self::$basicRequest->get("https://tools.akile.io/gping?address=$ip&port=$port");
-
-        if ($response->ok()) {
-            $data = $response->json();
-            if ($data) {
-                return [
-                    'in' => [
-                        'icmp' => $data['cn_icmp'],
-                        'tcp' => $data['cn_tcp'],
-                    ],
-                    'out' => [
-                        'icmp' => $data['global_icmp'],
-                        'tcp' => $data['global_tcp'],
-                    ],
-                ];
+    private static function fetchJson(callable $callback, string $ip, string $apiName): ?array
+    {
+        try {
+            $res = $callback();
+            if ($res->ok()) {
+                return $res->json();
             }
             }
+        } catch (Exception $e) {
+            Log::warning("【阻断检测】检测{$ip}时, [$apiName]接口异常: ".$e->getMessage());
         }
         }
-        Log::warning("【阻断检测】检测{$ip}时,[akile]接口返回异常");
 
 
         return null;
         return null;
     }
     }
 
 
-    private function gd(string $ip, int $port): ?array
-    { // 开发依据: https://ping.gd/
-        $response = self::$basicRequest->get("https://ping.gd/api/ip-test/$ip:$port");
-
-        if ($response->ok()) {
-            $data = $response->json();
-            if ($data) {
-                return [
-                    'in' => [
-                        'icmp' => $data[0]['result']['ping_alive'],
-                        'tcp' => $data[0]['result']['telnet_alive'],
-                    ],
-                    'out' => [
-                        'icmp' => $data[1]['result']['ping_alive'],
-                        'tcp' => $data[1]['result']['telnet_alive'],
-                    ],
-                ];
-            }
+    private static function gd(string $ip, int $port): ?array
+    { // deprecated, 开发依据: https://ping.gd/
+        $data = self::fetchJson(static fn () => self::$basicRequest->get("https://ping.gd/api/ip-test/$ip:$port"), $ip, 'gd');
+        if (! $data) {
+            return null;
         }
         }
-        Log::warning("【阻断检测】检测{$ip}时,[ping.gd]接口返回异常");
 
 
-        return null;
+        return [
+            'in' => [
+                'icmp' => $data[0]['result']['ping_alive'],
+                'tcp' => $data[0]['result']['telnet_alive'],
+            ],
+            'out' => [
+                'icmp' => $data[1]['result']['ping_alive'],
+                'tcp' => $data[1]['result']['telnet_alive'],
+            ],
+        ];
     }
     }
 
 
-    private function vps234(string $ip): ?array
+    private static function vps234(string $ip, int $port): ?array
     { // 开发依据: https://www.vps234.com/ipchecker/
     { // 开发依据: https://www.vps234.com/ipchecker/
-        $response = self::$basicRequest->asForm()->post('https://www.vps234.com/ipcheck/getdata/', ['ip' => $ip]);
-        if ($response->ok()) {
-            $data = $response->json();
-            if ($data) {
-                if ($data['error'] === false && $data['data']['success']) {
-                    $result = $data['data']['data'];
-
-                    return [
-                        'in' => [
-                            'icmp' => $result['innerICMP'],
-                            'tcp' => $result['innerTCP'],
-                        ],
-                        'out' => [
-                            'icmp' => $result['outICMP'],
-                            'tcp' => $result['outTCP'],
-                        ],
-                    ];
-                }
-                Log::warning('【阻断检测】检测'.$ip.'时,[vps234]接口返回'.var_export($data, true));
-            }
-            Log::warning("【阻断检测】检测{$ip}时, [vps234]接口返回异常");
+        $data = self::fetchJson(static fn () => self::$basicRequest
+            ->withHeaders(['Origin' => 'https://www.vps234.com', 'Referer' => 'https://www.vps234.com/ipchecker/'])
+            ->asForm()->post('https://www.vps234.com/ipcheck/getdata/', ['ip' => $ip]), $ip, 'vps234');
+        if (! $data || $data['error'] || ! ($data['data']['success'] ?? false)) {
+            return null;
         }
         }
+        $r = $data['data']['data'];
 
 
-        return null;
+        return [
+            'in' => ['icmp' => $r['innerICMP'], 'tcp' => $r['innerTCP']],
+            'out' => ['icmp' => $r['outICMP'], 'tcp' => $r['outTCP']],
+        ];
     }
     }
 
 
-    private function flyzy2005(string $ip, int $port): ?array
-    { // 开发依据: https://www.flyzy2005.cn/tech/ip-check/
-        $response_inner = self::$basicRequest->get("https://mini.flyzy2005.cn/ip_check.php?ip=$ip&port=$port");
-        $response_outer = self::$basicRequest->get("https://mini.flyzy2005.cn/ip_check_outside.php?ip=$ip&port=$port");
+    private static function flyzy2005(string $ip, int $port): ?array
+    { // deprecated, 开发依据: https://www.flyzy2005.cn/tech/ip-check/
+        $inner = self::fetchJson(static fn () => self::$basicRequest->get("https://mini.flyzy2005.cn/ip_check.php?ip=$ip&port=$port"), $ip, 'flyzy2005');
+        $outer = self::fetchJson(static fn () => self::$basicRequest->get("https://mini.flyzy2005.cn/ip_check_outside.php?ip=$ip&port=$port"), $ip, 'flyzy2005');
 
 
-        if ($response_inner->ok() && $response_outer->ok()) {
-            return $this->common_detection($response_inner->json(), $response_outer->json(), $ip);
+        if (! $inner || ! $outer) {
+            return null;
         }
         }
 
 
-        return null;
+        return [
+            'in' => ['icmp' => $inner['icmp'] === 'success', 'tcp' => $inner['tcp'] === 'success'],
+            'out' => ['icmp' => $outer['outside_icmp'] === 'success', 'tcp' => $outer['outside_tcp'] === 'success'],
+        ];
     }
     }
 
 
-    private function idcoffer(string $ip, int $port): ?array
+    private static function idcoffer(string $ip, int $port): ?array
     { // 开发依据: https://www.idcoffer.com/ipcheck
     { // 开发依据: https://www.idcoffer.com/ipcheck
-        $response_inner = self::$basicRequest->get("https://api.24kplus.com/ipcheck?host=$ip&port=$port");
-        $response_outer = self::$basicRequest->get("https://api.idcoffer.com/ipcheck?host=$ip&port=$port");
-
-        if ($response_inner->ok() && $response_outer->ok()) {
-            $inner = $response_inner->json();
-            $outer = $response_outer->json();
-            if ($inner && $outer) {
-                if ($inner['code'] && $outer['code']) {
-                    return [
-                        'in' => [
-                            'icmp' => $inner['data']['ping'],
-                            'tcp' => $inner['data']['tcp'],
-                        ],
-                        'out' => [
-                            'icmp' => $outer['data']['ping'],
-                            'tcp' => $outer['data']['tcp'],
-                        ],
-                    ];
-                }
-                Log::warning('【阻断检测】检测'.$ip.$port.'时,[idcoffer]接口返回'.var_export($inner, true).PHP_EOL.var_export($outer, true));
-            }
-            Log::warning("【阻断检测】检测{$ip}时,[idcoffer]接口返回异常");
+        $inner = self::fetchJson(static fn () => self::$basicRequest->get("https://api.24kplus.com/ipcheck?host=$ip&port=$port"), $ip, 'idcoffer');
+        $outer = self::fetchJson(static fn () => self::$basicRequest->get("https://api.idcoffer.com/ipcheck?host=$ip&port=$port"), $ip, 'idcoffer');
+
+        if (! $inner || ! $outer || ! $inner['code'] || ! $outer['code']) {
+            return null;
         }
         }
 
 
-        return null;
+        return [
+            'in' => ['icmp' => $inner['data']['ping'], 'tcp' => $inner['data']['tcp']],
+            'out' => ['icmp' => $outer['data']['ping'], 'tcp' => $outer['data']['tcp']],
+        ];
     }
     }
 
 
-    private function ip112(string $ip, int $port = 443): ?array
+    private static function ip112(string $ip, int $port): ?array
     { // 开发依据: https://ip112.cn/
     { // 开发依据: https://ip112.cn/
-        $response_inner = self::$basicRequest->asForm()->post('https://api.ycwxgzs.com/ipcheck/index.php', ['ip' => $ip, 'port' => $port]);
-        $response_outer = self::$basicRequest->asForm()->post('https://api.52bwg.com/ipcheck/ipcheck.php', ['ip' => $ip, 'port' => $port]);
-
-        if ($response_inner->ok() && $response_outer->ok()) {
-            $inner = $response_inner->json();
-            $outer = $response_outer->json();
-            if ($inner && $outer) {
-                return [
-                    'in' => [
-                        'icmp' => str_contains($inner['icmp'], 'green'),
-                        'tcp' => str_contains($inner['tcp'], 'green'),
-                    ],
-                    'out' => [
-                        'icmp' => str_contains($outer['icmp'], 'green'),
-                        'tcp' => str_contains($outer['tcp'], 'green'),
-                    ],
-                ];
-            }
+        $inner = self::fetchJson(static fn () => self::$basicRequest->asForm()->post('https://api.ycwxgzs.com/ipcheck/index.php', ['ip' => $ip, 'port' => $port]), $ip, 'ip112');
+        $outer = self::fetchJson(static fn () => self::$basicRequest->asForm()->post('https://api.52bwg.com/ipcheck/ipcheck.php', ['ip' => $ip, 'port' => $port]), $ip, 'ip112');
+
+        if (! $inner || ! $outer) {
+            return null;
         }
         }
-        Log::warning("【阻断检测】检测{$ip}时,[ip112]接口返回异常");
 
 
-        return null;
+        return [
+            'in' => ['icmp' => str_contains($inner['icmp'], 'green'), 'tcp' => str_contains($inner['tcp'], 'green')],
+            'out' => ['icmp' => str_contains($outer['icmp'], 'green'), 'tcp' => str_contains($outer['tcp'], 'green')],
+        ];
     }
     }
 
 
-    private function upx8(string $ip, int $port = 443): ?array
+    private static function upx8(string $ip, int $port): ?array
     { // 开发依据: https://blog.upx8.com/ipcha.html
     { // 开发依据: https://blog.upx8.com/ipcha.html
-        $response_inner = self::$basicRequest->asForm()->post('https://ip.upx8.com/check.php', ['ip' => $ip, 'port' => $port]);
-        $response_outer = self::$basicRequest->asForm()->post('https://ip.7761.cf/check.php', ['ip' => $ip, 'port' => $port]);
-
-        if ($response_inner->ok() && $response_outer->ok()) {
-            $inner = $response_inner->json();
-            $outer = $response_outer->json();
-            if ($inner && $outer) {
-                return [
-                    'in' => [
-                        'icmp' => $inner['icmp'] === '正常',
-                        'tcp' => $inner['tcp'] === '正常',
-                    ],
-                    'out' => [
-                        'icmp' => $outer['icmp'] === '正常',
-                        'tcp' => $outer['tcp'] === '正常',
-                    ],
-                ];
-            }
+        $inner = self::fetchJson(static fn () => self::$basicRequest->asForm()->post('https://api.sm171.com/check-cn.php', ['ip' => $ip, 'port' => $port]), $ip, 'upx8');
+        $outer = self::fetchJson(static fn () => self::$basicRequest->asForm()->post('https://ip.upx8.com/api/check-us.php', ['ip' => $ip, 'port' => $port]), $ip, 'upx8');
+
+        if (! $inner || ! $outer) {
+            return null;
         }
         }
-        Log::warning("【阻断检测】检测{$ip}时,[upx8]接口返回异常");
 
 
-        return null;
+        return [
+            'in' => ['icmp' => $inner['icmp'] === '正常', 'tcp' => $inner['tcp'] === '正常'],
+            'out' => ['icmp' => $outer['icmp'] === '正常', 'tcp' => $outer['tcp'] === '正常'],
+        ];
     }
     }
 
 
-    private function vps1352(string $ip, int $port): ?array
+    private static function vps1352(string $ip, int $port): ?array
     { // 开发依据: https://www.51vps.info/ipcheck.html https://www.vps1352.com/ipcheck.html 有缺陷api,查不了海外做判断 备用
     { // 开发依据: https://www.51vps.info/ipcheck.html https://www.vps1352.com/ipcheck.html 有缺陷api,查不了海外做判断 备用
-        $response = self::$basicRequest->asForm()->withHeader('Referer', 'https://www.51vps.info')->post('https://www.vps1352.com/check.php', ['ip' => $ip, 'port' => $port]);
-
-        if ($response->ok()) {
-            $data = $response->json();
-            if ($data) {
-                return [
-                    'in' => [
-                        'icmp' => $data['icmp'] === '开放',
-                        'tcp' => $data['tcp'] === '开放',
-                    ],
-                    'out' => [
-                        'icmp' => true,
-                        'tcp' => true,
-                    ],
-                ];
+        try {
+            $response = self::$basicRequest->asForm()->withHeader('Referer', 'https://www.51vps.info')
+                ->post('https://www.vps1352.com/check.php', ['ip' => $ip, 'port' => $port]);
+
+            if ($response->ok()) {
+                // 检查响应内容是否包含PHP错误信息
+                $body = $response->body();
+                if (str_contains($body, 'Warning') || str_contains($body, 'Fatal error')) {
+                    // 如果响应中包含PHP错误信息,则提取JSON部分
+                    $jsonStart = strpos($body, '{');
+                    $jsonEnd = strrpos($body, '}');
+                    if ($jsonStart !== false && $jsonEnd !== false) {
+                        $jsonStr = substr($body, $jsonStart, $jsonEnd - $jsonStart + 1);
+                        $data = json_decode($jsonStr, true, 512, JSON_THROW_ON_ERROR);
+                    } else {
+                        Log::warning("【阻断检测】检测{$ip}时,[vps1352]接口返回内容包含错误信息: ".$body);
+
+                        return null;
+                    }
+                } else {
+                    $data = $response->json();
+                }
+
+                if (! empty($data)) {
+                    return [
+                        'in' => ['icmp' => $data['icmp'] === '开放', 'tcp' => $data['tcp'] === '开放'],
+                        'out' => ['icmp' => true, 'tcp' => true],
+                    ];
+                }
             }
             }
+        } catch (Exception $e) {
+            Log::warning("【阻断检测】检测{$ip}时,[vps1352]接口异常: ".$e->getMessage());
         }
         }
-        Log::warning("【阻断检测】检测{$ip}时, [vps1352.com]接口返回异常");
 
 
         return null;
         return null;
     }
     }
 
 
-    private function rss(string $ip, int $port): ?array
-    { // https://ip.rss.ink/index/check
-        $client = self::$basicRequest->withHeader('X-Token', '5AXfB1xVfuq5hxv4');
-
-        foreach (['in', 'out'] as $type) {
-            foreach (['icmp', 'tcp'] as $protocol) {
-                $response = $client->get('https://ip.rss.ink/netcheck/'.($type === 'in' ? 'cn' : 'global')."/api/check/$protocol?ip=$ip".($protocol === 'tcp' ? "&port=$port" : ''));
+    private static function rss(string $ip, int $port): ?array
+    { // 开发依据: https://ip.rss.ink/index/check
+        $inner = self::fetchJson(static fn () => self::$basicRequest->get("https://ip.rss.ink/api/scan?ip=$ip&port=$port"), $ip, 'rss');
+        $outer = self::fetchJson(static fn () => self::$basicRequest->get("https://tcp.mk/api/scan?ip=$ip&port=$port"), $ip, 'rss');
 
 
-                if ($response->ok()) {
-                    $data = $response->json();
-                    $ret[$type][$protocol] = $data['msg'] === 'success';
-                }
-            }
+        if (! $inner || ! $outer || $inner['code'] !== 200 || $outer['code'] !== 200) {
+            return null;
         }
         }
 
 
-        if (! isset($ret)) {
-            Log::warning("【阻断检测】检测{$ip}时, [rss]接口返回异常");
+        return [
+            'in' => ['icmp' => $inner['msg'] === 'Ok', 'tcp' => $inner['msg'] === 'Ok'],
+            'out' => ['icmp' => $outer['msg'] === 'Ok', 'tcp' => $outer['msg'] === 'Ok'],
+        ];
+    }
+
+    private static function selfHost(string $ip, int $port): ?array
+    { // 开发依据: https://github.com/ProxyPanel/PingAgent
+        // 从环境变量获取探针服务器配置
+        $domestic = config('services.probe.domestic');
+        $foreign = config('services.probe.foreign');
+        if (empty($domestic)) {
+            return null;
         }
         }
 
 
-        return $ret ?? null;
+        $probe = static fn ($entry) => (static function ($entry) use ($ip, $port) {
+            [$url,$token] = array_pad(explode('|', $entry), 2, null);
+            $req = self::$basicRequest;
+            if ($token) {
+                $req = $req->withToken($token);
+            }
+            $res = $req->asJson()->post("$url/probe", ['target' => $ip, 'port' => $port]);
+            $d = $res->ok() && ! empty($res->json()[0]) ? $res->json()[0] : [];
+
+            return ['icmp' => ! empty($d['icmp']), 'tcp' => ! empty($d['tcp'])];
+        })($entry);
+
+        return ['in' => $probe($domestic), 'out' => empty($foreign) ? ['icmp' => true, 'tcp' => true] : $probe($foreign)];
     }
     }
 }
 }

+ 2 - 2
composer.json

@@ -49,8 +49,8 @@
     "barryvdh/laravel-ide-helper": "^3.1",
     "barryvdh/laravel-ide-helper": "^3.1",
     "fakerphp/faker": "^1.9",
     "fakerphp/faker": "^1.9",
     "laravel-lang/common": "^6.0",
     "laravel-lang/common": "^6.0",
-    "laravel-lang/lang":"^14.0",
-    "laravel/pint": "^1.10",
+    "laravel-lang/lang": "^14.0",
+    "laravel/pint": "^1.25",
     "laravel/telescope": "^5.2",
     "laravel/telescope": "^5.2",
     "mockery/mockery": "^1.4.4",
     "mockery/mockery": "^1.4.4",
     "nunomaduro/collision": "^7.0",
     "nunomaduro/collision": "^7.0",

+ 1 - 1
config/ide-helper.php

@@ -35,7 +35,7 @@ return [
     |
     |
     */
     */
 
 
-    'include_fluent' => false,
+    'include_fluent' => true,
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------

+ 6 - 0
config/services.php

@@ -90,11 +90,17 @@ return [
         'baidu_ak' => env('BAIDU_APP_AK'),
         'baidu_ak' => env('BAIDU_APP_AK'),
         'ipinfo_token' => env('IPINFO_ACCESS_TOKEN'),
         'ipinfo_token' => env('IPINFO_ACCESS_TOKEN'),
         'IP2Location_key' => env('IP2LOCATION_API_KEY'),
         'IP2Location_key' => env('IP2LOCATION_API_KEY'),
+        'ipApiCom_acess_key' => env('IP_API_ACCESS_KEY'),
         'ip-api_key' => env('IP_API_KEY'),
         'ip-api_key' => env('IP_API_KEY'),
         'ipdata_key' => env('IPDATA_API_KEY'),
         'ipdata_key' => env('IPDATA_API_KEY'),
         'bjjii_key' => env('BJJII_KEY'),
         'bjjii_key' => env('BJJII_KEY'),
     ],
     ],
 
 
+    'probe' => [
+        'domestic' => env('PROBE_SERVERS_DOMESTIC'),
+        'foreign' => env('PROBE_SERVERS_FOREIGN'),
+    ],
+
     'currency' => [
     'currency' => [
         'exchangerate-api_key' => env('EXCAHNGERATE_API_KEY'),
         'exchangerate-api_key' => env('EXCAHNGERATE_API_KEY'),
         'apiLayer_key' => env('API_LAYER_API_KEY'),
         'apiLayer_key' => env('API_LAYER_API_KEY'),

+ 1 - 1
config/session.php

@@ -168,7 +168,7 @@ return [
     |
     |
     */
     */
 
 
-    'secure' => env('SESSION_SECURE_COOKIE', true),
+    'secure' => env('FORCE_HTTPS', true),
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------

+ 59 - 16
config/telescope.php

@@ -5,6 +5,19 @@ use Laravel\Telescope\Watchers;
 
 
 return [
 return [
 
 
+    /*
+    |--------------------------------------------------------------------------
+    | Telescope Master Switch
+    |--------------------------------------------------------------------------
+    |
+    | This option may be used to disable all Telescope watchers regardless
+    | of their individual configuration, which simply provides a single
+    | and convenient way to enable or disable Telescope data storage.
+    |
+    */
+
+    'enabled' => env('TELESCOPE_ENABLED', true),
+
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
     | Telescope Domain
     | Telescope Domain
@@ -53,16 +66,20 @@ return [
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
-    | Telescope Master Switch
+    | Telescope Queue
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
     |
     |
-    | This option may be used to disable all Telescope watchers regardless
-    | of their individual configuration, which simply provides a single
-    | and convenient way to enable or disable Telescope data storage.
+    | This configuration options determines the queue connection and queue
+    | which will be used to process ProcessPendingUpdate jobs. This can
+    | be changed if you would prefer to use a non-default connection.
     |
     |
     */
     */
 
 
-    'enabled' => env('TELESCOPE_ENABLED', true),
+    'queue' => [
+        'connection' => env('TELESCOPE_QUEUE_CONNECTION', null),
+        'queue' => env('TELESCOPE_QUEUE', null),
+        'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
+    ],
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
@@ -82,7 +99,7 @@ return [
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
-    | Ignored Paths & Commands
+    | Allowed / Ignored Paths & Commands
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
     |
     |
     | The following array lists the URI paths and Artisan commands that will
     | The following array lists the URI paths and Artisan commands that will
@@ -91,8 +108,14 @@ return [
     |
     |
     */
     */
 
 
+    'only_paths' => [
+        // 'api/*'
+    ],
+
     'ignore_paths' => [
     'ignore_paths' => [
+        'livewire*',
         'nova-api*',
         'nova-api*',
+        'pulse*',
     ],
     ],
 
 
     'ignore_commands' => [
     'ignore_commands' => [
@@ -111,14 +134,24 @@ return [
     */
     */
 
 
     'watchers' => [
     'watchers' => [
-        Watchers\CacheWatcher::class => env('TELESCOPE_CACHE_WATCHER', true),
+        Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
+
+        Watchers\CacheWatcher::class => [
+            'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
+            'hidden' => [],
+        ],
+
+        Watchers\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
 
 
         Watchers\CommandWatcher::class => [
         Watchers\CommandWatcher::class => [
             'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
             'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
             'ignore' => [],
             'ignore' => [],
         ],
         ],
 
 
-        Watchers\DumpWatcher::class => env('TELESCOPE_DUMP_WATCHER', true),
+        Watchers\DumpWatcher::class => [
+            'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
+            'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
+        ],
 
 
         Watchers\EventWatcher::class => [
         Watchers\EventWatcher::class => [
             'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
             'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
@@ -126,13 +159,27 @@ return [
         ],
         ],
 
 
         Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
         Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
+
+        Watchers\GateWatcher::class => [
+            'enabled' => env('TELESCOPE_GATE_WATCHER', true),
+            'ignore_abilities' => [],
+            'ignore_packages' => true,
+            'ignore_paths' => [],
+        ],
+
         Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
         Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
-        Watchers\LogWatcher::class => env('TELESCOPE_LOG_WATCHER', true),
+
+        Watchers\LogWatcher::class => [
+            'enabled' => env('TELESCOPE_LOG_WATCHER', true),
+            'level' => 'error',
+        ],
+
         Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
         Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
 
 
         Watchers\ModelWatcher::class => [
         Watchers\ModelWatcher::class => [
             'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
             'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
             'events' => ['eloquent.*'],
             'events' => ['eloquent.*'],
+            'hydrations' => true,
         ],
         ],
 
 
         Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
         Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
@@ -140,6 +187,7 @@ return [
         Watchers\QueryWatcher::class => [
         Watchers\QueryWatcher::class => [
             'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
             'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
             'ignore_packages' => true,
             'ignore_packages' => true,
+            'ignore_paths' => [],
             'slow' => 100,
             'slow' => 100,
         ],
         ],
 
 
@@ -148,16 +196,11 @@ return [
         Watchers\RequestWatcher::class => [
         Watchers\RequestWatcher::class => [
             'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
             'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
             'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
             'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
-        ],
-
-        Watchers\GateWatcher::class => [
-            'enabled' => env('TELESCOPE_GATE_WATCHER', true),
-            'ignore_abilities' => [],
-            'ignore_packages' => true,
+            'ignore_http_methods' => [],
+            'ignore_status_codes' => [],
         ],
         ],
 
 
         Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
         Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
-
         Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
         Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
     ],
     ],
 ];
 ];

+ 2 - 2
scripts/download_dbs.sh

@@ -15,8 +15,8 @@ declare -Ag docs
 
 
 # 设置数据库信息
 # 设置数据库信息
 docs[geo_lite_name]="GeoLite2-City.mmdb"
 docs[geo_lite_name]="GeoLite2-City.mmdb"
-docs[geo_lite_version]=$(get_tag "PrxyHunter/GeoLite2")
-docs[geo_lite_url]="https://github.com/PrxyHunter/GeoLite2/releases/download/${docs[geo_lite_version]}/GeoLite2-City.mmdb"
+docs[geo_lite_version]=$(get_tag "P3TERX/GeoLite.mmdb")
+docs[geo_lite_url]="https://github.com/P3TERX/GeoLite.mmdb/releases/download/${docs[geo_lite_version]}/GeoLite2-City.mmdb"
 
 
 docs[ip2location_name]="IP2LOCATION-LITE-DB11.IPV6.BIN"
 docs[ip2location_name]="IP2LOCATION-LITE-DB11.IPV6.BIN"
 docs[ip2location_version]=$(get_tag "renfei/ip2location")
 docs[ip2location_version]=$(get_tag "renfei/ip2location")

+ 0 - 19
tests/Feature/ExampleTest.php

@@ -1,19 +0,0 @@
-<?php
-
-namespace Tests\Feature;
-
-// use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class ExampleTest extends TestCase
-{
-    /**
-     * A basic test example.
-     */
-    public function test_the_application_returns_a_successful_response(): void
-    {
-        $response = $this->get('/');
-
-        $response->assertStatus(200);
-    }
-}

+ 0 - 16
tests/Unit/ExampleTest.php

@@ -1,16 +0,0 @@
-<?php
-
-namespace Tests\Unit;
-
-use PHPUnit\Framework\TestCase;
-
-class ExampleTest extends TestCase
-{
-    /**
-     * A basic test example.
-     */
-    public function test_that_true_is_true(): void
-    {
-        $this->assertTrue(true);
-    }
-}

+ 230 - 0
tests/Unit/Utils/CurrencyExchangeTest.php

@@ -0,0 +1,230 @@
+<?php
+
+namespace Tests\Unit\Utils;
+
+use App\Utils\CurrencyExchange;
+use Exception;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
+use ReflectionClass;
+use Tests\TestCase;
+
+class CurrencyExchangeTest extends TestCase
+{
+    public static function providerExchangeServices(): array
+    {
+        return [
+            'exchangerateApi_free' => [[
+                'name' => 'exchangerateApi',
+                'endpoint' => 'https://open.er-api.com/v6/latest/*',
+                'response' => '{"result":"success","provider":"https://www.exchangerate-api.com","documentation":"https://www.exchangerate-api.com/docs/free","terms_of_use":"https://www.exchangerate-api.com/terms","time_last_update_unix":1757462551,"time_last_update_utc":"Wed, 10 Sep 2025 00:02:31 +0000","time_next_update_unix":1757550141,"time_next_update_utc":"Thu, 11 Sep 2025 00:22:21 +0000","time_eol_unix":0,"base_code":"USD","rates":{"USD":1,"BYN":3.233971,"BZD":2,"CAD":1.383268,"CDF":2878.431948,"CHF":0.79644,"CLP":971.373897,"CNY":7.124079}}',
+                'expected' => 7.12,
+            ]],
+            'exchangerateApi_paid' => [[
+                'name' => 'exchangerateApi',
+                'config' => ['services.currency.exchangerate-api_key' => 'fake_key'],
+                'endpoint' => 'https://v6.exchangerate-api.com/v6/*',
+                'response' => '{"result":"success","documentation":"https://www.exchangerate-api.com/docs","terms_of_use":"https://www.exchangerate-api.com/terms","time_last_update_unix":1757462401,"time_last_update_utc":"Wed, 10 Sep 2025 00:00:01 +0000","time_next_update_unix":1757548801,"time_next_update_utc":"Thu, 11 Sep 2025 00:00:01 +0000","base_code":"USD","target_code":"CNY","conversion_rate":7.1241}',
+                'expected' => 7.12,
+            ]],
+            'k780' => [[
+                'name' => 'k780',
+                'endpoint' => 'https://sapi.k780.com/?app=finance.rate&scur=USD&tcur=CNY&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json',
+                'response' => '{"success":"1","result":{"status":"ALREADY","scur":"USD","tcur":"CNY","ratenm":"美元/人民币","rate":"7.1206","update":"2025-09-10 22:47:01"}}',
+                'expected' => 7.12,
+            ]],
+            'it120' => [[
+                'name' => 'it120',
+                'config' => ['services.currency.it120_key' => 'fake_key'],
+                'endpoint' => 'https://api.it120.cc/*',
+                'response' => '{"code":0,"data":{"rate":6.5749,"toCode":657.49,"fromCode":100},"msg":"success"}',
+                'expected' => 6.57,
+            ]],
+            'fixer' => [[
+                'name' => 'fixer',
+                'config' => ['services.currency.apiLayer_key' => 'fake_key'],
+                'endpoint' => 'https://api.apilayer.com/fixer/latest*',
+                'response' => '{"success":true,"timestamp":1757522110,"base":"USD","date":"2025-09-10","rates":{"CNY":7.121499}}',
+                'expected' => 7.12,
+            ]],
+            'currencyData' => [[
+                'name' => 'currencyData',
+                'config' => ['services.currency.apiLayer_key' => 'fake_key'],
+                'endpoint' => 'https://api.apilayer.com/currency_data/live*',
+                'response' => '{"success":true,"timestamp":1757522348,"source":"USD","quotes":{"USDCNY":7.121498}}',
+                'expected' => 7.12,
+            ]],
+            'exchangeRatesData' => [[
+                'name' => 'exchangeRatesData',
+                'config' => ['services.currency.apiLayer_key' => 'fake_key'],
+                'endpoint' => 'https://api.apilayer.com/exchangerates_data/latest*',
+                'response' => '{"success":true,"timestamp":1757564885,"base":"USD","date":"2025-09-11","rates":{"CNY":7.12125}}',
+                'expected' => 7.12,
+            ]],
+            'jsdelivrFile' => [[
+                'name' => 'jsdelivrFile',
+                'endpoint' => 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/*',
+                'response' => '{"date":"2025-09-10","usd":{"cfx":5.80551999,"chf":0.79810103,"chz":23.57267514,"clp":967.40244215,"cnh":7.1267651,"cny":7.12926824,"comp":0.022938216,"cop":3922.09820874,"crc":505.17600184,"cro":3.94111354,"crv":1.28387416,"cspr":103.9529098,"cuc":1,"cup":24.06001705,"cve":94.26691294,"cvx":0.28371305,"cyp":0.5003353,"czk":20.81016181,"dai":0.9997095,"dash":0.040430194}}',
+                'expected' => 7.12,
+            ]],
+            'duckduckgo' => [[
+                'name' => 'duckduckgo',
+                'endpoint' => 'https://duckduckgo.com/js/spice/currency_convert/1/*',
+                'response' => '{"terms":"https://www.xe.com/legal/","privacy":"http://www.xe.com/privacy.php","from":"USD","amount":1.0,"timestamp":"2025-09-11T04:44:00Z","to":[{"quotecurrency":"CNY","mid":7.1209786645}]}',
+                'expected' => 7.12,
+            ]],
+            'wise' => [[
+                'name' => 'wise',
+                'endpoint' => 'https://api.wise.com/v1/rates*',
+                'response' => '[{"rate":7.12105,"source":"USD","target":"CNY","time":"2025-09-11T04:47:28+0000"}]',
+                'expected' => 7.12,
+            ]],
+            'xRates' => [[
+                'name' => 'xRates',
+                'endpoint' => 'https://www.x-rates.com/calculator/*',
+                'response' => '<div class="ccOutputBx"><span class="ccOutputTxt">1.00 USD =</span><span class="ccOutputRslt">7.121<span class="ccOutputTrail">208</span><span class="ccOutputCode"> CNY</span></span></div><span class="calOutputTS">Sep 11, 2025 05:21 UTC</span>',
+                'expected' => 7.12,
+            ]],
+            'valutafx' => [[
+                'name' => 'valutafx',
+                'endpoint' => 'https://www.valutafx.com/api/v2/rates/lookup*',
+                'response' => '{"Amount":1,"Rate":7.1218,"UpdatedDateTimeUTC":"2025-09-11T06:15:00","FormattedResult":"= <span class=\"converter-result-to\">7.1218 CNY</span>","FormattedRates":"<span class=\"converter-rate-from\">1 USD</span> = <span class=\"converter-rate-to\">7.1218 CNY</span>","FormattedIndirectRates":"<span class=\"converter-rate-to\">1 CNY</span> = <span class=\"converter-rate-from\">0.14041 USD</span>","FormattedDateTime":"Last update <span class=\"converter-last-updated-value\">2025-09-11 6:15 AM UTC</span>","ErrorMessage":null}',
+                'expected' => 7.12,
+            ]],
+            'unionpay' => [[
+                'name' => 'unionpay',
+                'endpoint' => 'https://www.unionpayintl.com/upload/jfimg/*',
+                'response' => '{"exchangeRateJson":[{"transCur":"AED","baseCur":"AUD","rateData":0.41397204},{"transCur":"TJS","baseCur":"CNY","rateData":0.75934168},{"transCur":"TMT","baseCur":"CNY","rateData":2.0464021},{"transCur":"TND","baseCur":"CNY","rateData":2.45719918},{"transCur":"TOP","baseCur":"CNY","rateData":2.99489085},{"transCur":"UGX","baseCur":"CNY","rateData":0.00204028},{"transCur":"USD","baseCur":"CNY","rateData":7.1399},{"transCur":"UYU","baseCur":"CNY","rateData":0.17892278},{"transCur":"UZS","baseCur":"CNY","rateData":0.00058941}],"curDate":"2025-09-11"}',
+                'expected' => 7.13,
+            ]],
+            'baidu' => [[
+                'name' => 'baidu',
+                'endpoint' => 'https://finance.pae.baidu.com/vapi/async/v1*',
+                'response' => '{"DispExt":null,"QueryDispInfo":null,"ResultCode":0,"ResultNum":2,"QueryID":"162386106721317024","Result":[{"ClickNeed":"1","Degree":"0","DisplayData":{"StdCls":"2","StdStg":"5293","StdStl":"2","resultData":{"extData":{"OriginQuery":"","resourceid":"5293","tplt":"exrate"},"tplData":{"StdCls":"2","StdStg":"5293","StdStl":"2","card_order":"1","content1":"1美元=7.12270000人民币","content2":"1人民币=0.14039700美元","money1":"美元","money1_num":"1","money1_rev":"1","money2":"人民币","money2_num":"7.12270000","money2_rev":"0.140397","pk":[],"sigma_use":"1","strong_use":"1","templateName":"exrate","template_type":"1","text":"更新时间:2025-09-11 15:23 数据仅供参考"}},"strategy":{"ctplOrPhp":"1","hilightWord":"","precharge":"0","tempName":"unitstatic"}},"RecoverCacheTime":"0","Sort":"1","SrcID":"5293","SubResNum":"0","SubResult":[],"SuppInfo":"汇率换算","Title":"汇率换算","Weight":"3"}]}',
+                'expected' => 7.13,
+            ]],
+        ];
+    }
+
+    /**
+     * @dataProvider providerExchangeServices
+     */
+    public function test_currency_exchange_services(array $case): void
+    {
+        // 设置配置
+        if (isset($case['config'])) {
+            foreach ($case['config'] as $key => $value) {
+                config([$key => $value]);
+            }
+        }
+
+        // 模拟HTTP响应
+        if (isset($case['response'])) {
+            $fakeResponses[$case['endpoint']] = Http::response($case['response']);
+        }
+        $fakeResponses['*'] = Http::response([], 500);
+
+        Http::fake($fakeResponses);
+
+        $result = CurrencyExchange::getCurrencyRate($case['target'] ?? 'CNY', $case['base'] ?? 'USD', $case['name']);
+
+        $this->assertEqualsWithDelta(
+            $case['expected'],
+            $result,
+            0.01,
+            "Currency exchange service {$case['name']} failed"
+        );
+    }
+
+    public function test_currency_exchange_with_cache(): void
+    {
+        Cache::put('Currency_USD_CNY_ExRate', 6.5, 3600);
+
+        $result = CurrencyExchange::getCurrencyRate('CNY', 'USD');
+
+        $this->assertEquals(6.5, $result);
+        // 验证没有进行HTTP请求
+        Http::assertNothingSent();
+    }
+
+    public function test_currency_exchange_fallback(): void
+    {
+        // 模拟所有API都失败
+        Http::fake([
+            '*' => Http::response('Not Found', 404),
+        ]);
+
+        $result = CurrencyExchange::getCurrencyRate('CNY', 'USD');
+
+        $this->assertNull($result);
+    }
+
+    public function test_convert_method(): void
+    {
+        Cache::put('Currency_USD_CNY_ExRate', 6.5, 3600);
+
+        $result = CurrencyExchange::convert('CNY', 100, 'USD');
+
+        $this->assertEquals(650.0, $result);
+    }
+
+    public function test_real_api_requests(): void
+    {
+        $target = 'CNY';
+        $base = 'USD';
+
+        $services = ['exchangerateApi', 'k780', 'it120', 'fixer', 'currencyData', 'exchangeRatesData',
+            'jsdelivrFile', 'duckduckgo', 'wise', 'xRates', 'valutafx', 'unionpay', 'baidu'];
+
+        $successfulRequests = 0;
+        $failedRequests = 0;
+        $results = [];
+
+        foreach ($services as $service) {
+            try {
+                $result = CurrencyExchange::getCurrencyRate($target, $base, $service);
+
+                if (is_numeric($result)) {
+                    $successfulRequests++;
+                } else {
+                    $failedRequests++;
+                }
+
+                $results[$service] = $result;
+            } catch (Exception $e) {
+                $failedRequests++;
+                echo "Service {$service} failed with exception: ".$e->getMessage()."\n";
+            }
+        }
+
+        // 输出测试结果摘要
+        echo "实际汇率API请求测试结果:\n";
+        echo "成功请求: {$successfulRequests}\n";
+        echo "失败请求: {$failedRequests}\n";
+        echo '总请求数: '.($successfulRequests + $failedRequests)."\n\n";
+
+        // 输出详细结果
+        foreach ($results as $service => $result) {
+            echo "[{$service}] - ".json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n";
+        }
+
+        $this->assertGreaterThan(0, $successfulRequests, '至少应有一个汇率API请求成功');
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // 清理 HTTP 假造与缓存
+        Http::fake([]);
+        Cache::flush();
+
+        // 重置 basicRequest
+        $ref = new ReflectionClass(CurrencyExchange::class);
+        if ($ref->hasProperty('basicRequest')) {
+            $prop = $ref->getProperty('basicRequest');
+            $prop->setAccessible(true);
+            $prop->setValue(null, null);
+        }
+    }
+}

+ 606 - 0
tests/Unit/Utils/IPTest.php

@@ -0,0 +1,606 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Utils;
+
+use App\Utils\IP;
+use Exception;
+use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
+use ReflectionClass;
+use Tests\TestCase;
+
+class IPTest extends TestCase
+{
+    public static function providerApiCases(): array
+    {
+        return [
+            'ipApi' => [[
+                'name' => 'ipApi',
+                'endpoint' => '*ip-api.com/json/*',
+                'response' => '{"city":"沈阳市","country":"中国","district":"","isp":"China Unicom CHINA169 Network","lat":41.8357,"lon":123.429,"query":"8.8.8.8","regionName":"辽宁","status":"success"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'city' => '沈阳市',
+                    'isp' => 'China Unicom CHINA169 Network',
+                    'latitude' => 41.8357,
+                    'longitude' => 123.429,
+                ],
+            ]],
+            'baidu' => [[
+                'name' => 'Baidu',
+                'config' => ['services.ip.baidu_ak' => 'fake_baidu_ak'],
+                'endpoint' => 'https://api.map.baidu.com/location/ip*',
+                'response' => '{"status":0,"address":"CN|辽宁省|沈阳市|None|None|100|65|0","content":{"address":"辽宁省沈阳市","address_detail":{"adcode":"210100","city":"沈阳市","city_code":58,"district":"","province":"辽宁省","street":"","street_number":""},"point":{"x":"123.46466579069178","y":"41.67756788393409"}}}',
+                'expected' => [
+                    'country' => 'CN',
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                    'latitude' => '41.67756788393409',
+                    'longitude' => '123.46466579069178',
+                ],
+            ]],
+            'baiduBce' => [[
+                'name' => 'baiduBce',
+                'endpoint' => 'https://qifu-api.baidubce.com/ip/geo/v1/district*',
+                'response' => '{"code":"Success","data":{"continent":"亚洲","country":"中国","zipcode":"110000","owner":"中国联通","isp":"中国联通","adcode":"210100","prov":"辽宁省","city":"沈阳市","district":""},"ip":"8.8.8.8"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                    'isp' => '中国联通',
+                ],
+            ]],
+            'ipGeoLocation' => [[
+                'name' => 'ipGeoLocation',
+                'endpoint' => 'https://api.ipgeolocation.io/ipgeo?ip=*',
+                'response' => '{"ip":"8.8.8.8","country_name":"中国","country_name_official":"","state_prov":"辽宁","district":"沈阳","city":"沈阳","isp":"China Unicom CHINA169 Network","latitude":"41.79680","longitude":"123.42910"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'city' => '沈阳',
+                    'isp' => 'China Unicom CHINA169 Network',
+                    'area' => '沈阳',
+                    'latitude' => '41.79680',
+                    'longitude' => '123.42910',
+                ],
+            ]],
+            'taobao' => [[
+                'name' => 'TaoBao',
+                'endpoint' => 'https://ip.taobao.com/outGetIpInfo?ip=*',
+                'response' => '{"data":{"area":"","country":"中国","isp_id":"100026","queryIp":"103.250.104.0","city":"沈阳","ip":"8.8.8.8","isp":"联通","county":"","region_id":"210000","area_id":"","county_id":null,"region":"辽宁","country_id":"CN","city_id":"210100"},"msg":"query success","code":0}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'city' => '沈阳',
+                    'isp' => '联通',
+                ],
+            ]],
+            'speedtest' => [[
+                'name' => 'speedtest',
+                'endpoint' => 'https://api-v3.speedtest.cn/ip*',
+                'response' => '{"code":0,"data":"1z41bLnGrmhViAP9vBtxaTvcnepxEF7nyynwu4VDL1s6YCnSK48PoPFgNf6lDQQ3GV9cmRtDJTrLLrU16eItDnmB+8+3stMtsFBhaLaRH1ece5b+D4lR73Cy1FvaxFXmIfGuPxIOjV/g4Mh4F7GuvEy1gm5/tj9gm4egANOyl3vkzMFvp9tB1ET9PUhaP29DTMQOwxCV4CendJn2LdwF6tP6elucLUoy3xweFC4h2w20oha/GcOiQAxKLB+h6aslydvXqDAzvMmeXRV6e0CQ6A==","\'msg\'":"ok"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'city' => '沈阳',
+                    'isp' => '中国联通',
+                    'area' => '沈河区',
+                    'latitude' => '41.796767',
+                    'longitude' => '123.429096',
+                ],
+            ]],
+            'juHe' => [[
+                'name' => 'juHe',
+                'endpoint' => 'https://apis.juhe.cn/ip/Example/query.php*',
+                'response' => '{"resultcode":"200","reason":"success","result":{"Country":"中国","Province":"辽宁","City":"沈阳","District":"","Isp":"联通"},"error_code":0}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'city' => '沈阳',
+                    'isp' => '联通',
+                ],
+            ]],
+            'ip2Region' => [[
+                'name' => 'ip2Region',
+                'ip' => '103.250.104.0',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                    'isp' => '联通',
+                ],
+            ]],
+            'IPDB' => [[
+                'name' => 'IPDB',
+                'ip' => '103.250.104.0',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁',
+                    'isp' => '联通',
+                ],
+            ]],
+            'IPSB' => [[
+                'name' => 'IPSB',
+                'endpoint' => 'https://api.ip.sb/geoip/*',
+                'response' => '{"country":"China","organization":"China Unicom","country_code":"CN","ip":"8.8.8.8","isp":"China Unicom","asn_organization":"CHINA UNICOM China169 Backbone","asn":4837,"offset":28800,"latitude":34.7732,"timezone":"Asia\/Shanghai","continent_code":"AS","longitude":113.722}',
+                'expected' => [
+                    'country' => 'China',
+                    'isp' => 'China Unicom',
+                    'latitude' => 34.7732,
+                    'longitude' => 113.722,
+                ],
+            ]],
+            'ipinfo' => [[
+                'name' => 'ipinfo',
+                'endpoint' => 'https://ipinfo.io*',
+                'response' => '{"input":"103.250.104.0","data":{"ip":"8.8.8.8","city":"Shenyang","region":"Liaoning","country":"CN","loc":"41.7922,123.4328","org":"AS4837 CHINA UNICOM China169 Backbone","postal":"110000","timezone":"Asia/Shanghai"}}',
+                'expected' => [
+                    'country' => 'CN',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'AS4837 CHINA UNICOM China169 Backbone',
+                    'latitude' => '41.7922',
+                    'longitude' => '123.4328',
+                ],
+            ]],
+            'ip234' => [[
+                'name' => 'ip234',
+                'endpoint' => 'https://ip234.in/search_ip*',
+                'response' => '{"code":0,"data":{"asn":4837,"city":"Shenyang","continent":"Asia","continent_code":"AS","country":"china","country_code":"CN","ip":"8.8.8.8","latitude":41.8357,"longitude":123.429,"metro_code":null,"network":"103.250.104.0/22","organization":"CHINA UNICOM China169 Backbone","postal":"210000","region":"Liaoning","timezone":"Asia/Shanghai"},"msg":""}',
+                'expected' => [
+                    'country' => 'china',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'CHINA UNICOM China169 Backbone',
+                    'latitude' => '41.8357',
+                    'longitude' => '123.429',
+                ],
+            ]],
+            'dbIP' => [[
+                'name' => 'dbIP',
+                'endpoint' => 'https://api.db-ip.com/v2/free/*',
+                'response' => '{"ipAddress":"8.8.8.8","continentCode":"AS","continentName":"Asia","countryCode":"CN","countryName":"China","stateProv":"Liaoning","city":"Shenyang"}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                ],
+            ]],
+            'IP2Online' => [[
+                'name' => 'IP2Online',
+                'config' => ['services.ip.IP2Location_key' => 'fake_ip2location_key'],
+                'endpoint' => 'https://api.ip2location.io/*',
+                'response' => '{"ip":"8.8.8.8","country_code":"CN","country_name":"China","region_name":"Liaoning","city_name":"Shenyang","latitude":41.79222,"longitude":123.43288,"zip_code":"210000","time_zone":"+08:00","asn":"4837","as":"China Unicom China169 Backbone","is_proxy":false}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'China Unicom China169 Backbone',
+                    'latitude' => '41.79222',
+                    'longitude' => '123.43288',
+                ],
+            ]],
+            'ipdata' => [[
+                'name' => 'ipdata',
+                'config' => ['services.ip.ipdata_key' => 'fake_ipdata_key'],
+                'endpoint' => 'https://api.ipdata.co/*',
+                'response' => '{"ip":"8.8.8.8","city":null,"region":null,"country_name":"China","latitude":34.77320098876953,"longitude":113.72200012207031,"asn":null}',
+                'expected' => [
+                    'country' => 'China',
+                    'latitude' => 34.77320098876953,
+                    'longitude' => 113.72200012207031,
+                ],
+            ]],
+            'ipApiCo' => [[
+                'name' => 'ipApiCo',
+                'endpoint' => 'https://ipapi.co/*/json/*',
+                'response' => '{"ip":"8.8.8.8","network":"103.250.104.0/22","version":"IPv4","city":"Shenyang","region":"Liaoning","region_code":"LN","country":"CN","country_name":"China","country_code":"CN","country_code_iso3":"CHN","country_capital":"Beijing","country_tld":".cn","continent_code":"AS","in_eu":false,"postal":null,"latitude":41.79222,"longitude":123.43278,"timezone":"Asia/Shanghai","utc_offset":"+0800","country_calling_code":"+86","currency":"CNY","currency_name":"Yuan Renminbi","languages":"zh-CN,yue,wuu,dta,ug,za","country_area":9596960,"country_population":1411778724,"asn":"AS4837","org":"CHINA UNICOM China169 Backbone"}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'CHINA UNICOM China169 Backbone',
+                    'latitude' => 41.79222,
+                    'longitude' => 123.43278,
+                ],
+            ]],
+            'ip2Location' => [[
+                'name' => 'ip2Location',
+                'ip' => '103.250.104.0',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'latitude' => 41.792221,
+                    'longitude' => 123.432877,
+                ],
+            ]],
+            'GeoIP2' => [[
+                'name' => 'GeoIP2',
+                'ip' => '103.250.104.0',
+                'expected' => [
+                    'country' => 'China',
+                    'latitude' => 34.7732,
+                    'longitude' => 113.722,
+                ],
+            ]],
+            'ipApiCom' => [[
+                'name' => 'ipApiCom',
+                'config' => ['services.ip.ipApiCom_acess_key' => 'fake_acess_key'],
+                'endpoint' => 'https://api.ipapi.com/api/*',
+                'response' => '{"ip": "8.8.8.8", "type": "ipv4", "continent_code": "AS", "continent_name": "Asia", "country_code": "CN", "country_name": "China", "region_code": "LN", "region_name": "Liaoning", "city": "Shenyang", "zip": "110000", "latitude": 41.801021575927734, "longitude": 123.40206909179688, "msa": null, "dma": null, "radius": "0", "ip_routing_type": "fixed", "connection_type": "tx", "location": {"geoname_id": 2034937, "capital": "Beijing", "languages": [{"code": "zh", "name": "Chinese", "native": "\u4e2d\u6587"}], "country_flag": "https://assets.ipstack.com/flags/cn.svg", "country_flag_emoji": "\ud83c\udde8\ud83c\uddf3", "country_flag_emoji_unicode": "U+1F1E8 U+1F1F3", "calling_code": "86", "is_eu": false}}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'latitude' => 41.801021575927734,
+                    'longitude' => 123.40206909179688,
+                    'address' => 'China Liaoning Shenyang',
+                ],
+            ]],
+            'vore' => [[
+                'name' => 'vore',
+                'endpoint' => 'https://api.vore.top/api/IPdata*',
+                'response' => '{"code":200,"msg":"SUCCESS","ipinfo":{"type":"ipv4","text":"103.250.104.0","cnip":true},"ipdata":{"info1":"辽宁省","info2":"沈阳市","info3":"","isp":"联通"},"adcode":{"o":"辽宁省沈阳市 - 联通","p":"辽宁","c":"沈阳","n":"辽宁-沈阳","r":"辽宁-沈阳","a":"210100","i":true},"tips":"接口由VORE-API(https://api.vore.top/)免费提供","time":1757149038}',
+                'expected' => [
+                    'country' => '辽宁省',
+                    'region' => '沈阳市',
+                    'isp' => '联通',
+                ],
+            ]],
+            'ipw_v4' => [[
+                'name' => 'ipw',
+                'endpoint' => 'https://rest.ipw.cn/api/aw/v1/ipv4*',
+                'response' => '{"code":"Success","data":{"continent":"亚洲","country":"中国","zipcode":"110000","timezone":"UTC+8","accuracy":"城市","owner":"中国联通","isp":"中国联通","source":"数据挖掘","areacode":"CN","adcode":"210100","asnumber":"4837","lat":"41.800551","lng":"123.420011","radius":"109.2745","prov":"辽宁省","city":"沈阳市","district":""},"charge":false,"msg":"查询成功","ip":"8.8.8.8","coordsys":"WGS84"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                    'isp' => '中国联通',
+                    'latitude' => '41.800551',
+                    'longitude' => '123.420011',
+                ],
+            ]],
+            'ipw_v6' => [[
+                'name' => 'ipw',
+                'ip' => '2408:8207:1850:2a60::4c8',
+                'endpoint' => 'https://rest.ipw.cn/api/aw/v1/ipv6*',
+                'response' => '{"code":"Success","data":{"continent":"亚洲","country":"日本","zipcode":"167-0033","timezone":"UTC+9","accuracy":"城市","owner":"亚马逊","isp":"亚马逊","source":"数据挖掘","areacode":"JP","adcode":"","asnumber":"16509","lat":"35.713914","lng":"139.616508","radius":"","prov":"东京都","city":"Suginami","district":"","currency_code":"JPY","currency_name":"日元"},"charge":false,"msg":"查询成功","ip":"2408:8207:1850:2a60::4c8","coordsys":"WGS84"}',
+                'expected' => [
+                    'country' => '日本',
+                    'region' => '东京都',
+                    'city' => 'Suginami',
+                    'isp' => '亚马逊',
+                    'latitude' => '35.713914',
+                    'longitude' => '139.616508',
+                    'address' => '日本 东京都 Suginami',
+                ],
+            ]],
+            'bjjii' => [[
+                'name' => 'bjjii',
+                'config' => ['services.ip.bjjii_key' => 'fake_acess_key'],
+                'endpoint' => 'https://api.bjjii.com/api/ip/query*',
+                'response' => '{"code":200,"msg":"请求成功","data":{"ip":"8.8.8.8","info":{"StartIPNum":1744463872,"StartIPText":"103.250.104.0","EndIPNum":1744464895,"EndIPText":"103.250.107.255","Country":"辽宁省沈阳市","Local":"联通","lat":41.835709999999999,"lng":123.42925,"nation":"中国","province":"辽宁省","city":"沈阳市","district":"","adcode":210000,"nation_code":156,"update":"2025-09-06 17:48:39"}},"exec_time":0.023085000000000001,"ip":"117.147.44.132"}',
+                'expected' => [
+                    'country' => '中国',
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                    'latitude' => 41.83571,
+                    'longitude' => 123.42925,
+                ],
+            ]],
+            'pconline' => [[
+                'name' => 'pconline',
+                'endpoint' => 'https://whois.pconline.com.cn/*',
+                'response' => '{"ip":"8.8.8.8","pro":"辽宁省","proCode":"210000","city":"沈阳市","cityCode":"210100","region":"","regionCode":"0","addr":"辽宁省沈阳市 联通","regionNames":"","err":""}',
+                'expected' => [
+                    'region' => '辽宁省',
+                    'city' => '沈阳市',
+                ],
+            ]],
+            'ipApiIO' => [[
+                'name' => 'ipApiIO',
+                'endpoint' => 'https://ip-api.io/api/v1/ip/*',
+                'response' => '{"ip":"8.8.8.8","suspicious_factors":{"is_proxy":false,"is_tor_node":false,"is_spam":false,"is_crawler":false,"is_datacenter":false,"is_vpn":false,"is_threat":false},"location":{"country":"China","country_code":"CN","city":null,"latitude":34.7732,"longitude":113.722,"zip":null,"timezone":"Asia/Shanghai","local_time":"2025-09-07T14:37:47+08:00","local_time_unix":1757227067,"is_daylight_savings":false}}',
+                'expected' => [
+                    'country' => 'China',
+                    'latitude' => 34.7732,
+                    'longitude' => 113.722,
+                ],
+            ]],
+            'ipApiIS' => [[
+                'name' => 'ipApiIS',
+                'endpoint' => 'https://api.ipapi.is/*',
+                'response' => '{"ip":"8.8.8.8","asn":{"asn":4837,"abuser_score":"0.001 (Low)","route":"103.250.104.0/22","descr":"CHINA169-BACKBONE CHINA UNICOM China169 Backbone, CN","country":"cn","active":true,"org":"CHINA UNICOM China169 Backbone","domain":"chinaunicom.cn","abuse":"[email protected]","type":"isp","updated":"2024-02-06","rir":"APNIC","whois":"https://api.ipapi.is/?whois=AS4837"},"location":{"is_eu_member":false,"calling_code":"86","currency_code":"CNY","continent":"AS","country":"China","country_code":"CN","state":"Liaoning","city":"Shenyang","latitude":41.79222,"longitude":123.43278,"zip":"110000","timezone":"Asia/Shanghai","local_time":"2025-09-07T14:40:03+08:00","local_time_unix":1757227203,"is_dst":false},"elapsed_ms":1.05}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'CHINA UNICOM China169 Backbone',
+                    'latitude' => 41.79222,
+                    'longitude' => 123.43278,
+                ],
+            ]],
+            'freeipapi' => [[
+                'name' => 'freeipapi',
+                'endpoint' => 'https://free.freeipapi.com/*',
+                'response' => '{"ipVersion":4,"ipAddress":"8.8.8.8","latitude":41.8357,"longitude":123.429,"countryName":"China","countryCode":"CN","capital":"Beijing","phoneCodes":[86],"timeZones":["Asia\/Shanghai","Asia\/Urumqi"],"zipCode":"210000","cityName":"Shenyang","regionName":"Liaoning","continent":"Asia","continentCode":"AS","currencies":["CNY"],"languages":["zh"],"asn":"4837","asnOrganization":"CHINA UNICOM China169 Backbone","isProxy":false}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'CHINA UNICOM China169 Backbone',
+                    'latitude' => 41.8357,
+                    'longitude' => 123.429,
+                ],
+            ]],
+            'ipwhois' => [[
+                'name' => 'ipwhois',
+                'endpoint' => 'https://ipwhois.app/json/*',
+                'response' => '{"ip":"8.8.8.8","success":true,"type":"IPv4","continent":"Asia","continent_code":"AS","country":"China","country_code":"CN","country_flag":"https://cdn.ipwhois.io/flags/cn.svg","country_capital":"Beijing","country_phone":"+86","country_neighbours":"AF,BT,HK,IN,KG,KP,KZ,LA,MM,MN,MO,NP,PK,RU,TJ,VN","region":"Liaoning","city":"Shenyang","latitude":41.805699,"longitude":123.431472,"asn":"AS4837","org":"China Unicom Liaoning Province Network","isp":"China Unicom China1 Backbone","timezone":"Asia/Shanghai","timezone_name":"CST","timezone_dstOffset":0,"timezone_gmtOffset":28800,"timezone_gmt":"+08:00","currency":"Chinese Yuan","currency_code":"CNY","currency_symbol":"¥","currency_rates":7.133,"currency_plural":"Chinese yuan"}',
+                'expected' => [
+                    'country' => 'China',
+                    'region' => 'Liaoning',
+                    'city' => 'Shenyang',
+                    'isp' => 'China Unicom China1 Backbone',
+                    'latitude' => 41.805699,
+                    'longitude' => 123.431472,
+                ],
+            ]],
+        ];
+    }
+
+    public function test_localhost_returns_false_for_loopback_ips(): void
+    {
+        $this->assertFalse(IP::getIPInfo('127.0.0.1'));
+        $this->assertFalse(IP::getIPInfo('::1'));
+    }
+
+    public function test_get_client_ip_is_string_or_null(): void
+    {
+        $ip = IP::getClientIP();
+        $this->assertTrue(is_null($ip) || is_string($ip));
+    }
+
+    /**
+     * @dataProvider providerApiCases
+     */
+    public function test_get_ip_info_from_each_provider(array $case): void
+    {
+        App::setLocale($case['locale'] ?? 'zh_CN');
+
+        if (! empty($case['config'])) { // 设置可能存在的假token参数,来激活 API 访问
+            foreach ($case['config'] as $k => $v) {
+                config([$k => $v]);
+            }
+        }
+        // 模拟HTTP响应
+        if (isset($case['response'])) {
+            $fakeResponses[$case['endpoint']] = Http::response($case['response']);
+        }
+        $fakeResponses['*'] = Http::response([], 500);
+
+        Http::fake($fakeResponses);
+
+        $result = IP::getIPInfo($case['ip'] ?? '8.8.8.8', $case['name']);
+
+        $this->assertIsArray($result, "Provider {$case['name']} should return an array");
+
+        foreach ($case['expected'] as $k => $v) {
+            $this->assertEquals($v, $result[$k] ?? null, "Provider {$case['name']} field {$k} mismatch");
+        }
+    }
+
+    public function test_get_ip_info_caches_result_and_prevents_http_calls(): void
+    {
+        $ip = '9.9.9.9';
+        $cached = [
+            'country' => 'CachedLand',
+            'region' => 'CachedRegion',
+            'city' => 'CachedCity',
+            'latitude' => 1.23,
+            'longitude' => 4.56,
+        ];
+
+        // 将结果写入缓存
+        Cache::tags('IP_INFO')->put($ip, $cached, now()->addMinutes(10));
+
+        // 伪造 HTTP,如果有请求发生将返回 500(测试应从缓存直接返回)
+        Http::fake(['*' => Http::response([], 500)]);
+
+        $result = IP::getIPInfo($ip);
+
+        $this->assertIsArray($result);
+        $this->assertEquals('CachedLand', $result['country']);
+        // 确保没有执行任何外部 HTTP 请求
+        Http::assertNothingSent();
+    }
+
+    public function test_get_ip_geo_returns_lat_lon_consistent_with_get_ip_info(): void
+    {
+        $ip = '5.6.7.8';
+
+        Http::fake([
+            'http://ip-api.com/*' => Http::response([
+                'status' => 'success',
+                'country' => 'GeoTest',
+                'lat' => 11.11,
+                'lon' => 22.22,
+            ]),
+            '*' => Http::response([], 500),
+        ]);
+
+        // getIPInfo 会缓存并返回完整数据,getIPGeo 只返回 lat/lon
+        $info = IP::getIPInfo($ip);
+        $geo = IP::getIPGeo($ip);
+
+        $this->assertIsArray($info);
+        $this->assertIsArray($geo);
+        $this->assertArrayHasKey('latitude', $geo);
+        $this->assertArrayHasKey('longitude', $geo);
+        $this->assertEquals($info['latitude'], $geo['latitude']);
+        $this->assertEquals($info['longitude'], $geo['longitude']);
+    }
+
+    public function test_local_database_providers_are_used_when_http_fails(): void
+    {
+        // 设为中文以便优先走本地库(例如 IPDB / ip2region / ipip 等)
+        App::setLocale('zh_CN');
+        $ip = '123.123.123.123';
+
+        // 强制 HTTP 全部失败,确保使用本地数据库驱动(已在测试环境通过 eval 注入本地驱动 mock)
+        Http::fake(['*' => Http::response([], 500)]);
+        Cache::tags('IP_INFO')->forget($ip);
+
+        $result = IP::getIPInfo($ip);
+
+        $this->assertIsArray($result);
+        // 这些值依赖于测试时注入的本地 DB mock(原测试中为 中国 / 北京 / 北京市)
+        $this->assertEquals('中国', $result['country'] ?? null);
+        $this->assertEquals('北京', $result['region'] ?? null);
+        $this->assertEquals('北京市', $result['city'] ?? null);
+    }
+
+    public function test_get_ip_info_returns_null_when_http_timeout(): void
+    {
+        App::setLocale('en_US');
+        $ip = '2.2.2.2';
+
+        // 模拟超时情况
+        Http::fake([
+            '*' => function () {
+                // 模拟超时,抛出异常或返回超时错误
+                throw new ConnectionException('cURL error 28: Operation timed out');
+            },
+        ]);
+
+        $result = IP::getIPInfo($ip, 'ipApi');
+        $this->assertNull($result);
+    }
+
+    public function test_get_ip_info_returns_null_when_invalid_json_response(): void
+    {
+        App::setLocale('en_US');
+        $ip = '3.3.3.3';
+
+        // 模拟返回无效JSON
+        Http::fake([
+            'http://ip-api.com/*' => Http::response('Invalid JSON response', 200),
+            '*' => Http::response([], 500),
+        ]);
+
+        $result = IP::getIPInfo($ip, 'ipApi');
+        $this->assertNull($result);
+    }
+
+    public function test_get_ip_info_with_specific_checker_returns_null_when_provider_fails(): void
+    {
+        App::setLocale('en_US');
+        $ip = '5.5.5.5';
+
+        // 模拟特定检查器失败,并阻止其他检查器被调用
+        Http::fake([
+            '*' => Http::response([], 500), // 确保其他所有请求也失败
+        ]);
+
+        // 使用指定的checker
+        $result = IP::getIPInfo($ip, 'ipApi');
+        $this->assertNull($result);
+    }
+
+    public function test_real_api_requests(): void
+    {
+        $testIp = '8.8.8.8'; // 使用一个公共的IP地址进行测试
+
+        $checkers = ['ipApi', 'Baidu', 'baiduBce', 'ipw', 'ipGeoLocation', 'TaoBao', 'speedtest', 'bjjii', 'vore', 'juHe', 'ip2Region', 'IPDB', 'IPSB', 'ipinfo', 'ip234', 'dbIP', 'IP2Online', 'ipdata', 'ipApiCo', 'ip2Location', 'GeoIP2', 'ipApiCom', 'pconline', 'ipApiIO', 'ipApiIS', 'freeipapi', 'ipwhois'];
+
+        $successfulRequests = 0;
+        $failedRequests = 0;
+        $results = [];
+
+        // 为每个检查器执行实际请求
+        foreach ($checkers as $checker) {
+            try {
+                // 清除之前的缓存
+                Cache::tags('IP_INFO')->forget($testIp);
+
+                // 执行实际的API请求
+                $result = IP::getIPInfo($testIp, $checker);
+
+                if (is_array($result) && ! empty(array_filter($result))) {
+                    $successfulRequests++;
+                } else {
+                    $failedRequests++;
+                }
+                $results[$checker] = $result;
+            } catch (Exception $e) {
+                $failedRequests++;
+                echo "Checker {$checker} failed with exception: ".$e->getMessage()."\n";
+            }
+        }
+
+        // 输出测试结果摘要
+        echo "实际API请求测试结果:\n";
+        echo "成功请求: {$successfulRequests}\n";
+        echo "失败请求: {$failedRequests}\n";
+        echo '总请求数: '.($successfulRequests + $failedRequests)."\n";
+
+        $this->assertGreaterThan(0, $successfulRequests, '至少应有一个API请求成功');
+
+        foreach ($results as $checker => $result) {
+            echo "[$checker] - ".json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n";
+        }
+    }
+
+    /**
+     * 测试单个API的实际请求
+     *
+     * @param  string  $checker  检查器名称
+     * @param  string  $ip  测试IP地址
+     */
+    public function test_single_real_api_request(string $checker = 'speedtest', string $ip = '8.8.8.8'): void
+    {
+        try {
+            // 执行实际的API请求
+            $result = IP::getIPInfo($ip, $checker);
+
+            // 输出结果
+            echo "检查器: {$checker}\n";
+            echo "测试IP: {$ip}\n";
+            echo '结果: '.json_encode($result, JSON_UNESCAPED_UNICODE)."\n";
+
+            // 验证结果
+            if (is_array($result) && ! empty(array_filter($result))) {
+                $this->assertIsArray($result);
+                echo "测试成功: {$checker} 返回了有效数据\n";
+            } else {
+                echo "警告: {$checker} 没有返回有效数据\n";
+            }
+        } catch (Exception $e) {
+            echo "检查器 {$checker} 失败,异常信息: ".$e->getMessage()."\n";
+            $this->markTestIncomplete("检查器 {$checker} 请求失败: ".$e->getMessage());
+        }
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // 清理 HTTP 假造与缓存
+        Http::fake([]);
+        Cache::tags('IP_INFO')->flush();
+
+        // 重置 basicRequest
+        $ref = new ReflectionClass(IP::class);
+        if ($ref->hasProperty('basicRequest')) {
+            $prop = $ref->getProperty('basicRequest');
+            $prop->setAccessible(true);
+            $prop->setValue(null, null);
+        }
+    }
+}

+ 296 - 0
tests/Unit/Utils/NetworkDetectionTest.php

@@ -0,0 +1,296 @@
+<?php
+
+namespace Tests\Unit\Utils;
+
+use App\Utils\NetworkDetection;
+use Exception;
+use Illuminate\Support\Facades\Http;
+use ReflectionClass;
+use Tests\TestCase;
+
+class NetworkDetectionTest extends TestCase
+{
+    public static function providerDetectionServices(): array
+    {
+        return [
+            'toolsdaquan' => [[
+                'name' => 'toolsdaquan',
+                'responses' => [
+                    'https://www.toolsdaquan.com/toolapi/public/ipchecking*' => '{"success":1,"msg":"检查成功","data":{"tcp":"success","icmp":"success","outside_tcp":"success","outside_icmp":"success"}}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'vps234' => [[
+                'name' => 'vps234',
+                'responses' => [
+                    'https://www.vps234.com/ipcheck/getdata/*' => '{"error":false,"data":{"success":true,"msg":"请求成功","data":{"innerICMP":true,"innerTCP":true,"outICMP":true,"outTCP":true}}}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'idcoffer' => [[
+                'name' => 'idcoffer',
+                'responses' => [
+                    'https://api.24kplus.com/ipcheck*' => '{"code":1,"message":"\u68C0\u67E5\u6210\u529F\uFF01","data":{"ping":true,"tcp":true,"ip":"220.181.7.203","countryClode":"CN"}}',
+                    'https://api.idcoffer.com/ipcheck*' => '{"code":1,"message":"\u68C0\u67E5\u6210\u529F\uFF01","data":{"ping":true,"tcp":true,"ip":"220.181.7.203","countryClode":"HK"}}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'ip112' => [[
+                'name' => 'ip112',
+                'responses' => [
+                    'https://api.ycwxgzs.com/ipcheck/index.php' => '{"ip":"220.181.7.203","port":"443","tcp":"<span class=\"mdui-text-color-green\">\u7aef\u53e3\u53ef\u7528<\/span>","icmp":"<span class=\"mdui-text-color-green\">IP\u53ef\u7528<\/span>"}',
+                    'https://api.52bwg.com/ipcheck/ipcheck.php' => '{"ip":"220.181.7.203","port":"443","tcp":"<span class=\"mdui-text-color-green\">\u7aef\u53e3\u53ef\u7528<\/span>","icmp":"<span class=\"mdui-text-color-green\">IP\u53ef\u7528<\/span>"}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'upx8' => [[
+                'name' => 'upx8',
+                'responses' => [
+                    'https://api.sm171.com/check-cn.php' => '{"ip":"220.181.7.203","port":"443","tcp":"\u6b63\u5e38","icmp":"\u6b63\u5e38"}',
+                    'https://ip.upx8.com/api/check-us.php' => '{"ip":"220.181.7.203","port":"443","tcp":"\u6b63\u5e38","icmp":"\u6b63\u5e38"}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'rss' => [[
+                'name' => 'rss',
+                'responses' => [
+                    'https://ip.rss.ink/api/scan*' => '{"code":200,"data":"","msg":"Ok"}',
+                    'https://tcp.mk/api/scan*' => '{"code":200,"data":"","msg":"Ok"}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'vps1352' => [[
+                'name' => 'vps1352',
+                'responses' => [
+                    'https://www.vps1352.com/check.php' => '{"ip":"220.181.7.203","port":"443","tcp":"\u5f00\u653e","icmp":"\u5f00\u653e"}',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+            'selfHost' => [[
+                'name' => 'selfHost',
+                'config' => ['services.probe.domestic' => 'test_domestic.com|test_token', 'services.probe.foreign' => 'test_foreign.com:8080'],
+                'responses' => [
+                    'test_domestic.com*' => '[{"ip":"220.181.7.203","icmp":29.627562,"tcp":29.17411}]',
+                    'test_foreign.com*' => '[{"ip":"220.181.7.203","icmp":29.627562,"tcp":29.17411}]',
+                ],
+                'expected' => [
+                    'icmp' => 1,
+                    'tcp' => 1,
+                ],
+            ]],
+        ];
+    }
+
+    /**
+     * 测试所有检测服务
+     *
+     * @dataProvider providerDetectionServices
+     */
+    public function test_network_detection_services(array $case): void
+    {
+        // 准备响应
+        if (! empty($case['config'])) { // 设置可能存在的假token参数,来激活 API 访问
+            foreach ($case['config'] as $k => $v) {
+                config([$k => $v]);
+            }
+        }
+
+        $responses = array_map(static function ($response) {
+            return Http::response($response);
+        }, $case['responses']);
+
+        // 添加通配符响应以防止意外请求
+        $responses['*'] = Http::response(['error' => 'Not found'], 404);
+
+        Http::fake($responses);
+
+        $result = NetworkDetection::networkStatus($case['ip'] ?? '8.8.8.8', $case['port'] ?? 443, $case['name']);
+
+        $this->assertIsArray($result, "Service {$case['name']} should return an array");
+
+        foreach ($case['expected'] as $protocol => $status) {
+            $this->assertEquals($status, $result[$protocol], "Service {$case['name']} protocol {$protocol} mismatch");
+        }
+    }
+
+    /**
+     * 测试被墙的情况.
+     */
+    public function test_network_status_detects_blocked_ips()
+    {
+        Http::fake([
+            'https://www.vps234.com/ipcheck/getdata/*' => Http::response([
+                'error' => false,
+                'data' => [
+                    'success' => true,
+                    'msg' => '请求成功',
+                    'data' => [
+                        'innerICMP' => false,
+                        'innerTCP' => false,
+                        'outICMP' => true,
+                        'outTCP' => true,
+                    ],
+                ],
+            ]),
+
+            '*' => Http::response(['error' => 'Not found'], 404),
+        ]);
+        $result = NetworkDetection::networkStatus('8.8.8.8', 443, 'vps234');
+
+        $this->assertIsArray($result);
+        $this->assertEquals(3, $result['icmp']); // 被墙
+        $this->assertEquals(3, $result['tcp']); // 被墙
+    }
+
+    /**
+     * 测试国外访问异常的情况.
+     */
+    public function test_network_status_detects_foreign_access_issues()
+    {
+        Http::fake([
+            'https://www.vps234.com/ipcheck/getdata/*' => Http::response([
+                'error' => false,
+                'data' => [
+                    'success' => true,
+                    'msg' => '请求成功',
+                    'data' => [
+                        'innerICMP' => true,
+                        'innerTCP' => true,
+                        'outICMP' => true,
+                        'outTCP' => false,
+                    ],
+                ],
+            ]),
+
+            '*' => Http::response(['error' => 'Not found'], 404),
+        ]);
+
+        $result = NetworkDetection::networkStatus('8.8.8.8', 443, 'vps234');
+
+        $this->assertIsArray($result);
+        $this->assertEquals(1, $result['icmp']); // 正常
+        $this->assertEquals(2, $result['tcp']); // 国外访问异常
+    }
+
+    /**
+     * 测试服务器宕机的情况.
+     */
+    public function test_network_status_detects_server_down()
+    {
+        Http::fake([
+            'https://www.vps234.com/ipcheck/getdata/*' => Http::response([
+                'error' => false,
+                'data' => [
+                    'success' => true,
+                    'data' => [
+                        'innerICMP' => false,
+                        'innerTCP' => false,
+                        'outICMP' => false,
+                        'outTCP' => false,
+                    ],
+                ],
+            ]),
+
+            '*' => Http::response(['error' => 'Not found'], 404),
+        ]);
+
+        $result = NetworkDetection::networkStatus('8.8.8.8', 443, 'vps234');
+
+        $this->assertIsArray($result);
+        $this->assertEquals(4, $result['icmp']); // 服务器宕机
+        $this->assertEquals(4, $result['tcp']); // 服务器宕机
+    }
+
+    /**
+     * 测试当所有检测服务都失败时返回 null.
+     */
+    public function test_network_status_returns_null_when_all_services_fail()
+    {
+        Http::fake([
+            '*' => Http::response(['error' => 'Service unavailable'], 500),
+        ]);
+
+        $result = NetworkDetection::networkStatus('8.8.8.8', 443);
+
+        $this->assertNull($result);
+    }
+
+    /**
+     * 测试真实可用的 IP.
+     */
+    public function test_real_ip_connectivity()
+    {
+        $successfulRequests = 0;
+        $failedRequests = 0;
+        $results = [];
+
+        $ip = '220.181.7.203';
+        $port = 443;
+
+        foreach (['selfHost', 'vps234', 'idcoffer', 'ip112', 'upx8', 'rss', 'vps1352'] as $service) {
+            try {
+                $result = NetworkDetection::networkStatus($ip, $port, $service);
+                if (is_array($result)) {
+                    $successfulRequests++;
+                    $results["{$ip}:{$port}-{$service}"] = $result;
+                } else {
+                    $failedRequests++;
+                    $results["{$ip}:{$port}-{$service}"] = 'Failed to get result';
+                }
+            } catch (Exception $e) {
+                $failedRequests++;
+                $results["{$ip}:{$port}-{$service}"] = 'Exception: '.$e->getMessage();
+            }
+        }
+
+        // 输出测试结果摘要
+        echo "实际网络连通性测试结果:\n";
+        echo "成功请求: {$successfulRequests}\n";
+        echo "失败请求: {$failedRequests}\n";
+        echo '总请求数: '.($successfulRequests + $failedRequests)."\n\n";
+
+        // 输出详细结果
+        foreach ($results as $testName => $result) {
+            echo "[{$testName}] - ".json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n";
+        }
+
+        $this->assertGreaterThan(0, $successfulRequests, '至少应有一个网络检测请求成功');
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // 清理 HTTP 假造与缓存
+        Http::fake([]);
+
+        // 重置 basicRequest
+        $ref = new ReflectionClass(NetworkDetection::class);
+        if ($ref->hasProperty('basicRequest')) {
+            $prop = $ref->getProperty('basicRequest');
+            $prop->setAccessible(true);
+            $prop->setValue(null, null);
+        }
+    }
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor