feat(trending): switch trending endpoints to Ranking V2 ranking_score\n\n- discoverTrending() now sorts by ranking_score:desc + engagement_velocity:desc\n- HomepageService::getTrending() now sorts by ranking_score:desc + velocity\n- DB fallback joins artwork_stats for ranking_score sort\n- Both trending endpoints filter to last 30 days (spec §6)\n- Add created_at to Meilisearch filterableAttributes for date filtering\n- Synced index settings"
This commit is contained in:
@@ -249,17 +249,21 @@ final class ArtworkSearchService
|
|||||||
// ── Discover section helpers ───────────────────────────────────────────────
|
// ── Discover section helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min).
|
* Trending: sorted by Ranking Engine V2 `ranking_score` (recalculated every 30 min).
|
||||||
* Falls back to views:desc if the column is not yet populated.
|
*
|
||||||
|
* Spec §6: Uses ranking_score, limits to last 30 days,
|
||||||
|
* highlights high-velocity artworks via engagement_velocity tiebreaker.
|
||||||
*/
|
*/
|
||||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$page = (int) request()->get('page', 1);
|
$page = (int) request()->get('page', 1);
|
||||||
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
$cutoff = now()->subDays(30)->toDateString();
|
||||||
|
|
||||||
|
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage, $cutoff) {
|
||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'],
|
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||||||
])
|
])
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,20 +133,22 @@ final class HomepageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
|
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||||
*
|
*
|
||||||
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
|
* Uses Meilisearch sorted by the V2 score (updated every 30 min).
|
||||||
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
|
* Falls back to DB ORDER BY ranking_score if Meilisearch is unavailable.
|
||||||
* Spec: no heavy joins in the hot path.
|
* Spec §6: ranking_score, last 30 days, highlight high-velocity artworks.
|
||||||
*/
|
*/
|
||||||
public function getTrending(int $limit = 10): array
|
public function getTrending(int $limit = 10): array
|
||||||
{
|
{
|
||||||
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
$cutoff = now()->subDays(30)->toDateString();
|
||||||
|
|
||||||
|
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
|
||||||
try {
|
try {
|
||||||
$results = Artwork::search('')
|
$results = Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => 'is_public = true AND is_approved = true',
|
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||||||
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
|
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||||||
])
|
])
|
||||||
->paginate($limit, 'page', 1);
|
->paginate($limit, 'page', 1);
|
||||||
|
|
||||||
@@ -172,15 +174,18 @@ final class HomepageService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DB-only fallback for trending (Meilisearch unavailable).
|
* DB-only fallback for trending (Meilisearch unavailable).
|
||||||
* Uses pre-computed trending_score_7d column — no correlated subqueries.
|
* Joins artwork_stats to sort by V2 ranking_score.
|
||||||
*/
|
*/
|
||||||
private function getTrendingFromDb(int $limit): array
|
private function getTrendingFromDb(int $limit): array
|
||||||
{
|
{
|
||||||
return Artwork::public()
|
return Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||||
->orderByDesc('trending_score_7d')
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->orderByDesc('trending_score_24h')
|
->select('artworks.*')
|
||||||
|
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||||
|
->orderByDesc('artwork_stats.ranking_score')
|
||||||
|
->orderByDesc('artwork_stats.engagement_velocity')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ return [
|
|||||||
'author_id',
|
'author_id',
|
||||||
'is_public',
|
'is_public',
|
||||||
'is_approved',
|
'is_approved',
|
||||||
|
'created_at',
|
||||||
],
|
],
|
||||||
'sortableAttributes' => [
|
'sortableAttributes' => [
|
||||||
'created_at',
|
'created_at',
|
||||||
|
|||||||
Reference in New Issue
Block a user