Files
SkinbaseNova/app/Http/Controllers/Api/SuggestedCreatorsController.php

218 lines
9.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* GET /api/user/suggestions/creators
*
* Returns up to 12 creators the authenticated user might want to follow.
*
* Ranking algorithm (Phase 1 no embeddings):
* 1. Creators followed by people you follow (mutual-follow signal)
* 2. Creators whose recent works overlap your top tags
* 3. High-quality creators (followers_count / artworks_count) in your categories
*
* Exclusions: yourself, already-followed creators.
*
* Cached per user for config('recommendations.ttl.creator_suggestions') seconds (default 30 min).
*/
final class SuggestedCreatorsController extends Controller
{
private const LIMIT = 12;
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->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.uploads_count, 0) as artworks_count,
COUNT(*) as mutual_weight
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_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.uploads_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.uploads_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<int, int> $excludedIds
* @return array<int, array<string, mixed>>
*/
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.uploads_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();
}
}