274 lines
12 KiB
PHP
274 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Services\Recommendation\UserPreferenceBuilder;
|
|
use App\Support\AvatarUrl;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
final class UserSuggestionService
|
|
{
|
|
public function __construct(
|
|
private readonly UserPreferenceBuilder $preferenceBuilder,
|
|
private readonly FollowService $followService,
|
|
) {
|
|
}
|
|
|
|
public function suggestFor(User $viewer, int $limit = 8): array
|
|
{
|
|
$resolvedLimit = max(1, min(24, $limit));
|
|
$cacheKey = sprintf('user_suggestions:v2:%d:%d', (int) $viewer->id, $resolvedLimit);
|
|
|
|
return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($viewer, $resolvedLimit): array {
|
|
try {
|
|
return $this->buildSuggestions($viewer, $resolvedLimit);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('UserSuggestionService failed', [
|
|
'viewer_id' => (int) $viewer->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
private function buildSuggestions(User $viewer, int $limit): array
|
|
{
|
|
$profile = $this->preferenceBuilder->build($viewer);
|
|
$followingIds = DB::table('user_followers')
|
|
->where('follower_id', $viewer->id)
|
|
->pluck('user_id')
|
|
->map(fn ($id) => (int) $id)
|
|
->values()
|
|
->all();
|
|
|
|
$excludedIds = array_values(array_unique(array_merge($followingIds, [(int) $viewer->id])));
|
|
$topTagSlugs = array_slice($profile->topTagSlugs ?? [], 0, 10);
|
|
$topCategoryIds = $this->topCategoryIdsForViewer((int) $viewer->id);
|
|
|
|
$candidates = [];
|
|
|
|
foreach ($this->mutualFollowCandidates($viewer, $followingIds) as $candidate) {
|
|
$candidates[$candidate['id']] = $candidate;
|
|
}
|
|
|
|
foreach ($this->sharedInterestCandidates($viewer, $topTagSlugs, $topCategoryIds) as $candidate) {
|
|
if (isset($candidates[$candidate['id']])) {
|
|
$candidates[$candidate['id']]['score'] += $candidate['score'];
|
|
$candidates[$candidate['id']]['reason'] = $candidates[$candidate['id']]['reason'] . ' · ' . $candidate['reason'];
|
|
continue;
|
|
}
|
|
|
|
$candidates[$candidate['id']] = $candidate;
|
|
}
|
|
|
|
foreach ($this->trendingCreatorCandidates($excludedIds) as $candidate) {
|
|
if (! isset($candidates[$candidate['id']])) {
|
|
$candidates[$candidate['id']] = $candidate;
|
|
}
|
|
}
|
|
|
|
foreach ($this->newActiveCreatorCandidates($excludedIds) as $candidate) {
|
|
if (! isset($candidates[$candidate['id']])) {
|
|
$candidates[$candidate['id']] = $candidate;
|
|
}
|
|
}
|
|
|
|
$ranked = array_values(array_filter(
|
|
$candidates,
|
|
fn (array $candidate): bool => ! in_array((int) $candidate['id'], $excludedIds, true)
|
|
));
|
|
|
|
usort($ranked, fn (array $left, array $right): int => $right['score'] <=> $left['score']);
|
|
|
|
return array_map(function (array $candidate) use ($viewer): array {
|
|
$context = $this->followService->relationshipContext((int) $viewer->id, (int) $candidate['id']);
|
|
|
|
return [
|
|
'id' => (int) $candidate['id'],
|
|
'username' => (string) $candidate['username'],
|
|
'name' => (string) ($candidate['name'] ?? $candidate['username']),
|
|
'profile_url' => '/@' . strtolower((string) $candidate['username']),
|
|
'avatar_url' => AvatarUrl::forUser((int) $candidate['id'], $candidate['avatar_hash'] ?? null, 64),
|
|
'followers_count' => (int) ($candidate['followers_count'] ?? 0),
|
|
'following_count' => (int) ($candidate['following_count'] ?? 0),
|
|
'reason' => (string) ($candidate['reason'] ?? 'Recommended creator'),
|
|
'context' => $context,
|
|
];
|
|
}, array_slice($ranked, 0, $limit));
|
|
}
|
|
|
|
private function mutualFollowCandidates(User $viewer, array $followingIds): array
|
|
{
|
|
if ($followingIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return DB::table('user_followers as uf')
|
|
->join('users as u', 'u.id', '=', 'uf.user_id')
|
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
|
->whereIn('uf.follower_id', $followingIds)
|
|
->where('uf.user_id', '!=', $viewer->id)
|
|
->where('u.is_active', true)
|
|
->whereNull('u.deleted_at')
|
|
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(*) as overlap_count')
|
|
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
|
|
->orderByDesc('overlap_count')
|
|
->limit(20)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'id' => (int) $row->id,
|
|
'username' => $row->username,
|
|
'name' => $row->name,
|
|
'avatar_hash' => $row->avatar_hash,
|
|
'followers_count' => (int) $row->followers_count,
|
|
'following_count' => (int) $row->following_count,
|
|
'score' => (float) $row->overlap_count * 3.0,
|
|
'reason' => 'Popular in your network',
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function sharedInterestCandidates(User $viewer, array $topTagSlugs, array $topCategoryIds): array
|
|
{
|
|
if ($topTagSlugs === [] && $topCategoryIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$query = DB::table('users as u')
|
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
|
->join('artworks as a', 'a.user_id', '=', 'u.id')
|
|
->leftJoin('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
|
|
->leftJoin('tags as t', 't.id', '=', 'at.tag_id')
|
|
->leftJoin('artwork_category as ac', 'ac.artwork_id', '=', 'a.id')
|
|
->where('u.id', '!=', $viewer->id)
|
|
->where('u.is_active', true)
|
|
->whereNull('u.deleted_at')
|
|
->where('a.is_public', true)
|
|
->where('a.is_approved', true)
|
|
->whereNull('a.deleted_at')
|
|
->whereNotNull('a.published_at');
|
|
|
|
$query->where(function ($builder) use ($topTagSlugs, $topCategoryIds): void {
|
|
if ($topTagSlugs !== []) {
|
|
$builder->orWhereIn('t.slug', $topTagSlugs);
|
|
}
|
|
|
|
if ($topCategoryIds !== []) {
|
|
$builder->orWhereIn('ac.category_id', $topCategoryIds);
|
|
}
|
|
});
|
|
|
|
return $query
|
|
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(DISTINCT t.id) as matched_tags, COUNT(DISTINCT ac.category_id) as matched_categories')
|
|
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
|
|
->orderByDesc(DB::raw('COUNT(DISTINCT t.id) + COUNT(DISTINCT ac.category_id)'))
|
|
->limit(20)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'id' => (int) $row->id,
|
|
'username' => $row->username,
|
|
'name' => $row->name,
|
|
'avatar_hash' => $row->avatar_hash,
|
|
'followers_count' => (int) $row->followers_count,
|
|
'following_count' => (int) $row->following_count,
|
|
'score' => ((float) $row->matched_tags * 2.0) + (float) $row->matched_categories,
|
|
'reason' => 'Shared tags and categories',
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function trendingCreatorCandidates(array $excludedIds): array
|
|
{
|
|
return DB::table('users as u')
|
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
|
->join('artworks as a', 'a.user_id', '=', 'u.id')
|
|
->whereNotIn('u.id', $excludedIds)
|
|
->where('u.is_active', true)
|
|
->whereNull('u.deleted_at')
|
|
->where('a.is_public', true)
|
|
->where('a.is_approved', true)
|
|
->whereNull('a.deleted_at')
|
|
->where('a.published_at', '>=', now()->subDays(30))
|
|
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks')
|
|
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
|
|
->orderByDesc('followers_count')
|
|
->orderByDesc('recent_artworks')
|
|
->limit(10)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'id' => (int) $row->id,
|
|
'username' => $row->username,
|
|
'name' => $row->name,
|
|
'avatar_hash' => $row->avatar_hash,
|
|
'followers_count' => (int) $row->followers_count,
|
|
'following_count' => (int) $row->following_count,
|
|
'score' => ((float) $row->followers_count * 0.1) + (float) $row->recent_artworks,
|
|
'reason' => 'Trending creator',
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function newActiveCreatorCandidates(array $excludedIds): array
|
|
{
|
|
return DB::table('users as u')
|
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
|
->join('artworks as a', 'a.user_id', '=', 'u.id')
|
|
->whereNotIn('u.id', $excludedIds)
|
|
->where('u.is_active', true)
|
|
->whereNull('u.deleted_at')
|
|
->where('u.created_at', '>=', now()->subDays(60))
|
|
->where('a.is_public', true)
|
|
->where('a.is_approved', true)
|
|
->whereNull('a.deleted_at')
|
|
->where('a.published_at', '>=', now()->subDays(14))
|
|
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks')
|
|
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
|
|
->orderByDesc('recent_artworks')
|
|
->orderByDesc('followers_count')
|
|
->limit(10)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'id' => (int) $row->id,
|
|
'username' => $row->username,
|
|
'name' => $row->name,
|
|
'avatar_hash' => $row->avatar_hash,
|
|
'followers_count' => (int) $row->followers_count,
|
|
'following_count' => (int) $row->following_count,
|
|
'score' => ((float) $row->recent_artworks * 2.0) + ((float) $row->followers_count * 0.05),
|
|
'reason' => 'New active creator',
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function topCategoryIdsForViewer(int $viewerId): array
|
|
{
|
|
return DB::table('artwork_category as ac')
|
|
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
|
|
->leftJoin('artwork_favourites as af', 'af.artwork_id', '=', 'a.id')
|
|
->where(function ($query) use ($viewerId): void {
|
|
$query
|
|
->where('a.user_id', $viewerId)
|
|
->orWhere('af.user_id', $viewerId);
|
|
})
|
|
->selectRaw('ac.category_id, COUNT(*) as weight')
|
|
->groupBy('ac.category_id')
|
|
->orderByDesc('weight')
|
|
->limit(6)
|
|
->pluck('category_id')
|
|
->map(fn ($id) => (int) $id)
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|