storing analytics data
This commit is contained in:
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Drain the Redis artwork-stat delta queue into MySQL.
|
||||
*
|
||||
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
|
||||
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
|
||||
* available. This command drains that queue by applying each delta to the
|
||||
* artwork_stats table via applyDelta().
|
||||
*
|
||||
* Designed to run every 5 minutes so counters stay reasonably fresh while
|
||||
* keeping MySQL write pressure low. If Redis is unavailable the command exits
|
||||
* immediately without error — the service already fell back to direct DB
|
||||
* writes in that case.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:flush-redis-stats
|
||||
* php artisan skinbase:flush-redis-stats --max=500
|
||||
*/
|
||||
class FlushRedisStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
|
||||
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
|
||||
|
||||
public function handle(ArtworkStatsService $service): int
|
||||
{
|
||||
$max = (int) $this->option('max');
|
||||
|
||||
$processed = $service->processPendingFromRedis($max);
|
||||
|
||||
if ($this->getOutput()->isVerbose()) {
|
||||
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Delete artwork_view_events rows older than N days.
|
||||
*
|
||||
* The view event log grows ~proportionally to site traffic. Rows beyond the
|
||||
* retention window are no longer useful for trending (which looks back ≤7
|
||||
* days) or for computing "recently viewed" lists in the UI.
|
||||
*
|
||||
* Default retention is 90 days — long enough for analytics queries and user
|
||||
* history pages, short enough to keep the table from growing unbounded.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:prune-view-events
|
||||
* php artisan skinbase:prune-view-events --days=30
|
||||
*/
|
||||
class PruneViewEventsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
|
||||
protected $description = 'Delete artwork_view_events rows older than N days';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = DB::table('artwork_view_events')
|
||||
->where('viewed_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TrendingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
|
||||
*/
|
||||
class RecalculateTrendingCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:recalculate-trending
|
||||
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
|
||||
{--chunk=1000 : DB chunk size}
|
||||
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||
|
||||
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
|
||||
|
||||
public function __construct(private readonly TrendingService $trending)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$period = (string) $this->option('period');
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$skipIndex = (bool) $this->option('skip-index');
|
||||
|
||||
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
|
||||
|
||||
foreach ($periods as $p) {
|
||||
if (! in_array($p, ['24h', '7d'], true)) {
|
||||
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Recalculating trending ({$p}) …");
|
||||
$start = microtime(true);
|
||||
$updated = $this->trending->recalculate($p, $chunkSize);
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
|
||||
$this->info(" ✓ {$updated} artworks updated in {$elapsed}s");
|
||||
|
||||
if (! $skipIndex) {
|
||||
$this->info(" Dispatching Meilisearch index jobs …");
|
||||
$this->trending->syncToSearchIndex($p);
|
||||
$this->info(" ✓ Index jobs dispatched");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:reset-windowed-stats --period=24h|7d
|
||||
*
|
||||
* Resets / recomputes the sliding-window stats columns in artwork_stats:
|
||||
*
|
||||
* views_24h / views_7d
|
||||
* — Zeroed on each reset because we have no per-view event log.
|
||||
* Artworks re-accumulate from the next view event onward.
|
||||
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
|
||||
*
|
||||
* downloads_24h / downloads_7d
|
||||
* — Recomputed accurately from the artwork_downloads event log.
|
||||
* A single bulk UPDATE with a correlated COUNT() is safe here because
|
||||
* it runs once nightly/weekly, not in the hot path.
|
||||
*
|
||||
* Scheduled in routes/console.php:
|
||||
* --period=24h daily at 03:30
|
||||
* --period=7d weekly (Monday) at 03:30
|
||||
*/
|
||||
class ResetWindowedStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:reset-windowed-stats
|
||||
{--period=24h : Window to reset: 24h or 7d}';
|
||||
|
||||
protected $description = 'Reset windowed view/download counters in artwork_stats';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$period = (string) $this->option('period');
|
||||
|
||||
if (! in_array($period, ['24h', '7d'], true)) {
|
||||
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
|
||||
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
|
||||
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
|
||||
};
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
// ── 1. Zero the views window column ──────────────────────────────────
|
||||
// We have no per-view event log, so we reset the accumulator.
|
||||
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
|
||||
|
||||
// ── 2. Recompute downloads window from the event log ─────────────────
|
||||
// artwork_downloads has created_at, so each row's window is accurate.
|
||||
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
|
||||
// so this command works in both MySQL (production) and SQLite (tests).
|
||||
$downloadsRecomputed = 0;
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->orderBy('artwork_id')
|
||||
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
|
||||
foreach ($rows as $row) {
|
||||
$count = DB::table('artwork_downloads')
|
||||
->where('artwork_id', $row->artwork_id)
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->count();
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $row->artwork_id)
|
||||
->update([$downloadsCol => max(0, $count)]);
|
||||
|
||||
$downloadsRecomputed++;
|
||||
}
|
||||
});
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
|
||||
$this->info("Period: {$period}");
|
||||
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
|
||||
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
|
||||
|
||||
Log::info('ResetWindowedStats complete', [
|
||||
'period' => $period,
|
||||
'views_col' => $viewsCol,
|
||||
'views_rows_reset' => $viewsReset,
|
||||
'downloads_col' => $downloadsCol,
|
||||
'downloads_recomputed' => $downloadsRecomputed,
|
||||
'elapsed_s' => $elapsed,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
@@ -36,6 +37,7 @@ class Kernel extends ConsoleKernel
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,9 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,17 @@ final class ArtworkAwardController extends Controller
|
||||
|
||||
$award = $this->service->award($artwork, $user, $data['medal']);
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_AWARD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
meta: ['medal' => $data['medal']],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(
|
||||
$this->buildPayload($artwork->id, $user->id),
|
||||
201
|
||||
|
||||
@@ -93,6 +93,16 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
|
||||
}
|
||||
|
||||
|
||||
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* POST /api/art/{id}/download
|
||||
*
|
||||
* Records a download event and returns the full-resolution download URL.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Validates the artwork is public and published.
|
||||
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
|
||||
* 3. Increments artwork_stats.downloads + forwards to creator stats.
|
||||
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
|
||||
* trigger the actual browser download.
|
||||
*
|
||||
* The frontend fires this POST on click, then uses the returned URL to
|
||||
* trigger the file download (or falls back to the pre-resolved URL it
|
||||
* already has).
|
||||
*/
|
||||
final class ArtworkDownloadController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id'])
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
// Record the download event — non-blocking, errors are swallowed.
|
||||
$this->recordDownload($request, $artwork);
|
||||
|
||||
// Increment counters — deferred via Redis when available.
|
||||
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
|
||||
|
||||
// Resolve the highest-resolution download URL available.
|
||||
$url = $this->resolveDownloadUrl($artwork);
|
||||
|
||||
return response()->json(['ok' => true, 'url' => $url]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row in artwork_downloads.
|
||||
* Uses a raw insert for the binary(16) IP column.
|
||||
* Silently ignores failures (analytics should never break user flow).
|
||||
*/
|
||||
private function recordDownload(Request $request, Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
$ip = $request->ip() ?? '0.0.0.0';
|
||||
$bin = @inet_pton($ip);
|
||||
|
||||
DB::table('artwork_downloads')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()?->id,
|
||||
'ip' => $bin !== false ? $bin : null,
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Analytics failure must never interrupt the download.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best available download URL: XL → LG → MD.
|
||||
* Returns an empty string if no thumbnail can be resolved.
|
||||
*/
|
||||
private function resolveDownloadUrl(Artwork $artwork): string
|
||||
{
|
||||
foreach (['xl', 'lg', 'md'] as $size) {
|
||||
$thumb = ThumbnailPresenter::present($artwork, $size);
|
||||
if (! empty($thumb['url'])) {
|
||||
return (string) $thumb['url'];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller
|
||||
if ($state) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
|
||||
// Record activity event (new favourite only)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} else {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/art/{id}/view
|
||||
*
|
||||
* Fire-and-forget view tracker.
|
||||
*
|
||||
* Deduplication strategy (layered):
|
||||
* 1. Session key (`art_viewed.{id}`) — prevents double-counts within the
|
||||
* same browser session (survives page reloads).
|
||||
* 2. Route throttle (5 per 10 minutes per IP+artwork) — catches bots that
|
||||
* don't send session cookies.
|
||||
*
|
||||
* The frontend should additionally guard with sessionStorage so it only
|
||||
* calls this endpoint once per page load.
|
||||
*/
|
||||
final class ArtworkViewController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$sessionKey = 'art_viewed.' . $id;
|
||||
|
||||
// Already counted this session — return early without touching the DB.
|
||||
if ($request->hasSession() && $request->session()->has($sessionKey)) {
|
||||
return response()->json(['ok' => true, 'counted' => false]);
|
||||
}
|
||||
|
||||
// Write persistent event log (auth user_id or null for guests).
|
||||
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
|
||||
|
||||
// Defer to Redis when available, fall back to direct DB increment.
|
||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
||||
|
||||
// Mark this session so the artwork is not counted again.
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put($sessionKey, true);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'counted' => true]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller
|
||||
{
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ class MessagingSettingsController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal file
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GET /api/art/{id}/similar
|
||||
*
|
||||
* Returns up to 12 similar artworks based on:
|
||||
* 1. Tag overlap (primary signal)
|
||||
* 2. Same category
|
||||
* 3. Similar orientation
|
||||
*
|
||||
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
||||
* Current artwork and its creator are excluded from results.
|
||||
*/
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
public function __invoke(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->with(['tags:id,slug', 'categories:id,slug'])
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Artwork not found'], 404);
|
||||
}
|
||||
|
||||
$cacheKey = "api.similar.{$artwork->id}";
|
||||
|
||||
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
|
||||
return $this->findSimilar($artwork);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$orientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
'id != ' . $artwork->id,
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Filter by same orientation (landscape/portrait) — improves visual coherence
|
||||
if ($orientation !== 'square') {
|
||||
$filterParts[] = 'orientation = "' . $orientation . '"';
|
||||
}
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
$tagSlugs
|
||||
));
|
||||
$filterParts[] = '(' . $tagFilter . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
// Fallback to category if no tags
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
$categorySlugs
|
||||
));
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate(self::LIMIT);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn (Artwork $a): array => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'thumb' => $a->thumbUrl('md'),
|
||||
'url' => '/art/' . $a->id . '/' . $a->slug,
|
||||
'author_id' => $a->user_id,
|
||||
'orientation' => $this->orientation($a),
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function orientation(Artwork $artwork): string
|
||||
{
|
||||
if (! $artwork->width || ! $artwork->height) {
|
||||
return 'square';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$artwork->width > $artwork->height => 'landscape',
|
||||
$artwork->height > $artwork->width => 'portrait',
|
||||
default => 'square',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,16 @@ final class UploadController extends Controller
|
||||
$artwork->published_at = now();
|
||||
$artwork->save();
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
|
||||
124
app/Http/Controllers/Web/CommunityActivityController.php
Normal file
124
app/Http/Controllers/Web/CommunityActivityController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Community activity feed.
|
||||
*
|
||||
* GET /community/activity?type=global|following
|
||||
*/
|
||||
final class CommunityActivityController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 30;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$type = $request->query('type', 'global'); // global | following
|
||||
$perPage = self::PER_PAGE;
|
||||
|
||||
$query = ActivityEvent::query()
|
||||
->orderByDesc('created_at')
|
||||
->with(['actor:id,name,username']);
|
||||
|
||||
if ($type === 'following' && $user) {
|
||||
// Show only events from followed users
|
||||
$followingIds = DB::table('user_followers')
|
||||
->where('follower_id', $user->id)
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if (empty($followingIds)) {
|
||||
$query->whereRaw('0 = 1'); // empty result set
|
||||
} else {
|
||||
$query->whereIn('actor_id', $followingIds);
|
||||
}
|
||||
}
|
||||
|
||||
$events = $query->paginate($perPage)->withQueryString();
|
||||
$enriched = $this->enrich($events->getCollection());
|
||||
|
||||
return view('web.community.activity', [
|
||||
'events' => $events,
|
||||
'enriched' => $enriched,
|
||||
'active_tab' => $type,
|
||||
'page_title' => 'Community Activity',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach target object data to each event for display.
|
||||
*/
|
||||
private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection
|
||||
{
|
||||
// Collect artwork IDs and user IDs to eager-load
|
||||
$artworkIds = $events
|
||||
->where('target_type', ActivityEvent::TARGET_ARTWORK)
|
||||
->pluck('target_id')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$userIds = $events
|
||||
->where('target_type', ActivityEvent::TARGET_USER)
|
||||
->pluck('target_id')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$artworks = Artwork::whereIn('id', $artworkIds)
|
||||
->with('user:id,name,username')
|
||||
->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext'])
|
||||
->keyBy('id');
|
||||
|
||||
$users = User::whereIn('id', $userIds)
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->get(['id', 'name', 'username'])
|
||||
->keyBy('id');
|
||||
|
||||
return $events->map(function (ActivityEvent $event) use ($artworks, $users): array {
|
||||
$target = null;
|
||||
|
||||
if ($event->target_type === ActivityEvent::TARGET_ARTWORK) {
|
||||
$artwork = $artworks->get($event->target_id);
|
||||
$target = $artwork ? [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('sm'),
|
||||
] : null;
|
||||
} elseif ($event->target_type === ActivityEvent::TARGET_USER) {
|
||||
$u = $users->get($event->target_id);
|
||||
$target = $u ? [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'username' => $u->username,
|
||||
'url' => '/@' . $u->username,
|
||||
] : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'type' => $event->type,
|
||||
'target_type' => $event->target_type,
|
||||
'actor' => [
|
||||
'id' => $event->actor?->id,
|
||||
'name' => $event->actor?->name,
|
||||
'username' => $event->actor?->username,
|
||||
'url' => '/@' . $event->actor?->username,
|
||||
],
|
||||
'target' => $target,
|
||||
'created_at' => $event->created_at?->toIso8601String(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -190,26 +191,56 @@ final class DiscoverController extends Controller
|
||||
->pluck('user_id');
|
||||
|
||||
if ($followingIds->isEmpty()) {
|
||||
$artworks = Artwork::query()->paginate(0);
|
||||
// Trending fallback: show popular artworks so the page isn't blank
|
||||
try {
|
||||
$fallbackResults = $this->searchService->discoverTrending(12);
|
||||
$fallbackArtworks = $fallbackResults->getCollection()
|
||||
->transform(fn ($a) => $this->presentArtwork($a));
|
||||
} catch (\Throwable) {
|
||||
$fallbackArtworks = collect();
|
||||
}
|
||||
|
||||
// Suggested creators: most-followed users the viewer doesn't follow yet
|
||||
$suggestedCreators = DB::table('users')
|
||||
->join('user_statistics', 'users.id', '=', 'user_statistics.user_id')
|
||||
->where('users.id', '!=', $user->id)
|
||||
->whereNotNull('users.email_verified_at')
|
||||
->where('users.is_active', true)
|
||||
->orderByDesc('user_statistics.followers_count')
|
||||
->limit(8)
|
||||
->select(
|
||||
'users.id',
|
||||
'users.name',
|
||||
'users.username',
|
||||
'user_statistics.followers_count',
|
||||
)
|
||||
->get();
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
'empty' => true,
|
||||
'artworks' => collect(),
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
'empty' => true,
|
||||
'fallback_trending' => $fallbackArtworks,
|
||||
'fallback_creators' => $suggestedCreators,
|
||||
]);
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cacheKey = "discover.following.{$user->id}.p{$page}";
|
||||
|
||||
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
});
|
||||
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ final class HomeController extends Controller
|
||||
|
||||
public function index(Request $request): \Illuminate\View\View
|
||||
{
|
||||
$sections = $this->homepage->all();
|
||||
$user = $request->user();
|
||||
$sections = $user
|
||||
? $this->homepage->allForUser($user)
|
||||
: $this->homepage->all();
|
||||
|
||||
$hero = $sections['hero'];
|
||||
|
||||
@@ -27,8 +30,9 @@ final class HomeController extends Controller
|
||||
];
|
||||
|
||||
return view('web.home', [
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
'is_logged_in' => (bool) $user,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Models/ActivityEvent.php
Normal file
93
app/Models/ActivityEvent.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Unified activity feed event.
|
||||
*
|
||||
* Types: upload | comment | favorite | award | follow
|
||||
* target_type: artwork | user
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $actor_id
|
||||
* @property string $type
|
||||
* @property string $target_type
|
||||
* @property int $target_id
|
||||
* @property array|null $meta
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
*/
|
||||
class ActivityEvent extends Model
|
||||
{
|
||||
protected $table = 'activity_events';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'actor_id',
|
||||
'type',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'actor_id' => 'integer',
|
||||
'target_id' => 'integer',
|
||||
'meta' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Event type constants ──────────────────────────────────────────────────
|
||||
|
||||
const TYPE_UPLOAD = 'upload';
|
||||
const TYPE_COMMENT = 'comment';
|
||||
const TYPE_FAVORITE = 'favorite';
|
||||
const TYPE_AWARD = 'award';
|
||||
const TYPE_FOLLOW = 'follow';
|
||||
|
||||
const TARGET_ARTWORK = 'artwork';
|
||||
const TARGET_USER = 'user';
|
||||
|
||||
// ── Relations ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The user who performed the action */
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id');
|
||||
}
|
||||
|
||||
// ── Factory helpers ───────────────────────────────────────────────────────
|
||||
|
||||
public static function record(
|
||||
int $actorId,
|
||||
string $type,
|
||||
string $targetType,
|
||||
int $targetId,
|
||||
array $meta = []
|
||||
): static {
|
||||
$event = static::create([
|
||||
'actor_id' => $actorId,
|
||||
'type' => $type,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'meta' => $meta ?: null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Ensure created_at is available on the returned instance
|
||||
// ($timestamps = false means Eloquent doesn't auto-populate it)
|
||||
if ($event->created_at === null) {
|
||||
$event->created_at = now();
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,12 @@ class Artwork extends Model
|
||||
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
|
||||
'is_public' => (bool) $this->is_public,
|
||||
'is_approved' => (bool) $this->is_approved,
|
||||
// ── Trending / discovery fields ────────────────────────────────────
|
||||
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
|
||||
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
|
||||
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'awards' => [
|
||||
'gold' => $awardStat?->gold_count ?? 0,
|
||||
'silver' => $awardStat?->silver_count ?? 0,
|
||||
|
||||
@@ -175,8 +175,8 @@ final class ArtworkSearchService
|
||||
// ── Discover section helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trending: most viewed artworks, weighted toward recent uploads.
|
||||
* Uses views:desc + recency via created_at:desc as tiebreaker.
|
||||
* Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min).
|
||||
* Falls back to views:desc if the column is not yet populated.
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
@@ -185,7 +185,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'created_at:desc'],
|
||||
'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -239,6 +239,64 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching any of the given tag slugs, sorted by trending score.
|
||||
* Used for personalized "Because you like {tags}" homepage section.
|
||||
*
|
||||
* @param string[] $tagSlugs
|
||||
*/
|
||||
public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
array_slice($tagSlugs, 0, 5)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by created_at desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
*/
|
||||
public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return $this->recent($limit);
|
||||
}
|
||||
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
array_slice($categorySlugs, 0, 3)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
|
||||
@@ -23,26 +23,56 @@ class ArtworkStatsService
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
* Both all-time (views) and windowed (views_24h, views_7d) are updated.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views_24h', $by);
|
||||
$this->pushDelta($artworkId, 'views_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
$this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
* Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_24h', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
$this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one row to artwork_view_events (the persistent event log).
|
||||
*
|
||||
* Called from ArtworkViewController after session dedup passes.
|
||||
* Guests (unauthenticated) are recorded with user_id = null.
|
||||
* Rows are pruned after 90 days by skinbase:prune-view-events.
|
||||
*/
|
||||
public function logViewEvent(int $artworkId, ?int $userId): void
|
||||
{
|
||||
try {
|
||||
DB::table('artwork_view_events')->insert([
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'viewed_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write artwork_view_events row', [
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,17 +105,21 @@ class ArtworkStatsService
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists — insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection.
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,18 @@ final class FollowService
|
||||
$this->incrementCounter($targetId, 'followers_count');
|
||||
});
|
||||
|
||||
// Record activity event outside the transaction to avoid deadlocks
|
||||
if ($inserted) {
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $actorId,
|
||||
type: \App\Models\ActivityEvent::TYPE_FOLLOW,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_USER,
|
||||
targetId: $targetId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,7 +25,11 @@ final class HomepageService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkService $artworks) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public aggregator
|
||||
@@ -44,6 +50,36 @@ final class HomepageService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Personalized homepage data for an authenticated user.
|
||||
*
|
||||
* Sections:
|
||||
* 1. from_following – artworks from creators you follow
|
||||
* 2. trending – same trending feed as guests
|
||||
* 3. by_tags – artworks matching user's top tags
|
||||
* 4. by_categories – fresh uploads in user's favourite categories
|
||||
* 5. tags / creators / news – shared with guest homepage
|
||||
*/
|
||||
public function allForUser(\App\Models\User $user): array
|
||||
{
|
||||
$prefs = $this->prefs->build($user);
|
||||
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'trending' => $this->getTrending(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||
'tags' => $this->getPopularTags(),
|
||||
'creators' => $this->getCreatorSpotlight(),
|
||||
'news' => $this->getNews(),
|
||||
'preferences' => [
|
||||
'top_tags' => $prefs['top_tags'] ?? [],
|
||||
'top_categories' => $prefs['top_categories'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sections
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -72,54 +108,61 @@ final class HomepageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks ordered by award score, views, downloads, recent activity.
|
||||
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
|
||||
*
|
||||
* Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1.
|
||||
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode.
|
||||
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
|
||||
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
|
||||
* Spec: no heavy joins in the hot path.
|
||||
*/
|
||||
public function getTrending(int $limit = 12): array
|
||||
{
|
||||
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||||
$ids = DB::table('artworks')
|
||||
->select('id')
|
||||
->selectRaw(
|
||||
'(SELECT COALESCE(SUM(weight * CASE medal'
|
||||
. ' WHEN \'gold\' THEN 3'
|
||||
. ' WHEN \'silver\' THEN 2'
|
||||
. ' ELSE 1 END), 0)'
|
||||
. ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score'
|
||||
)
|
||||
->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views')
|
||||
->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('award_score')
|
||||
->orderByDesc('stat_views')
|
||||
->orderByDesc('stat_downloads')
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->pluck('id');
|
||||
try {
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $ids
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only fallback for trending (Meilisearch unavailable).
|
||||
* Uses pre-computed trending_score_7d column — no correlated subqueries.
|
||||
*/
|
||||
private function getTrendingFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->orderByDesc('trending_score_7d')
|
||||
->orderByDesc('trending_score_24h')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh uploads: latest 12 approved public artworks.
|
||||
*/
|
||||
@@ -268,6 +311,84 @@ final class HomepageService
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Personalized sections (auth only)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Latest artworks from creators the user follows (max 12).
|
||||
*/
|
||||
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
|
||||
{
|
||||
$followingIds = $prefs['followed_creators'] ?? [];
|
||||
|
||||
if (empty($followingIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
"homepage.following.{$user->id}",
|
||||
60, // short TTL – personal data
|
||||
function () use ($followingIds): array {
|
||||
$artworks = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching the user's top tags (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByTags(array $tagSlugs): array
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in the user's favourite categories (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByCategories(array $categorySlugs): array
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
132
app/Services/TrendingService.php
Normal file
132
app/Services/TrendingService.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* TrendingService
|
||||
*
|
||||
* Calculates and persists deterministic trending scores for artworks.
|
||||
*
|
||||
* Formula (Phase 1):
|
||||
* score = (award_score * 5)
|
||||
* + (favorites_count * 3)
|
||||
* + (reactions_count * 2)
|
||||
* + (downloads_count * 1)
|
||||
* + (views * 2)
|
||||
* - (hours_since_published * 0.1)
|
||||
*
|
||||
* The score is stored in artworks.trending_score_24h (artworks ≤ 7 days old)
|
||||
* and artworks.trending_score_7d (artworks ≤ 30 days old).
|
||||
*
|
||||
* Both columns are updated every run; use `--period` to limit computation.
|
||||
*/
|
||||
final class TrendingService
|
||||
{
|
||||
/** Weight constants — tune via config('discovery.trending.*') if needed */
|
||||
private const W_AWARD = 5.0;
|
||||
private const W_FAVORITE = 3.0;
|
||||
private const W_REACTION = 2.0;
|
||||
private const W_DOWNLOAD = 1.0;
|
||||
private const W_VIEW = 2.0;
|
||||
private const DECAY_RATE = 0.1; // score loss per hour since publish
|
||||
|
||||
/**
|
||||
* Recalculate trending scores for artworks published within the look-back window.
|
||||
*
|
||||
* @param string $period '24h' targets trending_score_24h (7-day window)
|
||||
* '7d' targets trending_score_7d (30-day window)
|
||||
* @param int $chunkSize Number of IDs per DB UPDATE batch
|
||||
* @return int Number of artworks updated
|
||||
*/
|
||||
public function recalculate(string $period = '7d', int $chunkSize = 1000): int
|
||||
{
|
||||
[$column, $windowDays] = match ($period) {
|
||||
'24h' => ['trending_score_24h', 7],
|
||||
default => ['trending_score_7d', 30],
|
||||
};
|
||||
|
||||
// Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d
|
||||
// instead of all-time totals so trending reflects recent activity.
|
||||
[$viewCol, $dlCol] = match ($period) {
|
||||
'24h' => ['views_24h', 'downloads_24h'],
|
||||
default => ['views_7d', 'downloads_7d'],
|
||||
};
|
||||
|
||||
$cutoff = now()->subDays($windowDays)->toDateTimeString();
|
||||
$updated = 0;
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void {
|
||||
$ids = $artworks->pluck('id')->toArray();
|
||||
$inClause = implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
// One bulk UPDATE per chunk – uses pre-computed windowed counters
|
||||
// for views and downloads (accurate rolling windows, reset nightly/weekly)
|
||||
// rather than all-time totals. All other signals use correlated subqueries.
|
||||
// Column name ($column) is controlled internally, not user-supplied.
|
||||
DB::update(
|
||||
"UPDATE artworks
|
||||
SET
|
||||
{$column} = GREATEST(
|
||||
COALESCE((SELECT score_total FROM artwork_award_stats WHERE artwork_award_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT favorites FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT COUNT(*) FROM artwork_reactions WHERE artwork_reactions.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
- (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?)
|
||||
, 0),
|
||||
last_trending_calculated_at = NOW()
|
||||
WHERE id IN ({$inClause})",
|
||||
array_merge(
|
||||
[self::W_AWARD, self::W_FAVORITE, self::W_REACTION, self::W_DOWNLOAD, self::W_VIEW, self::DECAY_RATE],
|
||||
$ids
|
||||
)
|
||||
);
|
||||
|
||||
$updated += count($ids);
|
||||
});
|
||||
|
||||
Log::info('TrendingService: recalculation complete', [
|
||||
'period' => $period,
|
||||
'column' => $column,
|
||||
'updated' => $updated,
|
||||
]);
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Meilisearch re-index jobs for artworks in the trending window.
|
||||
* Called after recalculate() to keep the search index current.
|
||||
*/
|
||||
public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void
|
||||
{
|
||||
$windowDays = $period === '24h' ? 7 : 30;
|
||||
$cutoff = now()->subDays($windowDays)->toDateTimeString();
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->chunkById($chunkSize, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
93
app/Services/UserPreferenceService.php
Normal file
93
app/Services/UserPreferenceService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* UserPreferenceService
|
||||
*
|
||||
* Builds a lightweight preference profile for a user based on:
|
||||
* - Tags on artworks they have favourited
|
||||
* - Categories of artwork they have favourited / downloaded
|
||||
* - Creators they follow
|
||||
*
|
||||
* Output shape:
|
||||
* [
|
||||
* 'top_tags' => ['space', 'nature', ...], // up to 5 slugs
|
||||
* 'top_categories' => ['wallpapers', ...], // up to 3 slugs
|
||||
* 'followed_creators' => [1, 5, 23, ...], // user IDs
|
||||
* ]
|
||||
*/
|
||||
final class UserPreferenceService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function build(User $user): array
|
||||
{
|
||||
return Cache::remember(
|
||||
"user.prefs.{$user->id}",
|
||||
self::CACHE_TTL,
|
||||
fn () => $this->compute($user)
|
||||
);
|
||||
}
|
||||
|
||||
private function compute(User $user): array
|
||||
{
|
||||
return [
|
||||
'top_tags' => $this->topTags($user),
|
||||
'top_categories' => $this->topCategories($user),
|
||||
'followed_creators' => $this->followedCreatorIds($user),
|
||||
];
|
||||
}
|
||||
|
||||
/** Top tag slugs derived from the user's favourited artworks */
|
||||
private function topTags(User $user, int $limit = 5): array
|
||||
{
|
||||
return DB::table('artwork_favourites as af')
|
||||
->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) as cnt')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->orderByDesc('cnt')
|
||||
->limit($limit)
|
||||
->pluck('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** Top category slugs derived from the user's favourited artworks */
|
||||
private function topCategories(User $user, int $limit = 3): array
|
||||
{
|
||||
return DB::table('artwork_favourites as af')
|
||||
->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('c.deleted_at')
|
||||
->selectRaw('c.slug, COUNT(*) as cnt')
|
||||
->groupBy('c.id', 'c.slug')
|
||||
->orderByDesc('cnt')
|
||||
->limit($limit)
|
||||
->pluck('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** IDs of creators the user follows, latest follows first */
|
||||
private function followedCreatorIds(User $user, int $limit = 100): array
|
||||
{
|
||||
return DB::table('user_followers')
|
||||
->where('follower_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->pluck('user_id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user