63 lines
1.9 KiB
PHP
63 lines
1.9 KiB
PHP
<?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]);
|
|
}
|
|
}
|