feat(comments): 60s dedupe window + idempotency key
- Server window raised from 5s to 60s so slow double-fires (observed on /videos/CRKAf: same body saved 3-7 times, seconds to minutes apart) collapse into one comment. - X-Idempotency-Key honored: the client sends a per-attempt UUID; the server caches the resulting comment id for 5 minutes under that key. A retry with the same key returns the first-written comment without touching the DB a second time. - Comment and reply submit JS both send the key via fetch(). The existing _busy flag + disabled submit button already covers the fast double-click case on the client; this hardens the server side so duplicates can't slip through even when the client guard is bypassed. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
3122420a3d
commit
c9123bc7b8
2 files changed
+51
-7
No files matched your search
@@ -36,10 +36,34 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after an
|
||||
// SPA swap, fast double-click, a flaky connection) can POST the identical
|
||||
// body twice. Treat an identical comment from the same user to the same
|
||||
// video within 5 seconds as the same submission and return the first one.
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
@@ -48,11 +72,14 @@ class CommentController extends Controller
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
@@ -65,6 +92,10 @@ class CommentController extends Controller
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
|
||||
Reference in new issue
Block a user