optimizations
This commit is contained in:
243
app/Services/CollectionRecommendationService.php
Normal file
243
app/Services/CollectionRecommendationService.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CollectionRecommendationService
|
||||
{
|
||||
public function recommendedForUser(?User $user, int $limit = 12): EloquentCollection
|
||||
{
|
||||
$safeLimit = max(1, min($limit, 18));
|
||||
|
||||
if (! $user) {
|
||||
return $this->fallbackPublicCollections($safeLimit);
|
||||
}
|
||||
|
||||
$seedIds = collect()
|
||||
->merge(DB::table('collection_saves')->where('user_id', $user->id)->pluck('collection_id'))
|
||||
->merge(DB::table('collection_likes')->where('user_id', $user->id)->pluck('collection_id'))
|
||||
->merge(DB::table('collection_follows')->where('user_id', $user->id)->pluck('collection_id'))
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$followedCreatorIds = DB::table('user_followers')
|
||||
->where('follower_id', $user->id)
|
||||
->pluck('user_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$seedCollections = $seedIds->isEmpty()
|
||||
? collect()
|
||||
: Collection::query()
|
||||
->publicEligible()
|
||||
->whereIn('id', $seedIds->all())
|
||||
->get(['id', 'type', 'event_key', 'campaign_key', 'season_key', 'user_id']);
|
||||
|
||||
$candidateQuery = Collection::query()
|
||||
->publicEligible()
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
])
|
||||
->when($seedIds->isNotEmpty(), fn ($query) => $query->whereNotIn('id', $seedIds->all()))
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('followers_count')
|
||||
->orderByDesc('saves_count')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(max(24, $safeLimit * 4));
|
||||
|
||||
if ($seedCollections->isEmpty() && $followedCreatorIds->isNotEmpty()) {
|
||||
$candidateQuery->whereIn('user_id', $followedCreatorIds->all());
|
||||
}
|
||||
|
||||
$candidates = $candidateQuery->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return $this->fallbackPublicCollections($safeLimit);
|
||||
}
|
||||
|
||||
$candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$creatorMap = $this->creatorMap($candidateIds);
|
||||
$tagMap = $this->tagMap($candidateIds);
|
||||
$seedTypes = $seedCollections->pluck('type')->filter()->unique()->values()->all();
|
||||
$seedCampaigns = $seedCollections->pluck('campaign_key')->filter()->unique()->values()->all();
|
||||
$seedEvents = $seedCollections->pluck('event_key')->filter()->unique()->values()->all();
|
||||
$seedSeasons = $seedCollections->pluck('season_key')->filter()->unique()->values()->all();
|
||||
$seedCreatorIds = $seedIds->isEmpty()
|
||||
? []
|
||||
: collect($this->creatorMap($seedIds->all()))
|
||||
->flatten()
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$seedTagSlugs = $seedIds->isEmpty()
|
||||
? []
|
||||
: $seedCollections
|
||||
->map(fn (Collection $collection) => $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? []))
|
||||
->flatten()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return new EloquentCollection($candidates
|
||||
->map(function (Collection $candidate) use ($safeLimit, $seedTypes, $seedCampaigns, $seedEvents, $seedSeasons, $seedCreatorIds, $seedTagSlugs, $followedCreatorIds, $creatorMap, $tagMap): array {
|
||||
$candidateCreators = $creatorMap[(int) $candidate->id] ?? [];
|
||||
$candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []);
|
||||
|
||||
$score = 0;
|
||||
$score += in_array($candidate->type, $seedTypes, true) ? 5 : 0;
|
||||
$score += ($candidate->campaign_key && in_array($candidate->campaign_key, $seedCampaigns, true)) ? 4 : 0;
|
||||
$score += ($candidate->event_key && in_array($candidate->event_key, $seedEvents, true)) ? 4 : 0;
|
||||
$score += ($candidate->season_key && in_array($candidate->season_key, $seedSeasons, true)) ? 3 : 0;
|
||||
$score += in_array((int) $candidate->user_id, $followedCreatorIds->all(), true) ? 6 : 0;
|
||||
$score += count(array_intersect($seedCreatorIds, $candidateCreators)) * 2;
|
||||
$score += count(array_intersect($seedTagSlugs, $candidateTags));
|
||||
$score += $candidate->is_featured ? 2 : 0;
|
||||
$score += min(4, (int) floor(((int) $candidate->followers_count + (int) $candidate->saves_count) / 40));
|
||||
$score += min(3, (int) floor((float) $candidate->ranking_score / 25));
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'collection' => $candidate,
|
||||
];
|
||||
})
|
||||
->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0))
|
||||
->take($safeLimit)
|
||||
->pluck('collection')
|
||||
->values()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function relatedPublicCollections(Collection $collection, int $limit = 6): EloquentCollection
|
||||
{
|
||||
$safeLimit = max(1, min($limit, 12));
|
||||
$candidates = Collection::query()
|
||||
->publicEligible()
|
||||
->where('id', '!=', $collection->id)
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
])
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('followers_count')
|
||||
->orderByDesc('saves_count')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(30)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
$candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$creatorMap = $this->creatorMap($candidateIds);
|
||||
$tagMap = $this->tagMap($candidateIds);
|
||||
$currentCreatorIds = $this->creatorMap([(int) $collection->id])[(int) $collection->id] ?? [];
|
||||
$currentTagSlugs = $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? []);
|
||||
|
||||
return new EloquentCollection($candidates
|
||||
->map(function (Collection $candidate) use ($collection, $creatorMap, $tagMap, $currentCreatorIds, $currentTagSlugs): array {
|
||||
$candidateCreators = $creatorMap[(int) $candidate->id] ?? [];
|
||||
$candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []);
|
||||
|
||||
$score = 0;
|
||||
$score += $candidate->type === $collection->type ? 4 : 0;
|
||||
$score += (int) $candidate->user_id === (int) $collection->user_id ? 3 : 0;
|
||||
$score += ($collection->event_key && $candidate->event_key === $collection->event_key) ? 4 : 0;
|
||||
$score += $candidate->is_featured ? 1 : 0;
|
||||
$score += count(array_intersect($currentCreatorIds, $candidateCreators)) * 2;
|
||||
$score += count(array_intersect($currentTagSlugs, $candidateTags));
|
||||
$score += min(2, (int) floor(((int) $candidate->saves_count + (int) $candidate->followers_count) / 25));
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'collection' => $candidate,
|
||||
];
|
||||
})
|
||||
->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0))
|
||||
->take($safeLimit)
|
||||
->pluck('collection')
|
||||
->values()
|
||||
->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $collectionIds
|
||||
* @return array<int, array<int, int>>
|
||||
*/
|
||||
private function creatorMap(array $collectionIds): array
|
||||
{
|
||||
return DB::table('collection_artwork as ca')
|
||||
->join('artworks as a', 'a.id', '=', 'ca.artwork_id')
|
||||
->whereIn('ca.collection_id', $collectionIds)
|
||||
->whereNull('a.deleted_at')
|
||||
->select('ca.collection_id', 'a.user_id')
|
||||
->get()
|
||||
->groupBy('collection_id')
|
||||
->map(fn ($rows) => collect($rows)->pluck('user_id')->map(static fn ($id) => (int) $id)->unique()->values()->all())
|
||||
->mapWithKeys(fn ($value, $key) => [(int) $key => $value])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $collectionIds
|
||||
* @return array<int, array<int, string>>
|
||||
*/
|
||||
private function tagMap(array $collectionIds): array
|
||||
{
|
||||
return DB::table('collection_artwork as ca')
|
||||
->join('artwork_tag as at', 'at.artwork_id', '=', 'ca.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->whereIn('ca.collection_id', $collectionIds)
|
||||
->select('ca.collection_id', 't.slug')
|
||||
->get()
|
||||
->groupBy('collection_id')
|
||||
->map(fn ($rows) => collect($rows)->pluck('slug')->map(static fn ($slug) => (string) $slug)->unique()->take(10)->values()->all())
|
||||
->mapWithKeys(fn ($value, $key) => [(int) $key => $value])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tagSlugs
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function signalTagSlugs(Collection $collection, array $tagSlugs): array
|
||||
{
|
||||
if (! $collection->isSmart() || ! is_array($collection->smart_rules_json)) {
|
||||
return $tagSlugs;
|
||||
}
|
||||
|
||||
$ruleTags = collect($collection->smart_rules_json['rules'] ?? [])
|
||||
->map(fn ($rule) => is_array($rule) ? ($rule['value'] ?? null) : null)
|
||||
->filter(fn ($value) => is_string($value) && $value !== '')
|
||||
->map(fn (string $value) => strtolower(trim($value)))
|
||||
->take(10)
|
||||
->all();
|
||||
|
||||
return array_values(array_unique(array_merge($tagSlugs, $ruleTags)));
|
||||
}
|
||||
|
||||
private function fallbackPublicCollections(int $limit): EloquentCollection
|
||||
{
|
||||
return Collection::query()
|
||||
->publicEligible()
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
])
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('followers_count')
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user