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