feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\RankList;
use App\Services\RankingService;
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\DB;
use Illuminate\Support\Facades\Log;
/**
* RankBuildListsJob
*
* Runs hourly (after RankComputeArtworkScoresJob).
*
* Builds ordered artwork_id arrays for:
* global trending, new_hot, best
* each category trending, new_hot, best
* each content_type trending, new_hot, best
*
* Applies author-diversity cap (max 3 per author in a list of 50).
* Stores results in rank_lists and busts relevant Redis keys.
*/
class RankBuildListsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800;
public int $tries = 2;
private const LIST_TYPES = ['trending', 'new_hot', 'best'];
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
$listSize = (int) config('ranking.diversity.list_size', 50);
$candidatePool = (int) config('ranking.diversity.candidate_pool', 200);
$listsBuilt = 0;
// ── 1. Global ──────────────────────────────────────────────────────
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'global', 0,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
// ── 2. Per category ────────────────────────────────────────────────
Category::query()
->select(['id'])
->where('is_active', true)
->orderBy('id')
->chunk(200, function ($categories) use (
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
): void {
foreach ($categories as $cat) {
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'category', $cat->id,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
}
});
// ── 3. Per content type ────────────────────────────────────────────
ContentType::query()
->select(['id'])
->orderBy('id')
->chunk(50, function ($ctypes) use (
$ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt
): void {
foreach ($ctypes as $ct) {
foreach (self::LIST_TYPES as $listType) {
$this->buildAndStore(
$ranking, $listType, 'content_type', $ct->id,
$modelVersion, $maxPerAuthor, $listSize, $candidatePool
);
$listsBuilt++;
}
}
});
Log::info('RankBuildListsJob: finished', [
'lists_built' => $listsBuilt,
'model_version' => $modelVersion,
]);
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Fetch candidates, apply diversity, and upsert the resulting list.
*/
private function buildAndStore(
RankingService $ranking,
string $listType,
string $scopeType,
int $scopeId,
string $modelVersion,
int $maxPerAuthor,
int $listSize,
int $candidatePool
): void {
$scoreCol = $this->scoreColumn($listType);
$candidates = $this->fetchCandidates(
$scopeType, $scopeId, $scoreCol, $candidatePool, $modelVersion
);
$diverse = $ranking->applyDiversity(
$candidates->all(), $maxPerAuthor, $listSize
);
$ids = array_map(
fn ($item) => (int) ($item->artwork_id ?? $item['artwork_id']),
$diverse
);
// Upsert the list (unique: scope_type + scope_id + list_type + model_version)
DB::table('rank_lists')->upsert(
[[
'scope_type' => $scopeType,
'scope_id' => $scopeId,
'list_type' => $listType,
'model_version' => $modelVersion,
'artwork_ids' => json_encode($ids),
'computed_at' => now()->toDateTimeString(),
]],
['scope_type', 'scope_id', 'list_type', 'model_version'],
['artwork_ids', 'computed_at']
);
// Bust Redis cache so next request picks up the new list
$ranking->bustCache($scopeType, $scopeId === 0 ? null : $scopeId, $listType);
}
/**
* Fetch top N candidates (with user_id) for a given scope/score column.
*
* @return \Illuminate\Support\Collection<int, object>
*/
private function fetchCandidates(
string $scopeType,
int $scopeId,
string $scoreCol,
int $limit,
string $modelVersion
): \Illuminate\Support\Collection {
$query = DB::table('rank_artwork_scores as ras')
->select(['ras.artwork_id', 'a.user_id', "ras.{$scoreCol}"])
->join('artworks as a', function ($join): void {
$join->on('a.id', '=', 'ras.artwork_id')
->where('a.is_public', 1)
->where('a.is_approved', 1)
->whereNull('a.deleted_at');
})
->where('ras.model_version', $modelVersion)
->orderByDesc("ras.{$scoreCol}")
->limit($limit);
if ($scopeType === 'category' && $scopeId > 0) {
$query->join(
'artwork_category as ac',
fn ($j) => $j->on('ac.artwork_id', '=', 'a.id')
->where('ac.category_id', $scopeId)
);
}
if ($scopeType === 'content_type' && $scopeId > 0) {
$query->join(
'artwork_category as ac',
'ac.artwork_id', '=', 'a.id'
)->join(
'categories as cat',
fn ($j) => $j->on('cat.id', '=', 'ac.category_id')
->where('cat.content_type_id', $scopeId)
->whereNull('cat.deleted_at')
);
}
return $query->get();
}
/**
* Map list_type to the rank_artwork_scores column name.
*/
private function scoreColumn(string $listType): string
{
return match ($listType) {
'new_hot' => 'score_new_hot',
'best' => 'score_best',
default => 'score_trending',
};
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\RankingService;
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\DB;
use Illuminate\Support\Facades\Log;
/**
* RankComputeArtworkScoresJob
*
* Runs hourly. Queries raw artwork signals (views, favourites, downloads,
* age, tags) in batches, computes the three ranking scores using
* RankingService::computeScores(), and bulk-upserts the results into
* rank_artwork_scores.
*
* No N+1: all signals are resolved via a single pre-aggregated JOIN query,
* chunked by artwork id.
*/
class RankComputeArtworkScoresJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 min max
public int $tries = 2;
private const CHUNK_SIZE = 500;
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$total = 0;
$now = now()->toDateTimeString();
$ranking->artworkSignalsQuery()
->orderBy('a.id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ranking, $modelVersion, $now, &$total): void {
$rows = collect($rows);
if ($rows->isEmpty()) {
return;
}
$upserts = $rows->map(function ($row) use ($ranking, $modelVersion, $now): array {
$scores = $ranking->computeScores($row);
return [
'artwork_id' => (int) $row->id,
'score_trending' => $scores['score_trending'],
'score_new_hot' => $scores['score_new_hot'],
'score_best' => $scores['score_best'],
'model_version' => $modelVersion,
'computed_at' => $now,
];
})->all();
DB::table('rank_artwork_scores')->upsert(
$upserts,
['artwork_id'], // unique key
['score_trending', 'score_new_hot', 'score_best', // update these
'model_version', 'computed_at']
);
$total += count($upserts);
});
Log::info('RankComputeArtworkScoresJob: finished', [
'total_updated' => $total,
'model_version' => $modelVersion,
]);
}
}