optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,273 @@
<?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();
}
}