Files
SkinbaseNova/app/Services/CollectionRecommendationService.php
2026-03-28 19:15:39 +01:00

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();
}
}