optimizations
This commit is contained in:
@@ -9,9 +9,11 @@ use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\Recommendation\RecommendationService;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Models\Collection as CollectionModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -27,13 +29,22 @@ use Illuminate\Database\QueryException;
|
||||
final class HomepageService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
private const ARTWORK_SERIALIZATION_RELATIONS = [
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,sort_order',
|
||||
'categories.contentType:id,name,slug',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
private readonly RecommendationService $reco,
|
||||
private readonly RecommendationFeedResolver $feedResolver,
|
||||
private readonly GridFiller $gridFiller,
|
||||
private readonly CollectionDiscoveryService $collectionDiscovery,
|
||||
private readonly CollectionService $collectionService,
|
||||
private readonly CollectionSurfaceService $collectionSurfaces,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -50,6 +61,10 @@ final class HomepageService
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'collections_featured' => $this->getFeaturedCollections(),
|
||||
'collections_trending' => $this->getTrendingCollections(),
|
||||
'collections_editorial' => $this->getEditorialCollections(),
|
||||
'collections_community' => $this->getCommunityCollections(),
|
||||
'tags' => $this->getPopularTags(),
|
||||
'creators' => $this->getCreatorSpotlight(),
|
||||
'news' => $this->getNews(),
|
||||
@@ -61,12 +76,12 @@ final class HomepageService
|
||||
*
|
||||
* Sections:
|
||||
* 1. user_data – welcome row counts (messages, notifications, new followers)
|
||||
* 2. from_following – artworks from creators you follow
|
||||
* 3. trending – same trending feed as guests
|
||||
* 4. by_tags – artworks matching user's top tags (Trending For You)
|
||||
* 5. by_categories – fresh uploads in user's favourite categories
|
||||
* 6. suggested_creators – creators the user might want to follow
|
||||
* 7. tags / creators / news – shared with guest homepage
|
||||
* 2. from_following – artworks from creators you follow
|
||||
* 3. for_you – personalized recommendation preview
|
||||
* 4. trending – same trending feed as guests
|
||||
* 5. by_categories – fresh uploads in user's favourite categories
|
||||
* 6. suggested_creators – creators the user might want to follow
|
||||
* 7. tags / creators / news – shared with guest homepage
|
||||
*/
|
||||
public function allForUser(\App\Models\User $user): array
|
||||
{
|
||||
@@ -81,6 +96,11 @@ final class HomepageService
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'collections_featured' => $this->getFeaturedCollections(),
|
||||
'collections_recent' => $this->getRecentCollections(),
|
||||
'collections_trending' => $this->getTrendingCollections(),
|
||||
'collections_editorial' => $this->getEditorialCollections(),
|
||||
'collections_community' => $this->getCommunityCollections(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
|
||||
@@ -95,21 +115,127 @@ final class HomepageService
|
||||
}
|
||||
|
||||
/**
|
||||
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
|
||||
*
|
||||
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
|
||||
* diversity controls, and its own Redis cache layer.
|
||||
* "For You" homepage preview backed by the personalized feed engine.
|
||||
*/
|
||||
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
||||
{
|
||||
try {
|
||||
return $this->reco->forYouPreview($user, $limit);
|
||||
$feed = $this->feedResolver->getFeed((int) $user->id, $limit);
|
||||
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
|
||||
$discoveryEndpoint = route('api.discovery.events.store');
|
||||
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
|
||||
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
|
||||
|
||||
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
|
||||
$reason = (string) ($item['reason'] ?? 'Picked for you');
|
||||
|
||||
return [
|
||||
'id' => (int) ($item['id'] ?? 0),
|
||||
'title' => (string) ($item['title'] ?? 'Untitled'),
|
||||
'name' => (string) ($item['title'] ?? 'Untitled'),
|
||||
'slug' => (string) ($item['slug'] ?? ''),
|
||||
'author' => (string) ($item['author'] ?? 'Artist'),
|
||||
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
|
||||
'author_username' => (string) ($item['username'] ?? ''),
|
||||
'author_avatar' => $item['avatar_url'] ?? null,
|
||||
'avatar_url' => $item['avatar_url'] ?? null,
|
||||
'thumb' => $item['thumbnail_url'] ?? null,
|
||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
|
||||
'category_name' => (string) ($item['category_name'] ?? ''),
|
||||
'category_slug' => (string) ($item['category_slug'] ?? ''),
|
||||
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
|
||||
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
|
||||
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
|
||||
'width' => isset($item['width']) ? (int) $item['width'] : null,
|
||||
'height' => isset($item['height']) ? (int) $item['height'] : null,
|
||||
'published_at' => $item['published_at'] ?? null,
|
||||
'primary_tag' => $item['primary_tag'] ?? null,
|
||||
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
|
||||
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
|
||||
'recommendation_reason' => $reason,
|
||||
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
|
||||
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
|
||||
'recommendation_surface' => 'homepage-for-you',
|
||||
'discovery_endpoint' => $discoveryEndpoint,
|
||||
'hide_artwork_endpoint' => $hideArtworkEndpoint,
|
||||
'dislike_tag_endpoint' => $dislikeTagEndpoint,
|
||||
'metric_badge' => [
|
||||
'label' => $reason,
|
||||
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
|
||||
],
|
||||
];
|
||||
})->values()->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getTrendingCollections(int $limit = 6): array
|
||||
{
|
||||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);
|
||||
|
||||
if ($surfaceCollections->isNotEmpty()) {
|
||||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||||
}
|
||||
|
||||
return $this->collectionService->mapCollectionCardPayloads(
|
||||
$this->collectionDiscovery->publicTrendingCollections($limit),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public function getEditorialCollections(int $limit = 6): array
|
||||
{
|
||||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);
|
||||
|
||||
if ($surfaceCollections->isNotEmpty()) {
|
||||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||||
}
|
||||
|
||||
return $this->collectionService->mapCollectionCardPayloads(
|
||||
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public function getCommunityCollections(int $limit = 6): array
|
||||
{
|
||||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);
|
||||
|
||||
if ($surfaceCollections->isNotEmpty()) {
|
||||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||||
}
|
||||
|
||||
return $this->collectionService->mapCollectionCardPayloads(
|
||||
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public function getFeaturedCollections(int $limit = 6): array
|
||||
{
|
||||
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);
|
||||
|
||||
if ($surfaceCollections->isNotEmpty()) {
|
||||
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
|
||||
}
|
||||
|
||||
return $this->collectionService->mapCollectionCardPayloads(
|
||||
$this->collectionDiscovery->publicFeaturedCollections($limit),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
public function getRecentCollections(int $limit = 6): array
|
||||
{
|
||||
return $this->collectionService->mapCollectionCardPayloads(
|
||||
$this->collectionDiscovery->publicRecentCollections($limit),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sections
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -133,6 +259,10 @@ final class HomepageService
|
||||
$artwork = null;
|
||||
}
|
||||
|
||||
if ($artwork instanceof Artwork) {
|
||||
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
||||
}
|
||||
|
||||
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
|
||||
});
|
||||
}
|
||||
@@ -156,13 +286,13 @@ final class HomepageService
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
if ($items->isEmpty()) {
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
@@ -183,7 +313,7 @@ final class HomepageService
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
@@ -216,13 +346,13 @@ final class HomepageService
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
if ($items->isEmpty()) {
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
@@ -244,7 +374,7 @@ final class HomepageService
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
@@ -270,7 +400,7 @@ final class HomepageService
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
|
||||
$artworks = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
@@ -523,7 +653,7 @@ final class HomepageService
|
||||
function () use ($followingIds): array {
|
||||
$artworks = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->limit(10)
|
||||
@@ -535,7 +665,7 @@ final class HomepageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching the user's top tags (max 12).
|
||||
* Fresh artworks matching the user's favourite tags (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByTags(array $tagSlugs): array
|
||||
@@ -546,8 +676,9 @@ final class HomepageService
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
||||
$items = $this->searchResultCollection($results);
|
||||
|
||||
return $results->getCollection()
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
@@ -569,8 +700,9 @@ final class HomepageService
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
||||
$items = $this->searchResultCollection($results);
|
||||
|
||||
return $results->getCollection()
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
@@ -584,6 +716,42 @@ final class HomepageService
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function searchResultCollection(mixed $results): Collection
|
||||
{
|
||||
if ($results instanceof Collection) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
if (is_object($results) && method_exists($results, 'getCollection')) {
|
||||
$collection = $results->getCollection();
|
||||
if ($collection instanceof Collection) {
|
||||
return $collection;
|
||||
}
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure serialized artwork payloads do not trigger lazy-loading per item.
|
||||
*
|
||||
* @param Collection<int, Artwork> $artworks
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function prepareArtworksForSerialization(Collection $artworks): Collection
|
||||
{
|
||||
if ($artworks->isEmpty()) {
|
||||
return $artworks;
|
||||
}
|
||||
|
||||
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
|
||||
|
||||
return $artworks;
|
||||
}
|
||||
|
||||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||||
{
|
||||
$thumbMd = $artwork->thumbUrl('md');
|
||||
|
||||
Reference in New Issue
Block a user