130 lines
4.3 KiB
PHP
130 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\EarlyGrowth;
|
|
|
|
use App\Models\Artwork;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* GridFiller
|
|
*
|
|
* Ensures that browse / discover grids never appear half-empty.
|
|
* When real results fall below the configured minimum, it backfills
|
|
* with real trending artworks from the general pool.
|
|
*
|
|
* Rules (per spec):
|
|
* - Fill only the visible first page — never mix page-number scopes.
|
|
* - Filler is always real content (no fake items).
|
|
* - The original total is not reduced (pagination links stay stable).
|
|
* - Content is not labelled as "filler" in the UI — it is just valid content.
|
|
*/
|
|
final class GridFiller
|
|
{
|
|
/**
|
|
* Ensure a LengthAwarePaginator contains at least $minimum items on page 1.
|
|
* Returns the original paginator unchanged when:
|
|
* - EGS is disabled
|
|
* - Page is > 1
|
|
* - Real result count already meets the minimum
|
|
*/
|
|
public function fill(
|
|
LengthAwarePaginator $results,
|
|
int $minimum = 0,
|
|
int $page = 1,
|
|
): LengthAwarePaginator {
|
|
if (! EarlyGrowth::gridFillerEnabled() || $page > 1) {
|
|
return $results;
|
|
}
|
|
|
|
$minimum = $minimum > 0
|
|
? $minimum
|
|
: (int) config('early_growth.grid_min_results', 12);
|
|
|
|
$items = $results->getCollection();
|
|
$count = $items->count();
|
|
|
|
if ($count >= $minimum) {
|
|
return $results;
|
|
}
|
|
|
|
$needed = $minimum - $count;
|
|
$exclude = $items->pluck('id')->all();
|
|
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
|
|
|
|
$merged = $items
|
|
->concat($filler)
|
|
->unique('id')
|
|
->values();
|
|
|
|
return new LengthAwarePaginator(
|
|
$merged->all(),
|
|
max((int) $results->total(), $merged->count()), // never shrink reported total
|
|
$results->perPage(),
|
|
$page,
|
|
[
|
|
'path' => $results->path(),
|
|
'pageName' => $results->getPageName(),
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fill a plain Collection (for non-paginated grids like homepage sections).
|
|
*/
|
|
public function fillCollection(Collection $items, int $minimum = 0): Collection
|
|
{
|
|
if (! EarlyGrowth::gridFillerEnabled()) {
|
|
return $items;
|
|
}
|
|
|
|
$minimum = $minimum > 0
|
|
? $minimum
|
|
: (int) config('early_growth.grid_min_results', 12);
|
|
|
|
if ($items->count() >= $minimum) {
|
|
return $items;
|
|
}
|
|
|
|
$needed = $minimum - $items->count();
|
|
$exclude = $items->pluck('id')->all();
|
|
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
|
|
|
|
return $items->concat($filler)->unique('id')->values();
|
|
}
|
|
|
|
// ─── Private ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Pull high-ranking artworks as grid filler.
|
|
* Cache key includes an exclude-hash so different grids get distinct content.
|
|
*/
|
|
private function fetchTrendingFiller(int $limit, array $excludeIds): Collection
|
|
{
|
|
$ttl = (int) config('early_growth.cache_ttl.feed_blend', 300);
|
|
$excludeHash = md5(implode(',', array_slice(array_unique($excludeIds), 0, 50)));
|
|
$cacheKey = "egs.grid_filler.{$excludeHash}.{$limit}";
|
|
|
|
return Cache::remember($cacheKey, $ttl, function () use ($limit, $excludeIds): Collection {
|
|
return Artwork::query()
|
|
->public()
|
|
->published()
|
|
->with([
|
|
'user:id,name,username',
|
|
'user.profile:user_id,avatar_hash',
|
|
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
|
])
|
|
->leftJoin('artwork_stats as _gf_stats', '_gf_stats.artwork_id', '=', 'artworks.id')
|
|
->select('artworks.*')
|
|
->when(! empty($excludeIds), fn ($q) => $q->whereNotIn('artworks.id', $excludeIds))
|
|
->orderByDesc('_gf_stats.ranking_score')
|
|
->limit($limit)
|
|
->get()
|
|
->values();
|
|
});
|
|
}
|
|
}
|