laowu 4 dní pred
rodič
commit
b7fa50c162

+ 20 - 0
app/Http/Controllers/Game/LoginController.php

@@ -16,6 +16,7 @@ use App\Http\Controllers\Controller;
 use App\Http\helper\NumConfig;
 use App\IpLocation;
 use App\Jobs\AfEvent;
+use App\Jobs\IpRiskDetection;
 use App\Models\Account\AccountPhone;
 use App\Models\AccountsInfo;
 use App\Models\GamePhoneVerityCode;
@@ -93,6 +94,7 @@ class LoginController extends Controller
         if ($user) {
             $user = GlobalUserInfo::toWebData($user);
             Util::WriteLog('phone_login',$Phone);
+            $this->dispatchIpRiskCheck($user['UserID'] ?? 0);
             return response()->json(apiReturnSuc($user, ['login.success', 'Login bem-sucedido, bem-vindo de volta!']));//->withCookie($this->setLoginCookie($user['sign']));
         } else {
             return apiReturnFail(['web.login.notfound', 'Sua conta não foi encontrada, registre-se ou tente novamente!']);
@@ -762,9 +764,24 @@ class LoginController extends Controller
             $user['password']=$qp['password'];
         }
 
+        $this->dispatchIpRiskCheck($user['UserID'] ?? 0);
         return response()->json(apiReturnSuc($user, ['login.success', 'Login bem-sucedido, bem-vindo de volta!']));
     }
 
+    /**
+     * 异步派发 IP 风险检测任务
+     * 在用户登录/注册成功后调用,不阻塞响应
+     *
+     * @param int $userId
+     */
+    private function dispatchIpRiskCheck($userId)
+    {
+        $ip = IpLocation::getRealIp();
+        if ($ip && $userId) {
+            IpRiskDetection::dispatch($userId, $ip);
+        }
+    }
+
     public function login(Request $request)
     {
         $type=$request->input('type','id');
@@ -805,6 +822,7 @@ class LoginController extends Controller
         if ($user) {
             if (Hash::check($request->password, $user->LogonPass)) {
                 $user = GlobalUserInfo::toWebData($user,true);
+                $this->dispatchIpRiskCheck($user['UserID'] ?? 0);
                 return response()->json(apiReturnSuc($user, ['login.success', 'Login bem-sucedido, bem-vindo de volta!']));//->withCookie($this->setLoginCookie($user['sign']));
             } else {
                 return apiReturnFail(['web.login.notfound', 'Erro de entrada, tente novamente!'], '', 2);
@@ -1248,6 +1266,7 @@ class LoginController extends Controller
                 ],
             ]);
         }
+        $this->dispatchIpRiskCheck($UserID);
         return response()->json(apiReturnSuc($guser, ['reg.success', 'Registro realizado com sucesso!']));//->withCookie($this->setLoginCookie($guser['sign']));
     }
 
@@ -1548,6 +1567,7 @@ class LoginController extends Controller
             ]);
         }
 
+        $this->dispatchIpRiskCheck($UserID);
         return response()->json(apiReturnSuc($guser, ['reg.success', 'Registro realizado com sucesso!']));//->withCookie($this->setLoginCookie($guser['sign']));
     }
 

+ 8 - 0
app/Http/Controllers/Game/WebRouteController.php

@@ -26,6 +26,7 @@ use App\Http\helper\NumConfig;
 use App\IpLocation;
 use App\Models\AccountsInfo;
 use App\Models\SystemStatusInfo;
+use App\Jobs\IpRiskDetection;
 use App\Services\ApkService;
 use App\Services\VipService;
 use App\Util;
@@ -153,6 +154,13 @@ class WebRouteController extends Controller
         //转换成web数据
         //转换成web数据
         if ($user){
+            // 异步派发 IP 风险检测
+            $userId = $user->UserID ?? 0;
+            $ip = IpLocation::getRealIp();
+            if ($ip && $userId) {
+                IpRiskDetection::dispatch($userId, $ip);
+            }
+
             $user = GlobalUserInfo::toWebData($user);
             $config=WebChannelConfig::getByChannel($user['Channel']);
         } else{

+ 3 - 0
app/Http/logic/admin/GlobalLogicController.php

@@ -731,6 +731,9 @@ class GlobalLogicController extends BaseLogicController
                 ->sum('amount');
         }
 
+        // IP风险检测:查询 Redis Hash 中是否记录了该用户的可疑IP
+        $data['ip_risk'] = Redis::hget('ip_risk_users', (string)$UserID) ?: '';
+
         return compact('data', 'userInfo', 'registerInviteSwitches', 'gameCount', 'userSource', 'OpenPage','platformData');
     }
 

+ 136 - 0
app/Jobs/IpRiskDetection.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\IpRiskService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redis;
+
+/**
+ * IP风险检测异步任务
+ *
+ * 在用户注册或登录成功后异步执行,检测用户IP是否存在风险:
+ * 1. 如果用户已在 Redis Hash 中有记录且 IP 未变 → 跳过
+ * 2. IP 变更后重新检测
+ * 3. 检测到风险 → 写入 Redis Hash: ip_risk_users → {UserID} → {可疑IP}
+ * 4. 未检测到风险 → 从 Hash 中移除(如果之前存在)
+ */
+class IpRiskDetection implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * Redis Hash key
+     */
+    const REDIS_HASH_KEY = 'ip_risk_users';
+
+    /**
+     * 上次检测 IP 缓存 —— 按日分片 Hash,3 天自动过期
+     * key: ip_risk_last_detected:20260507
+     * field: UserID
+     * value: IP
+     * 每天一个 Hash,查过去 3 天的记录,无需大量独立 key
+     */
+    const LAST_DETECTED_PREFIX = 'ip_risk_last_detected:';
+
+    /**
+     * 每次检测覆盖的天数(含当天)
+     */
+    const DETECTION_WINDOW_DAYS = 3;
+
+    /**
+     * @var int 用户ID
+     */
+    protected $userId;
+
+    /**
+     * @var string 待检测的 IP 地址
+     */
+    protected $ip;
+
+    /**
+     * Create a new job instance.
+     *
+     * @param int    $userId
+     * @param string $ip
+     */
+    public function __construct($userId, $ip)
+    {
+        $this->userId = $userId;
+        $this->ip = $ip;
+    }
+
+    /**
+     * 获取过去 N 天的日期 key 列表(当天在最前)
+     *
+     * @param int $days
+     * @return array
+     */
+    private function getRecentDateKeys($days = 3)
+    {
+        $keys = [];
+        for ($i = 0; $i < $days; $i++) {
+            $keys[] = self::LAST_DETECTED_PREFIX . date('Ymd', strtotime("-{$i} days"));
+        }
+        return $keys;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle()
+    {
+        $userId = $this->userId;
+        $ip = $this->ip;
+
+        if (empty($userId) || empty($ip)) {
+            return;
+        }
+
+        // 1. 检查过去 3 天的每日 Hash 中是否已检测过同一 IP
+        //    只要任意一天记录相同 IP → 跳过(IP 未变)
+        $recentKeys = $this->getRecentDateKeys(self::DETECTION_WINDOW_DAYS);
+        $todayKey = $recentKeys[0];
+
+        $ipUnchanged = false;
+        foreach ($recentKeys as $key) {
+            $storedIp = Redis::hget($key, $userId);
+            if ($storedIp === $ip) {
+                $ipUnchanged = true;
+                break;
+            }
+        }
+
+        if ($ipUnchanged) {
+            // IP 在检测窗口内未变,跳过
+            return;
+        }
+
+        // 2. 执行风险检测
+        $service = new IpRiskService();
+        $result = $service->detect($ip);
+
+        // 3. 根据检测结果更新风险标记 Hash
+        if ($result['is_risky']) {
+            $value = $ip . '|' . $result['reason'];
+            Redis::hset(self::REDIS_HASH_KEY, $userId, $value);
+            Log::info("IpRiskDetection: user flagged", [
+                'user_id' => $userId,
+                'ip'      => $ip,
+                'reason'  => $result['reason'],
+            ]);
+        }
+
+        // 4. 记录本次检测结果到今天的 Hash,并确保 3 天过期
+        $isNew = !Redis::exists($todayKey);
+        Redis::hset($todayKey, $userId, $ip);
+        if ($isNew) {
+            Redis::expire($todayKey, 86400 * self::DETECTION_WINDOW_DAYS);
+        }
+    }
+}

+ 143 - 0
app/Services/IpRiskService.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace App\Services;
+
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * IP风险检测服务
+ *
+ * 检测逻辑:
+ * 1. 通过 ip-api.com 获取 IP 的 ASN 和国家信息
+ * 2. 如果 ASN/ISP 关键字包含常见云厂商 → 标记为可疑
+ * 3. 如果 IP 不属于美国 → 标记为可疑
+ */
+class IpRiskService
+{
+    // 常见云厂商 ASN/ISP 关键字(小写)
+    const CLOUD_KEYWORDS = [
+        'amazon',
+        'aws',
+        'google cloud',
+        'gcp',
+        'microsoft azure',
+        'microsoft corporation',
+        'digitalocean',
+        'linode',
+        'vultr',
+        'alibaba',
+        'tencent',
+        'oracle cloud',
+        'ovh',
+        'hetzner',
+        'cloudflare',
+        'akamai',
+        'fastly',
+    ];
+
+    /**
+     * 检测 IP 是否存在风险
+     *
+     * @param string $ip
+     * @return array{is_risky: bool, reason: string, ip: string}
+     */
+    public function detect($ip)
+    {
+        $result = ['is_risky' => false, 'reason' => '', 'ip' => $ip];
+
+        if (empty($ip) || $ip === '127.0.0.1' || $ip === '::1') {
+            return $result;
+        }
+
+        // 私有 IP 范围不检测
+        if ($this->isPrivateIp($ip)) {
+            return $result;
+        }
+
+        $info = $this->queryIpInfo($ip);
+        if (empty($info)) {
+            return $result;
+        }
+
+        $countryCode = strtoupper($info['countryCode'] ?? '');
+        $as = strtolower($info['as'] ?? '');
+        $org = strtolower($info['org'] ?? '');
+        $isp = strtolower($info['isp'] ?? '');
+
+        $combined = $as . ' ' . $org . ' ' . $isp;
+
+        // 检测1: 云厂商 ASN/ISP 关键字
+        $matchedKeyword = $this->matchCloudKeyword($combined);
+        if ($matchedKeyword) {
+            $result['is_risky'] = true;
+            $result['reason'] = "cloud_asn:{$matchedKeyword}";
+            Log::info("IpRisk: cloud ASN detected", ['ip' => $ip, 'keyword' => $matchedKeyword]);
+            return $result;
+        }
+
+        // 检测2: 非美国 IP
+        if ($countryCode !== '' && $countryCode !== 'US') {
+            $result['is_risky'] = true;
+            $result['reason'] = "non_us:{$countryCode}";
+            Log::info("IpRisk: non-US IP detected", ['ip' => $ip, 'country' => $countryCode]);
+            return $result;
+        }
+
+        return $result;
+    }
+
+    /**
+     * 调用 ip-api.com 查询 IP 信息
+     *
+     * @param string $ip
+     * @return array|null
+     */
+    protected function queryIpInfo($ip)
+    {
+        try {
+            $client = new Client(['timeout' => 5]);
+            $response = $client->get("http://ip-api.com/json/{$ip}", [
+                'query' => ['fields' => 'countryCode,as,org,isp'],
+            ]);
+            $data = json_decode($response->getBody()->getContents(), true);
+
+            if (isset($data['status']) && $data['status'] === 'fail') {
+                Log::warning("IpRisk: ip-api query failed", ['ip' => $ip, 'msg' => $data['message'] ?? '']);
+                return null;
+            }
+
+            return $data;
+        } catch (\Exception $e) {
+            Log::warning("IpRisk: ip-api request error", ['ip' => $ip, 'error' => $e->getMessage()]);
+            return null;
+        }
+    }
+
+    /**
+     * 检查是否命中云厂商关键字
+     *
+     * @param string $text
+     * @return string|false 命中的关键字,或 false
+     */
+    protected function matchCloudKeyword($text)
+    {
+        foreach (self::CLOUD_KEYWORDS as $keyword) {
+            if (strpos($text, $keyword) !== false) {
+                return $keyword;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断是否为私有 IP
+     *
+     * @param string $ip
+     * @return bool
+     */
+    protected function isPrivateIp($ip)
+    {
+        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
+    }
+}

+ 1 - 1
resources/views/admin/global/id_list.blade.php

@@ -78,7 +78,7 @@
                             </tr>
                             <tr>
                                 <td>{{ __('auto.会员') }}ID</td>
-                                <td>{{$userInfo->GameID}} @if($data['refund_flag'] || $data['refund_total']) <b style="color:red;">退款({{round($data['refund_flag']/100, 2)}}/{{round($data['refund_total']/100, 2)}})</b> @endif</td>
+                                <td>{{$userInfo->GameID}} @if($data['refund_flag'] || $data['refund_total']) <b style="color:red;">退款({{round($data['refund_flag']/100, 2)}}/{{round($data['refund_total']/100, 2)}})</b> @endif @if(!empty($data['ip_risk'])) <b style="color:orange;">IP风险用户({{$data['ip_risk']}})</b> @endif</td>
                             </tr>
 
                             <tr>

+ 22 - 0
tests/CreatesApplication.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests;
+
+use Illuminate\Contracts\Console\Kernel;
+
+trait CreatesApplication
+{
+    /**
+     * Creates the application.
+     *
+     * @return \Illuminate\Foundation\Application
+     */
+    public function createApplication()
+    {
+        $app = require __DIR__.'/../bootstrap/app.php';
+
+        $app->make(Kernel::class)->bootstrap();
+
+        return $app;
+    }
+}

+ 10 - 0
tests/TestCase.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Tests;
+
+use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
+
+abstract class TestCase extends BaseTestCase
+{
+    use CreatesApplication;
+}

+ 114 - 0
tests/Unit/IpRiskDetectionTest.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Jobs\IpRiskDetection;
+use Illuminate\Support\Facades\Redis;
+use Tests\TestCase;
+
+/**
+ * IpRiskDetection Job 单元测试
+ *
+ * 测试 Job handle() 方法的核心逻辑:
+ * - 重复 IP 跳过检测
+ * - 空参数跳过
+ * - Redis Hash 写入/读取/删除
+ */
+class IpRiskDetectionTest extends TestCase
+{
+    // ==================== 边界值:空参数 ====================
+
+    /** @test */
+    public function test_skips_when_user_id_is_empty()
+    {
+        $job = new IpRiskDetection(0, '1.2.3.4');
+        $job->handle();
+        $this->assertTrue(true);
+    }
+
+    /** @test */
+    public function test_skips_when_ip_is_empty()
+    {
+        $job = new IpRiskDetection(12345, '');
+        $job->handle();
+        $this->assertTrue(true);
+    }
+
+    /** @test */
+    public function test_skips_when_both_are_empty()
+    {
+        $job = new IpRiskDetection(0, '');
+        $job->handle();
+        $this->assertTrue(true);
+    }
+
+    // ==================== 重复检测跳过 ====================
+
+    /** @test */
+    public function test_skips_detection_when_same_ip_in_today_hash()
+    {
+        $userId = 10001;
+        $ip = '73.162.0.1';
+        $todayKey = IpRiskDetection::LAST_DETECTED_PREFIX . date('Ymd');
+
+        Redis::shouldReceive('hget')
+            ->once()
+            ->with($todayKey, $userId)
+            ->andReturn($ip);
+
+        $job = new IpRiskDetection($userId, $ip);
+        $job->handle();
+
+        $this->assertTrue(true);
+    }
+
+    /** @test */
+    public function test_skips_detection_when_same_ip_in_yesterday_hash()
+    {
+        $userId = 10001;
+        $ip = '73.162.0.1';
+        $todayKey = IpRiskDetection::LAST_DETECTED_PREFIX . date('Ymd');
+        $yesterdayKey = IpRiskDetection::LAST_DETECTED_PREFIX . date('Ymd', strtotime('-1 day'));
+
+        // today miss → yesterday hit
+        Redis::shouldReceive('hget')
+            ->once()
+            ->with($todayKey, $userId)
+            ->andReturn(null);
+        Redis::shouldReceive('hget')
+            ->once()
+            ->with($yesterdayKey, $userId)
+            ->andReturn($ip);
+
+        $job = new IpRiskDetection($userId, $ip);
+        $job->handle();
+
+        $this->assertTrue(true);
+    }
+
+    // ==================== 容器内集成测试 ====================
+
+    /**
+     * 以下测试需要模拟 IpRiskService,但 Job 内部通过 new 创建服务实例。
+     *
+     * 测试策略:
+     * - IpRiskService 自身的 detect() 逻辑已在 IpRiskServiceTest 中覆盖
+     * - Job 与 Redis 的交互在上述测试中覆盖
+     * - 完整链路测试建议在验收环境中通过实际登录/注册流程验证
+     */
+
+    /** @test */
+    public function test_has_correct_constants()
+    {
+        $this->assertEquals('ip_risk_users', IpRiskDetection::REDIS_HASH_KEY);
+        $this->assertEquals('ip_risk_last_detected:', IpRiskDetection::LAST_DETECTED_PREFIX);
+        $this->assertEquals(3, IpRiskDetection::DETECTION_WINDOW_DAYS);
+    }
+
+    /** @test */
+    public function test_implements_should_queue()
+    {
+        $job = new IpRiskDetection(1, '1.2.3.4');
+        $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
+    }
+}

+ 232 - 0
tests/Unit/IpRiskServiceTest.php

@@ -0,0 +1,232 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\IpRiskService;
+use Tests\TestCase;
+
+/**
+ * IpRiskService 单元测试
+ *
+ * 测试 IP 风险检测逻辑:
+ * - 云厂商 ASN 关键字命中
+ * - 非美国 IP 命中
+ * - 本地/私有 IP 跳过
+ * - API 失败时的兜底行为
+ */
+class IpRiskServiceTest extends TestCase
+{
+    // ==================== 云厂商 ASN 检测 ====================
+
+    /** @test */
+    public function test_aws_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS16509 Amazon.com, Inc.',
+            'org'         => 'Amazon Web Services',
+            'isp'         => 'Amazon.com, Inc.',
+        ]);
+
+        $result = $service->detect('1.2.3.4');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('cloud_asn:', $result['reason']);
+        $this->assertStringContainsString('amazon', $result['reason']);
+    }
+
+    /** @test */
+    public function test_google_cloud_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS15169 Google LLC',
+            'org'         => 'Google Cloud Platform',
+            'isp'         => 'Google LLC',
+        ]);
+
+        $result = $service->detect('8.8.8.8');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('cloud_asn:', $result['reason']);
+    }
+
+    /** @test */
+    public function test_azure_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS8075 Microsoft Corporation',
+            'org'         => 'Microsoft Azure',
+            'isp'         => 'Microsoft Corporation',
+        ]);
+
+        $result = $service->detect('40.76.4.15');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('microsoft', $result['reason']);
+    }
+
+    /** @test */
+    public function test_digitalocean_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS14061 DigitalOcean, LLC',
+            'org'         => 'DigitalOcean, LLC',
+            'isp'         => 'DigitalOcean, LLC',
+        ]);
+
+        $result = $service->detect('167.71.0.1');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('digitalocean', $result['reason']);
+    }
+
+    // ==================== 非美国 IP 检测 ====================
+
+    /** @test */
+    public function test_china_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'CN',
+            'as'          => 'AS4134 CHINANET',
+            'org'         => 'Chinanet',
+            'isp'         => 'Chinanet',
+        ]);
+
+        $result = $service->detect('114.114.114.114');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('non_us:CN', $result['reason']);
+    }
+
+    /** @test */
+    public function test_brazil_ip_as_risky()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'BR',
+            'as'          => 'AS7738 Vivo',
+            'org'         => 'Telefonica Brasil',
+            'isp'         => 'Vivo',
+        ]);
+
+        $result = $service->detect('200.200.0.1');
+
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('non_us:BR', $result['reason']);
+    }
+
+    // ==================== 正常 IP ====================
+
+    /** @test */
+    public function test_normal_us_residential_ip_not_flagged()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS7922 Comcast Cable',
+            'org'         => 'Comcast Cable Communications',
+            'isp'         => 'Comcast Cable',
+        ]);
+
+        $result = $service->detect('73.162.0.1');
+
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    // ==================== 边界值 ====================
+
+    /** @test */
+    public function test_skips_empty_ip()
+    {
+        $service = new IpRiskService();
+        $result = $service->detect('');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_skips_localhost_ip()
+    {
+        $service = new IpRiskService();
+        $result = $service->detect('127.0.0.1');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_skips_ipv6_localhost()
+    {
+        $service = new IpRiskService();
+        $result = $service->detect('::1');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_skips_private_ip_range_10()
+    {
+        $service = new IpRiskService();
+        $result = $service->detect('10.0.0.1');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_skips_private_ip_range_192_168()
+    {
+        $service = new IpRiskService();
+        $result = $service->detect('192.168.1.1');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_safe_when_api_query_fails()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn(null);
+
+        $result = $service->detect('8.8.8.8');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_safe_when_api_returns_fail_status()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'status'  => 'fail',
+            'message' => 'invalid query',
+        ]);
+
+        $result = $service->detect('999.999.999.999');
+        $this->assertFalse($result['is_risky']);
+        $this->assertEmpty($result['reason']);
+    }
+
+    /** @test */
+    public function test_cloud_asn_priority_over_non_us()
+    {
+        $service = $this->createPartialMock(IpRiskService::class, ['queryIpInfo']);
+        $service->method('queryIpInfo')->willReturn([
+            'countryCode' => 'US',
+            'as'          => 'AS16509 Amazon.com, Inc.',
+            'org'         => 'Amazon Web Services',
+            'isp'         => 'Amazon.com, Inc.',
+        ]);
+
+        $result = $service->detect('52.95.0.1');
+        $this->assertTrue($result['is_risky']);
+        $this->assertStringContainsString('cloud_asn', $result['reason']);
+    }
+}