redisAvailable()) { $this->pushDelta($artworkId, 'views', $by); $this->pushDelta($artworkId, 'views_24h', $by); $this->pushDelta($artworkId, 'views_7d', $by); return; } $this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]); } /** * Increment downloads for an artwork. * Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated. */ public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void { if ($defer && $this->redisAvailable()) { $this->pushDelta($artworkId, 'downloads', $by); $this->pushDelta($artworkId, 'downloads_24h', $by); $this->pushDelta($artworkId, 'downloads_7d', $by); return; } $this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]); } /** * Write one row to artwork_view_events (the persistent event log). * * Called from ArtworkViewController after session dedup passes. * Guests (unauthenticated) are recorded with user_id = null. * Rows are pruned after 90 days by skinbase:prune-view-events. */ public function logViewEvent(int $artworkId, ?int $userId): void { try { DB::table('artwork_view_events')->insert([ 'artwork_id' => $artworkId, 'user_id' => $userId, 'viewed_at' => now(), ]); } catch (Throwable $e) { Log::warning('Failed to write artwork_view_events row', [ 'artwork_id' => $artworkId, 'user_id' => $userId, 'error' => $e->getMessage(), ]); } } /** * Increment views using an Artwork model. */ public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void { $this->incrementViews((int) $artwork->id, $by, $defer); } /** * Increment downloads using an Artwork model. */ public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void { $this->incrementDownloads((int) $artwork->id, $by, $defer); } /** * Apply a set of deltas to the artwork_stats row inside a transaction. * After updating artwork-level stats, forwards view/download counts to * UserStatsService so creator-level counters stay current. * * @param int $artworkId * @param array $deltas */ public function applyDelta(int $artworkId, array $deltas): void { try { DB::transaction(function () use ($artworkId, $deltas) { // Ensure a stats row exists — insert default zeros if missing. DB::table('artwork_stats')->insertOrIgnore([ 'artwork_id' => $artworkId, 'views' => 0, 'views_24h' => 0, 'views_7d' => 0, 'downloads' => 0, 'downloads_24h' => 0, 'downloads_7d' => 0, 'favorites' => 0, 'rating_avg' => 0, 'rating_count' => 0, ]); foreach ($deltas as $column => $value) { // Only allow known columns to avoid SQL injection. if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) { continue; } DB::table('artwork_stats') ->where('artwork_id', $artworkId) ->increment($column, (int) $value); } }); // Forward creator-level counters outside the transaction. $this->forwardCreatorStats($artworkId, $deltas); } catch (Throwable $e) { Log::error('Failed to apply artwork stats delta', [ 'artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage(), ]); } } /** * After applying artwork-level deltas, forward relevant totals to the * creator's user_statistics row via UserStatsService. * Views skip Meilisearch reindex (high frequency — covered by recompute). * * @param int $artworkId * @param array $deltas */ protected function forwardCreatorStats(int $artworkId, array $deltas): void { $viewDelta = (int) ($deltas['views'] ?? 0); $downloadDelta = (int) ($deltas['downloads'] ?? 0); if ($viewDelta <= 0 && $downloadDelta <= 0) { return; } try { $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); if (! $creatorId) { return; } /** @var UserStatsService $svc */ $svc = app(UserStatsService::class); if ($viewDelta > 0) { // High-frequency: increment counter but skip Meilisearch reindex. $svc->incrementArtworkViewsReceived($creatorId, $viewDelta); } if ($downloadDelta > 0) { $svc->incrementDownloadsReceived($creatorId, $downloadDelta); } } catch (Throwable $e) { Log::warning('Failed to forward creator stats from artwork delta', [ 'artwork_id' => $artworkId, 'error' => $e->getMessage(), ]); } } /** * Push a delta to Redis queue for async processing. */ protected function pushDelta(int $artworkId, string $field, int $value): void { $payload = json_encode([ 'artwork_id' => $artworkId, 'field' => $field, 'value' => $value, 'ts' => time(), ]); try { Redis::rpush($this->redisKey, $payload); } catch (Throwable $e) { // If Redis is unavailable, fall back to immediate apply to avoid data loss. Log::warning('Redis unavailable for artwork stats; applying immediately', [ 'error' => $e->getMessage(), ]); $this->applyDelta($artworkId, [$field => $value]); } } /** * Drain and apply queued deltas from Redis. Returns number processed. * Designed to be invoked by a queued job or artisan command. */ public function processPendingFromRedis(int $max = 1000): int { if (! $this->redisAvailable()) { return 0; } $processed = 0; try { while ($processed < $max) { $item = Redis::lpop($this->redisKey); if (! $item) { break; } $decoded = json_decode($item, true); if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) { continue; } $this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]); $processed++; } } catch (Throwable $e) { Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]); } return $processed; } protected function redisAvailable(): bool { try { $pong = Redis::connection()->ping(); return (bool) $pong; } catch (Throwable $e) { return false; } } }