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 */ 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', }; } }