optimizations
This commit is contained in:
50
app/Jobs/AnalyzeArtworkAiAssistJob.php
Normal file
50
app/Jobs/AnalyzeArtworkAiAssistJob.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Studio\StudioAiAssistService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class AnalyzeArtworkAiAssistJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 45;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $artworkId,
|
||||
private readonly bool $force = false,
|
||||
) {
|
||||
$queue = (string) config('vision.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [5, 20, 60];
|
||||
}
|
||||
|
||||
public function handle(StudioAiAssistService $aiAssist): void
|
||||
{
|
||||
$artwork = Artwork::query()->find($this->artworkId);
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
$aiAssist->analyze($artwork, $this->force);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,14 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AutoTagArtworkJob implements ShouldQueue
|
||||
{
|
||||
@@ -54,9 +52,11 @@ final class AutoTagArtworkJob implements ShouldQueue
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(TagService $tagService, TagNormalizer $normalizer): void
|
||||
public function handle(TagService $tagService, TagNormalizer $normalizer, ?VisionService $vision = null): void
|
||||
{
|
||||
if (! (bool) config('vision.enabled', true)) {
|
||||
$vision ??= app(VisionService::class);
|
||||
|
||||
if (! $vision->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,27 +65,28 @@ final class AutoTagArtworkJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$imageUrl = $this->buildImageUrl($this->hash);
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$processingKey = $this->processingKey($this->artworkId, $this->hash);
|
||||
if (! $this->acquireProcessingLock($processingKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ref = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
$clipTags = $this->callClip($imageUrl, $ref);
|
||||
|
||||
$yoloTags = [];
|
||||
if ($this->shouldRunYolo($artwork)) {
|
||||
$yoloTags = $this->callYolo($imageUrl, $ref);
|
||||
$analysis = $vision->analyzeArtwork($artwork, $this->hash);
|
||||
if ($analysis === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = $this->mergeTags($clipTags, $yoloTags);
|
||||
$clipTags = $analysis['clip_tags'] ?? [];
|
||||
$yoloTags = $analysis['yolo_objects'] ?? [];
|
||||
|
||||
$vision->persistVisionMetadata(
|
||||
$artwork,
|
||||
$clipTags,
|
||||
$analysis['blip_caption'] ?? null,
|
||||
$yoloTags,
|
||||
);
|
||||
|
||||
$merged = $vision->mergeTags($clipTags, $yoloTags);
|
||||
if ($merged === []) {
|
||||
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
|
||||
return;
|
||||
@@ -109,7 +110,6 @@ final class AutoTagArtworkJob implements ShouldQueue
|
||||
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AutoTagArtworkJob failed', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $this->artworkId,
|
||||
'hash' => $this->hash,
|
||||
'attempt' => $this->attempts(),
|
||||
@@ -123,270 +123,6 @@ final class AutoTagArtworkJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = (string) config('cdn.files_url');
|
||||
$base = rtrim($base, '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$variant = $variant !== '' ? $variant : 'md';
|
||||
|
||||
// Matches the upload public path layout used for derivatives (img/aa/bb/cc/variant.webp).
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
$path = 'img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||
|
||||
return $base . '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||
*/
|
||||
private function callClip(string $imageUrl, string $ref): array
|
||||
{
|
||||
$base = trim((string) config('vision.clip.base_url', ''));
|
||||
if ($base === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$endpoint = (string) config('vision.clip.endpoint', '/analyze');
|
||||
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
$timeout = (int) config('vision.clip.timeout_seconds', 8);
|
||||
$connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2);
|
||||
$retries = (int) config('vision.clip.retries', 1);
|
||||
$delay = (int) config('vision.clip.retry_delay_ms', 200);
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $connectTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||
->post($url, [
|
||||
'url' => $imageUrl,
|
||||
'image_url' => $imageUrl,
|
||||
'limit' => 8,
|
||||
'artwork_id' => $this->artworkId,
|
||||
'hash' => $this->hash,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CLIP analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->serverError()) {
|
||||
Log::warning('CLIP analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||
throw new \RuntimeException('CLIP server error: ' . $response->status());
|
||||
}
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||
|
||||
// Fallback: try uploading the local derivative file to the gateway's file upload
|
||||
// endpoint (`/analyze/all/file`) if the gateway cannot fetch the public URL.
|
||||
try {
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$row = DB::table('artwork_files')
|
||||
->where('artwork_id', $this->artworkId)
|
||||
->where('variant', $variant)
|
||||
->first();
|
||||
|
||||
if ($row && ! empty($row->path)) {
|
||||
$storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR);
|
||||
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path);
|
||||
if (is_file($absolute) && is_readable($absolute)) {
|
||||
$uploadUrl = rtrim($base, '/') . '/analyze/all/file';
|
||||
try {
|
||||
$attach = file_get_contents($absolute);
|
||||
if ($attach !== false) {
|
||||
$uploadResp = Http::attach('file', $attach, basename($absolute))
|
||||
->post($uploadUrl, ['limit' => 5]);
|
||||
|
||||
if ($uploadResp->ok()) {
|
||||
return $this->extractTagList($uploadResp->json());
|
||||
}
|
||||
Log::warning('CLIP upload fallback non-ok', ['ref' => $ref, 'status' => $uploadResp->status(), 'body' => $this->safeBody($uploadResp->body())]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractTagList($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||
*/
|
||||
private function callYolo(string $imageUrl, string $ref): array
|
||||
{
|
||||
if (! (bool) config('vision.yolo.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = trim((string) config('vision.yolo.base_url', ''));
|
||||
if ($base === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$endpoint = (string) config('vision.yolo.endpoint', '/analyze');
|
||||
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
$timeout = (int) config('vision.yolo.timeout_seconds', 8);
|
||||
$connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2);
|
||||
$retries = (int) config('vision.yolo.retries', 1);
|
||||
$delay = (int) config('vision.yolo.retry_delay_ms', 200);
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $connectTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||
->post($url, [
|
||||
'url' => $imageUrl,
|
||||
'image_url' => $imageUrl,
|
||||
'conf' => 0.25,
|
||||
'artwork_id' => $this->artworkId,
|
||||
'hash' => $this->hash,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('YOLO analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->serverError()) {
|
||||
Log::warning('YOLO analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||
throw new \RuntimeException('YOLO server error: ' . $response->status());
|
||||
}
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('YOLO analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractTagList($response->json());
|
||||
}
|
||||
|
||||
private function shouldRunYolo(Artwork $artwork): bool
|
||||
{
|
||||
if (! (bool) config('vision.yolo.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) config('vision.yolo.photography_only', true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($artwork->categories as $category) {
|
||||
$slug = strtolower((string) ($category->contentType?->slug ?? ''));
|
||||
if ($slug === 'photography') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $json
|
||||
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||
*/
|
||||
private function extractTagList(mixed $json): array
|
||||
{
|
||||
if (is_array($json) && $this->isListOfTags($json)) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) {
|
||||
return $json['tags'];
|
||||
}
|
||||
|
||||
if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) {
|
||||
return $json['data'];
|
||||
}
|
||||
|
||||
// Common YOLO-style response: objects: [{label, confidence}]
|
||||
if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) {
|
||||
$out = [];
|
||||
foreach ($json['objects'] as $obj) {
|
||||
if (! is_array($obj)) {
|
||||
continue;
|
||||
}
|
||||
$label = (string) ($obj['label'] ?? $obj['tag'] ?? '');
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $arr
|
||||
*/
|
||||
private function isListOfTags(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($arr as $row) {
|
||||
if (! is_array($row)) {
|
||||
return false;
|
||||
}
|
||||
if (! array_key_exists('tag', $row)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{tag: string, confidence?: float|int|null}> $a
|
||||
* @param array<int, array{tag: string, confidence?: float|int|null}> $b
|
||||
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||
*/
|
||||
private function mergeTags(array $a, array $b): array
|
||||
{
|
||||
$byTag = [];
|
||||
foreach (array_merge($a, $b) as $row) {
|
||||
$tag = (string) ($row['tag'] ?? '');
|
||||
if ($tag === '') {
|
||||
continue;
|
||||
}
|
||||
$conf = $row['confidence'] ?? null;
|
||||
$conf = is_numeric($conf) ? (float) $conf : null;
|
||||
|
||||
// Keep highest confidence for duplicates.
|
||||
if (! isset($byTag[$tag])) {
|
||||
$byTag[$tag] = ['tag' => $tag, 'confidence' => $conf];
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $byTag[$tag]['confidence'];
|
||||
if ($existing === null || ($conf !== null && $conf > (float) $existing)) {
|
||||
$byTag[$tag]['confidence'] = $conf;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($byTag);
|
||||
}
|
||||
|
||||
private function processingKey(int $artworkId, string $hash): string
|
||||
{
|
||||
return 'autotag:processing:' . $artworkId . ':' . $hash;
|
||||
@@ -429,13 +165,4 @@ final class AutoTagArtworkJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function safeBody(string $body): string
|
||||
{
|
||||
$body = trim($body);
|
||||
if ($body === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Str::limit($body, 800);
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Jobs/BackfillArtworkVectorIndexJob.php
Normal file
73
app/Jobs/BackfillArtworkVectorIndexJob.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class BackfillArtworkVectorIndexJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $afterId = 0,
|
||||
private readonly int $batchSize = 200,
|
||||
private readonly bool $publicOnly = false,
|
||||
private readonly int $staleHours = 0,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$batch = max(1, min($this->batchSize, 1000));
|
||||
$staleHours = max(0, $this->staleHours);
|
||||
$staleBefore = $staleHours > 0 ? now()->subHours($staleHours) : null;
|
||||
|
||||
$query = Artwork::query()
|
||||
->where('id', '>', $this->afterId)
|
||||
->whereNotNull('hash')
|
||||
->whereHas('embeddings')
|
||||
->when($this->publicOnly, static fn ($query) => $query->public()->published())
|
||||
->orderBy('id')
|
||||
->limit($batch);
|
||||
|
||||
if ($staleBefore !== null) {
|
||||
$query->where(static function ($innerQuery) use ($staleBefore): void {
|
||||
$innerQuery->whereNull('last_vector_indexed_at')
|
||||
->orWhere('last_vector_indexed_at', '<=', $staleBefore);
|
||||
});
|
||||
}
|
||||
|
||||
$artworks = $query->get(['id']);
|
||||
|
||||
if ($artworks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
SyncArtworkVectorIndexJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
|
||||
if ($artworks->count() === $batch) {
|
||||
$lastId = (int) $artworks->last()->id;
|
||||
self::dispatch($lastId, $batch, $this->publicOnly, $staleHours);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@ use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\ArtworkVectorIndexService;
|
||||
use App\Services\Vision\VectorService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
@@ -42,13 +45,19 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder): void
|
||||
public function handle(
|
||||
ArtworkEmbeddingClient $client,
|
||||
ArtworkVisionImageUrl $imageUrlBuilder,
|
||||
VectorService|ArtworkVectorIndexService $vectors,
|
||||
): void
|
||||
{
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->find($this->artworkId);
|
||||
$artwork = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->find($this->artworkId);
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
@@ -111,11 +120,32 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->upsertVectorIndex($vectors, $artwork);
|
||||
} finally {
|
||||
$this->releaseLock($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertVectorIndex(
|
||||
VectorService|ArtworkVectorIndexService $vectors,
|
||||
Artwork $artwork
|
||||
): void
|
||||
{
|
||||
if (! $vectors->isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vectors->upsertArtwork($artwork);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -35,5 +36,6 @@ final class GenerateDerivativesJob implements ShouldQueue
|
||||
// Auto-tagging is async and must never block publish.
|
||||
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use App\Services\Recommendations\SessionRecoService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -42,7 +43,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(UserInterestProfileService $profileService): void
|
||||
public function handle(UserInterestProfileService $profileService, SessionRecoService $sessionRecoService): void
|
||||
{
|
||||
$idempotencyKey = sprintf('discovery:event:processed:%s', $this->eventId);
|
||||
|
||||
@@ -107,6 +108,15 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
algoVersion: $this->algoVersion,
|
||||
eventMeta: $this->meta
|
||||
);
|
||||
|
||||
$sessionRecoService->applyEvent(
|
||||
userId: $this->userId,
|
||||
eventType: $this->eventType,
|
||||
artworkId: $this->artworkId,
|
||||
categoryId: $categoryId !== null ? (int) $categoryId : null,
|
||||
occurredAt: $occurredAt->toIso8601String(),
|
||||
meta: $this->meta,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('IngestUserDiscoveryEventJob failed', [
|
||||
'event_id' => $this->eventId,
|
||||
|
||||
129
app/Jobs/NovaCards/GenerateNovaCardExportJob.php
Normal file
129
app/Jobs/NovaCards/GenerateNovaCardExportJob.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardExport;
|
||||
use App\Services\NovaCards\NovaCardRenderService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateNovaCardExportJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $exportId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(NovaCardRenderService $renderService): void
|
||||
{
|
||||
$export = NovaCardExport::query()->find($this->exportId);
|
||||
if (! $export || $export->status === NovaCardExport::STATUS_READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$card = NovaCard::query()->with(['backgroundImage'])->find($export->card_id);
|
||||
if (! $card) {
|
||||
$export->forceFill(['status' => NovaCardExport::STATUS_FAILED])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$export->forceFill(['status' => NovaCardExport::STATUS_PROCESSING])->save();
|
||||
|
||||
$specs = config('nova_cards.export_formats.' . $export->export_type, []);
|
||||
$width = (int) ($export->width ?: ($specs['width'] ?? 1080));
|
||||
$height = (int) ($export->height ?: ($specs['height'] ?? 1080));
|
||||
$format = (string) ($export->format ?: ($specs['format'] ?? 'png'));
|
||||
|
||||
$outputPath = $this->renderExport($renderService, $card, $export, $width, $height, $format);
|
||||
|
||||
$export->forceFill([
|
||||
'status' => NovaCardExport::STATUS_READY,
|
||||
'output_path' => $outputPath,
|
||||
'ready_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$export = NovaCardExport::query()->find($this->exportId);
|
||||
if ($export) {
|
||||
$export->forceFill(['status' => NovaCardExport::STATUS_FAILED])->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function renderExport(
|
||||
NovaCardRenderService $renderService,
|
||||
NovaCard $card,
|
||||
NovaCardExport $export,
|
||||
int $width,
|
||||
int $height,
|
||||
string $format,
|
||||
): string {
|
||||
// Use the render service for standard preview dimensions; for non-standard
|
||||
// dimensions delegate a temporary override via a cloned render call.
|
||||
if ($export->export_type === NovaCardExport::TYPE_PREVIEW) {
|
||||
$rendered = $renderService->render($card);
|
||||
|
||||
return (string) ($rendered['preview_path'] ?? '');
|
||||
}
|
||||
|
||||
// For all other export types, produce a dedicated output file.
|
||||
if (! function_exists('imagecreatetruecolor')) {
|
||||
throw new \RuntimeException('Nova card rendering requires the GD extension.');
|
||||
}
|
||||
|
||||
// Render the card at the requested dimensions by temporarily adjusting
|
||||
// the card's format to match before calling the shared render pipeline.
|
||||
// We copy the rendered image data to a dedicated export path rather than
|
||||
// overwriting the card's standard preview.
|
||||
$rendered = $renderService->render($card);
|
||||
$previewPath = (string) ($rendered['preview_path'] ?? '');
|
||||
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
|
||||
if (! $disk->exists($previewPath)) {
|
||||
throw new \RuntimeException('Render output not found after render: ' . $previewPath);
|
||||
}
|
||||
|
||||
$blob = (string) $disk->get($previewPath);
|
||||
$source = @imagecreatefromstring($blob);
|
||||
if ($source === false) {
|
||||
throw new \RuntimeException('Failed to load rendered preview for export.');
|
||||
}
|
||||
|
||||
$target = imagecreatetruecolor($width, $height);
|
||||
imagealphablending($target, true);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, imagesx($source), imagesy($source));
|
||||
imagedestroy($source);
|
||||
|
||||
$exportDir = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/')
|
||||
. '/' . $card->user_id . '/exports';
|
||||
$filename = $card->uuid . '-' . $export->export_type . '-' . Str::random(6) . '.' . $format;
|
||||
$outputPath = $exportDir . '/' . $filename;
|
||||
|
||||
ob_start();
|
||||
match ($format) {
|
||||
'jpg', 'jpeg' => imagejpeg($target, null, 90),
|
||||
'webp' => imagewebp($target, null, 88),
|
||||
default => imagepng($target, null, 6),
|
||||
};
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($target);
|
||||
|
||||
$disk->put($outputPath, $binary);
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
}
|
||||
51
app/Jobs/NovaCards/RenderNovaCardPreviewJob.php
Normal file
51
app/Jobs/NovaCards/RenderNovaCardPreviewJob.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardPublishModerationService;
|
||||
use App\Services\NovaCards\NovaCardRenderService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RenderNovaCardPreviewJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $cardId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(NovaCardRenderService $renderService, NovaCardPublishModerationService $moderation): void
|
||||
{
|
||||
$card = NovaCard::query()->with(['backgroundImage'])->find($this->cardId);
|
||||
if (! $card) {
|
||||
return;
|
||||
}
|
||||
|
||||
$renderService->render($card);
|
||||
|
||||
$evaluation = $moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));
|
||||
|
||||
$moderation->applyPublishOutcome($card->fresh(), $evaluation);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$card = NovaCard::query()->find($this->cardId);
|
||||
if (! $card) {
|
||||
return;
|
||||
}
|
||||
|
||||
$card->forceFill([
|
||||
'status' => NovaCard::STATUS_DRAFT,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
22
app/Jobs/RebuildTrendingNovaCardsJob.php
Normal file
22
app/Jobs/RebuildTrendingNovaCardsJob.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\NovaCards\NovaCardTrendingService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RebuildTrendingNovaCardsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(NovaCardTrendingService $trending): void
|
||||
{
|
||||
$trending->rebuildAll();
|
||||
}
|
||||
}
|
||||
24
app/Jobs/RecalculateRisingNovaCardsJob.php
Normal file
24
app/Jobs/RecalculateRisingNovaCardsJob.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\NovaCards\NovaCardRisingService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateRisingNovaCardsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(NovaCardRisingService $risingService): void
|
||||
{
|
||||
$risingService->invalidateCache();
|
||||
// Pre-warm the cache immediately so the next web request is fast.
|
||||
$risingService->risingCards(36, cached: false);
|
||||
}
|
||||
}
|
||||
48
app/Jobs/RefreshCollectionHealthJob.php
Normal file
48
app/Jobs/RefreshCollectionHealthJob.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionHealthService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class RefreshCollectionHealthJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 180, 600];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $collectionId,
|
||||
public readonly ?int $actorUserId = null,
|
||||
public readonly string $reason = 'queued-health-refresh',
|
||||
) {
|
||||
$this->onQueue((string) config('collections.v5.queue.name', 'collections'));
|
||||
}
|
||||
|
||||
public function handle(CollectionHealthService $health): void
|
||||
{
|
||||
$collection = Collection::query()->find($this->collectionId);
|
||||
|
||||
if (! $collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = $this->actorUserId ? User::query()->find($this->actorUserId) : null;
|
||||
|
||||
$health->refresh($collection, $actor, $this->reason);
|
||||
}
|
||||
}
|
||||
47
app/Jobs/RefreshCollectionQualityJob.php
Normal file
47
app/Jobs/RefreshCollectionQualityJob.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionWorkflowService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class RefreshCollectionQualityJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 180, 600];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $collectionId,
|
||||
public readonly ?int $actorUserId = null,
|
||||
) {
|
||||
$this->onQueue((string) config('collections.v5.queue.name', 'collections'));
|
||||
}
|
||||
|
||||
public function handle(CollectionWorkflowService $workflow): void
|
||||
{
|
||||
$collection = Collection::query()->find($this->collectionId);
|
||||
|
||||
if (! $collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = $this->actorUserId ? User::query()->find($this->actorUserId) : null;
|
||||
|
||||
$workflow->qualityRefresh($collection->loadMissing('user'), $actor);
|
||||
}
|
||||
}
|
||||
62
app/Jobs/RefreshCollectionRecommendationJob.php
Normal file
62
app/Jobs/RefreshCollectionRecommendationJob.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionHistoryService;
|
||||
use App\Services\CollectionRankingService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class RefreshCollectionRecommendationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 180, 600];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $collectionId,
|
||||
public readonly ?int $actorUserId = null,
|
||||
public readonly string $context = 'default',
|
||||
) {
|
||||
$this->onQueue((string) config('collections.v5.queue.name', 'collections'));
|
||||
}
|
||||
|
||||
public function handle(CollectionRankingService $ranking): void
|
||||
{
|
||||
$collection = Collection::query()->find($this->collectionId);
|
||||
|
||||
if (! $collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = $this->actorUserId ? User::query()->find($this->actorUserId) : null;
|
||||
$fresh = $ranking->refresh($collection, $this->context);
|
||||
|
||||
app(CollectionHistoryService::class)->record(
|
||||
$fresh,
|
||||
$actor,
|
||||
'recommendation_refreshed',
|
||||
'Collection recommendations refreshed.',
|
||||
null,
|
||||
[
|
||||
'context' => $this->context,
|
||||
'recommendation_tier' => $fresh->recommendation_tier,
|
||||
'ranking_bucket' => $fresh->ranking_bucket,
|
||||
'search_boost_tier' => $fresh->search_boost_tier,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Recommendations\PersonalizedFeedService;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -30,10 +30,10 @@ final class RegenerateUserRecommendationCacheJob implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(PersonalizedFeedService $feedService): void
|
||||
public function handle(RecommendationFeedResolver $feedResolver): void
|
||||
{
|
||||
try {
|
||||
$feedService->regenerateCacheForUser($this->userId, $this->algoVersion);
|
||||
$feedResolver->regenerateCacheForUser($this->userId, $this->algoVersion);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('RegenerateUserRecommendationCacheJob failed', [
|
||||
'user_id' => $this->userId,
|
||||
|
||||
50
app/Jobs/ScanCollectionDuplicateCandidatesJob.php
Normal file
50
app/Jobs/ScanCollectionDuplicateCandidatesJob.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionHealthService;
|
||||
use App\Services\CollectionMergeService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class ScanCollectionDuplicateCandidatesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 180, 600];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $collectionId,
|
||||
public readonly ?int $actorUserId = null,
|
||||
public readonly int $limit = 5,
|
||||
) {
|
||||
$this->onQueue((string) config('collections.v5.queue.name', 'collections'));
|
||||
}
|
||||
|
||||
public function handle(CollectionMergeService $merge, CollectionHealthService $health): void
|
||||
{
|
||||
$collection = Collection::query()->find($this->collectionId);
|
||||
|
||||
if (! $collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = $this->actorUserId ? User::query()->find($this->actorUserId) : null;
|
||||
|
||||
$merge->syncSuggestedCandidates($collection, $actor, $this->limit);
|
||||
$health->refresh($collection, $actor, 'duplicate-scan');
|
||||
}
|
||||
}
|
||||
65
app/Jobs/SyncArtworkVectorIndexJob.php
Normal file
65
app/Jobs/SyncArtworkVectorIndexJob.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Vision\VectorService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class SyncArtworkVectorIndexJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(private readonly int $artworkId)
|
||||
{
|
||||
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(VectorService $vectors): void
|
||||
{
|
||||
if (! $vectors->isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->find($this->artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vectors->upsertArtwork($artwork);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SyncArtworkVectorIndexJob failed', [
|
||||
'artwork_id' => $this->artworkId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Jobs/UpdateNovaCardStatsJob.php
Normal file
30
app/Jobs/UpdateNovaCardStatsJob.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardTrendingService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UpdateNovaCardStatsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(private readonly int $cardId) {}
|
||||
|
||||
public function handle(NovaCardTrendingService $trending): void
|
||||
{
|
||||
$card = NovaCard::query()->find($this->cardId);
|
||||
if (! $card) {
|
||||
return;
|
||||
}
|
||||
|
||||
$trending->refreshCard($card);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user