user(); $ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60); $cacheKey = "creator_suggestions:{$user->id}"; $data = Cache::remember($cacheKey, $ttl, function () use ($user) { return $this->buildSuggestions($user); }); return response()->json(['data' => $data]); } private function buildSuggestions(\App\Models\User $user): array { try { $profile = $this->prefBuilder->build($user); $followingIds = $profile->strongCreatorIds; $topTagSlugs = array_slice($profile->topTagSlugs, 0, 10); // ── 1. Mutual-follow candidates ─────────────────────────────────── $mutualCandidates = []; if ($followingIds !== []) { $rows = 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', '!=', $user->id) ->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id])) ->where('u.is_active', true) ->selectRaw(' u.id, u.name, u.username, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.artworks_count, 0) as artworks_count, COUNT(*) as mutual_weight ') ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') ->orderByDesc('mutual_weight') ->limit(20) ->get(); foreach ($rows as $row) { $mutualCandidates[(int) $row->id] = [ 'id' => (int) $row->id, 'name' => $row->name, 'username' => $row->username, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'artworks_count' => (int) $row->artworks_count, 'score' => (float) $row->mutual_weight * 3.0, 'reason' => 'Popular among creators you follow', ]; } } // ── 2. Tag-affinity candidates ──────────────────────────────────── $tagCandidates = []; if ($topTagSlugs !== []) { $tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?')); $rows = DB::table('tags as t') ->join('artwork_tag as at', 'at.tag_id', '=', 't.id') ->join('artworks as a', 'a.id', '=', 'at.artwork_id') ->join('users as u', 'u.id', '=', 'a.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') ->whereIn('t.slug', $topTagSlugs) ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNull('a.deleted_at') ->where('u.id', '!=', $user->id) ->whereNotIn('u.id', array_merge($followingIds, [$user->id])) ->where('u.is_active', true) ->selectRaw(' u.id, u.name, u.username, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.artworks_count, 0) as artworks_count, COUNT(DISTINCT t.id) as matched_tags ') ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') ->orderByDesc('matched_tags') ->limit(20) ->get(); foreach ($rows as $row) { if (isset($mutualCandidates[(int) $row->id])) { // Boost mutual candidate that also matches tags $mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags; continue; } $tagCandidates[(int) $row->id] = [ 'id' => (int) $row->id, 'name' => $row->name, 'username' => $row->username, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'artworks_count' => (int) $row->artworks_count, 'score' => (float) $row->matched_tags * 2.0, 'reason' => 'Matches your interests', ]; } } // ── 3. Merge & rank ─────────────────────────────────────────────── $combined = array_values(array_merge($mutualCandidates, $tagCandidates)); usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']); $top = array_slice($combined, 0, self::LIMIT); if (count($top) < self::LIMIT) { $topIds = array_column($top, 'id'); $excluded = array_unique(array_merge($followingIds, [$user->id], $topIds)); $top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top))); } return array_map(fn (array $c): array => [ 'id' => $c['id'], 'name' => $c['name'], 'username' => $c['username'], 'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'], 'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64), 'followers_count' => (int) ($c['followers_count'] ?? 0), 'artworks_count' => (int) ($c['artworks_count'] ?? 0), 'reason' => $c['reason'] ?? null, ], $top); } catch (\Throwable $e) { Log::warning('SuggestedCreatorsController: failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); return []; } } /** * @param array $excludedIds * @return array> */ private function highQualityFallback(array $excludedIds, int $limit): array { if ($limit <= 0) { return []; } $rows = DB::table('users as u') ->join('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') ->whereNotIn('u.id', $excludedIds) ->where('u.is_active', true) ->selectRaw(' u.id, u.name, u.username, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.artworks_count, 0) as artworks_count ') ->orderByDesc('followers_count') ->limit($limit) ->get(); return $rows->map(fn ($r) => [ 'id' => (int) $r->id, 'name' => $r->name, 'username' => $r->username, 'avatar_hash' => $r->avatar_hash, 'followers_count' => (int) $r->followers_count, 'artworks_count' => (int) $r->artworks_count, 'score' => (float) $r->followers_count * 0.1, 'reason' => 'Popular creator', ])->all(); } }