messages implemented
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ArtworkStatsService
|
||||
*
|
||||
@@ -14,147 +18,200 @@ use Illuminate\Support\Facades\Redis;
|
||||
*/
|
||||
class ArtworkStatsService
|
||||
{
|
||||
protected string $redisKey = 'artwork_stats:deltas';
|
||||
protected string $redisKey = 'artwork_stats:deltas';
|
||||
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
*/
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment views using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementViews((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a set of deltas to the artwork_stats row inside a transaction.
|
||||
* This method is safe to call from jobs or synchronously.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array<string,int> $deltas
|
||||
*/
|
||||
/**
|
||||
* 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<string,int> $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,
|
||||
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,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Increment downloads using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementDownloads((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
'views' => 0,
|
||||
'downloads' => 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', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
// 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
]);
|
||||
/**
|
||||
* 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<string,int> $deltas
|
||||
*/
|
||||
protected function forwardCreatorStats(int $artworkId, array $deltas): void
|
||||
{
|
||||
$viewDelta = (int) ($deltas['views'] ?? 0);
|
||||
$downloadDelta = (int) ($deltas['downloads'] ?? 0);
|
||||
|
||||
try {
|
||||
Redis::rpush($this->redisKey, $payload);
|
||||
} catch (Throwable $e) {
|
||||
// If Redis is unavailable, fallback to immediate apply to avoid data loss
|
||||
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
|
||||
$this->applyDelta($artworkId, [$field => $value]);
|
||||
}
|
||||
}
|
||||
if ($viewDelta <= 0 && $downloadDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain and apply queued deltas from Redis. Returns number processed.
|
||||
* Designed to be invoked by a queued job or artisan command.
|
||||
*/
|
||||
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;
|
||||
if (! $this->redisAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
while ($processed < $max) {
|
||||
$item = Redis::lpop($this->redisKey);
|
||||
if (! $item) {
|
||||
break;
|
||||
}
|
||||
$processed = 0;
|
||||
|
||||
$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()]);
|
||||
}
|
||||
try {
|
||||
while ($processed < $max) {
|
||||
$item = Redis::lpop($this->redisKey);
|
||||
if (! $item) {
|
||||
break;
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
$decoded = json_decode($item, true);
|
||||
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
protected function redisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
// Redis facade may throw if not configured
|
||||
$pong = Redis::connection()->ping();
|
||||
return (bool) $pong;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user