244 lines
11 KiB
PHP
244 lines
11 KiB
PHP
<?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();
|
|
}
|
|
}
|