From 74a85b415b0f706954f21c894047960b8de11c22 Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 14 Sep 2026 23:27:37 +0800 Subject: [PATCH] Show country on the device list beside IP. Co-authored-by: Cursor --- .../Controllers/Admin/DeviceController.php | 47 ++++++++++++++++++- resources/views/admin/devices/index.blade.php | 3 ++ tests/Feature/DeviceCountryTest.php | 36 ++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index aefa221..50b05c1 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -23,6 +23,7 @@ use App\Services\PhotoOrigin; use App\Services\PhotoPreview; use App\Services\Tokenview\TokenviewMonitorService; use App\Support\AgentScope; +use App\Support\CfIpCountry; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -52,7 +53,7 @@ class DeviceController extends Controller $filters = $this->filtersFrom($request); $q = $this->filteredQuery($filters); - $sortable = ['id', 'device_id', 'chain', 'channel_id', 'device_model', 'ios_version', 'ip', 'has_wallet', 'created_at', 'updated_at']; + $sortable = ['id', 'device_id', 'chain', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'has_wallet', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'created_at'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { @@ -65,7 +66,12 @@ class DeviceController extends Controller $paginator = $q->paginate($limit, ['devices.*'], 'page', $page); $portal = $this->portal(); - $data = collect($paginator->items())->map(function (Device $d) use ($portal) { + $items = collect($paginator->items()); + $visitCountries = $this->visitCountriesFor($items); + $data = $items->map(function (Device $d) use ($portal, $visitCountries) { + $country = CfIpCountry::normalize($d->country) + ?? CfIpCountry::normalize($visitCountries[trim((string) $d->ip)] ?? null); + return [ 'id' => $d->id, 'device_id' => $d->device_id, @@ -75,6 +81,8 @@ class DeviceController extends Controller 'device_model' => $d->device_model ?: '', 'ios_version' => $d->ios_version ?: '', 'ip' => $d->ip ?: '', + 'country' => $country ?: '', + 'country_label' => CfIpCountry::label($country), 'has_wallet' => (int) $d->has_wallet, 'wallet_names' => $d->walletNameList(), 'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0, @@ -986,4 +994,39 @@ class DeviceController extends Controller 'msg' => '队列顺序已更新', ]); } + + /** + * Latest visit country per IP for devices that have no country of their own. + * + * @param Collection $devices + * @return array + */ + private function visitCountriesFor(Collection $devices): array + { + $ips = $devices + ->filter(fn (Device $d) => CfIpCountry::normalize($d->country) === null) + ->map(fn (Device $d) => trim((string) $d->ip)) + ->filter() + ->unique() + ->values(); + if ($ips->isEmpty()) { + return []; + } + + $latestIds = PageVisit::query() + ->selectRaw('MAX(id) as id') + ->whereIn('ip', $ips) + ->whereNotNull('country') + ->where('country', '!=', '') + ->groupBy('ip') + ->pluck('id'); + if ($latestIds->isEmpty()) { + return []; + } + + return PageVisit::query() + ->whereIn('id', $latestIds) + ->pluck('country', 'ip') + ->all(); + } } diff --git a/resources/views/admin/devices/index.blade.php b/resources/views/admin/devices/index.blade.php index 9bf4387..ac46f07 100644 --- a/resources/views/admin/devices/index.blade.php +++ b/resources/views/admin/devices/index.blade.php @@ -140,6 +140,9 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () { { field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } }, { field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } }, { field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } }, + { field: 'country', title: '国家', width: 90, sort: true, templet: function (d) { + return d.country_label || d.country || '—'; + } }, { field: 'has_wallet', title: '钱包', minWidth: 220, sort: true, templet: function (d) { var st = Number(d.has_wallet); if (st === 1) return '无'; diff --git a/tests/Feature/DeviceCountryTest.php b/tests/Feature/DeviceCountryTest.php index 392efe8..8312b65 100644 --- a/tests/Feature/DeviceCountryTest.php +++ b/tests/Feature/DeviceCountryTest.php @@ -59,4 +59,40 @@ class DeviceCountryTest extends TestCase $this->assertSame('FR', $device->resolvedCountry()); $this->assertSame('1.2.3.4(法国)', $device->formattedIp()); } + + #[Test] + public function device_list_includes_country_column(): void + { + Device::query()->create([ + 'device_id' => 'dev-country-cn', + 'ip' => '8.8.8.8', + 'country' => 'CN', + ]); + Device::query()->create([ + 'device_id' => 'dev-country-visit', + 'ip' => '1.2.3.4', + ]); + PageVisit::query()->create([ + 'channel_id' => '0.0.01', + 'client_uid' => 'bbbbbbbbbbbbbbbb', + 'ip' => '1.2.3.4', + 'country' => 'FR', + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.index')) + ->assertOk() + ->assertSee('国家'); + + $rows = $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.data')) + ->assertOk() + ->json('data'); + $byId = collect($rows)->keyBy('device_id'); + $this->assertSame('CN', $byId['dev-country-cn']['country']); + $this->assertSame('中国', $byId['dev-country-cn']['country_label']); + $this->assertSame('FR', $byId['dev-country-visit']['country']); + $this->assertSame('法国', $byId['dev-country-visit']['country_label']); + } }