feat(cli): comments:dedupe cleanup command
Collapse historical duplicate comments produced before the 60-second dedupe guard landed. Keeps the earliest row in each cluster, reparents nested replies onto it, moves comment_likes rows, deletes the rest. Default --window=60 matches the server guard; --dry-run shows the plan without touching the DB. Transactional per group so a partial failure does not leave orphaned likes. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
c9123bc7b8
commit
48c947d171
1 file changed
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
|
||||
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
|
||||
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
|
||||
* the rest. --dry-run shows what would happen without touching the DB.
|
||||
*/
|
||||
class CommentsDedupeCommand extends Command
|
||||
{
|
||||
protected $signature = 'comments:dedupe
|
||||
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
|
||||
{--dry-run : Print what would be removed; do not delete}';
|
||||
|
||||
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$window = max(1, (int) $this->option('window'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
|
||||
// We ignore the window at the SQL layer and apply it per-group in PHP:
|
||||
// that avoids a correlated subquery and is fast enough for a one-shot
|
||||
// cleanup. For very large tables, chunk by user_id.
|
||||
$groups = DB::table('comments')
|
||||
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
|
||||
->groupBy('user_id', 'video_id', 'parent_id', 'body')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No duplicate (user, video, parent, body) groups found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRemoved = 0;
|
||||
$groupsTouched = 0;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
$rows = Comment::where('user_id', $g->user_id)
|
||||
->where('video_id', $g->video_id)
|
||||
->where(function ($q) use ($g) {
|
||||
$g->parent_id === null
|
||||
? $q->whereNull('parent_id')
|
||||
: $q->where('parent_id', $g->parent_id);
|
||||
})
|
||||
->where('body', $g->body)
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keeper = $rows->first();
|
||||
$cluster = collect([$keeper]);
|
||||
$previous = $keeper;
|
||||
|
||||
foreach ($rows->slice(1) as $row) {
|
||||
// Same cluster if row is within window of the previous row —
|
||||
// which is the behavior the server guard enforces today.
|
||||
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
|
||||
$cluster->push($row);
|
||||
$previous = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cluster->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toDelete = $cluster->skip(1);
|
||||
$groupsTouched++;
|
||||
$totalRemoved += $toDelete->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
|
||||
$g->user_id,
|
||||
$g->video_id,
|
||||
$g->parent_id ?? 'null',
|
||||
$keeper->id,
|
||||
$toDelete->pluck('id')->join(','),
|
||||
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($keeper, $toDelete) {
|
||||
$ids = $toDelete->pluck('id')->all();
|
||||
|
||||
// Reparent any replies that were nested under a doomed row.
|
||||
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
|
||||
|
||||
// Move likes to the keeper, dropping dupes.
|
||||
DB::table('comment_likes')
|
||||
->whereIn('comment_id', $ids)
|
||||
->update(['comment_id' => $keeper->id]);
|
||||
|
||||
Comment::whereIn('id', $ids)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s %d duplicate rows across %d groups.',
|
||||
$dryRun ? 'Would remove' : 'Removed',
|
||||
$totalRemoved,
|
||||
$groupsTouched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user