feat: add admin:fixPostCounts to resync post like/boost/comment counts

Add a FixPostCounts command mirroring admin:fixProfileCounts (single-id,
--all --scope, --active, --type, --dry-run, --force). It reconciles the
statuses likes_count, reblogs_count, and reply_count columns against
source-of-truth tables.

Add canonical recompute helpers and reconcileStatusCounts() to
StatusService (mirroring AccountStatService), busting the status cache
only when a column actually drifted.
pull/6940/head
Your Name 4 weeks ago
parent ba8b92105d
commit 744e453606

@ -0,0 +1,248 @@
<?php
namespace App\Console\Commands\FixBugs;
use App\Models\Status;
use App\Services\StatusService;
use Illuminate\Console\Command;
class FixPostCounts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:fixPostCounts
{id? : Status id to resync (omit with --all)}
{--all : Scan statuses and resync any with drifted counts (requires --scope)}
{--active=* : Scan only posts by local accounts active within N days (default 30). Bulk mode; mutually exclusive with --all}
{--scope= : Which statuses to scan in --all mode: local, remote, or both}
{--type= : Restrict to a single metric: likes, boosts, or comments (default: all three)}
{--dry-run : Report drift without changing anything}
{--force : Skip the confirmation prompt (for scheduled/unattended runs)}';
/**
* Default active window (days) when --active is passed without a value.
*/
protected const DEFAULT_ACTIVE_DAYS = 30;
/**
* Metrics this command can reconcile.
*
* @var array<int, string>
*/
protected const METRICS = ['likes', 'boosts', 'comments'];
/**
* Valid --scope values for --all mode.
*
* @var array<int, string>
*/
protected const SCOPES = ['local', 'remote', 'both'];
/**
* The console command description.
*
* @var string
*/
protected $description = 'Resync a post\'s cached counts (likes, boosts, comments) from source-of-truth tables. Use --all --scope=local|remote|both, or --active, for bulk reconciliation.';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$id = $this->argument('id');
$all = $this->option('all');
$activeDays = $this->resolveActiveDays();
$active = $activeDays !== null;
// Exactly one mode: a single id, --all, or --active.
$modes = (int) (bool) $id + (int) $all + (int) $active;
if ($modes === 0) {
$this->error('Provide a status id, or pass --all or --active.');
return 1;
}
if ($modes > 1) {
$this->error('Pass only one of: <id>, --all, or --active.');
return 1;
}
$type = $this->option('type');
if ($type !== null && ! in_array($type, self::METRICS, true)) {
$this->error('Invalid --type "'.$type.'". Use one of: '.implode(', ', self::METRICS).'.');
return 1;
}
$scope = $this->option('scope');
if ($scope !== null && ! in_array($scope, self::SCOPES, true)) {
$this->error('Invalid --scope "'.$scope.'". Use one of: '.implode(', ', self::SCOPES).'.');
return 1;
}
if ($all && $scope === null) {
$this->error('--all requires --scope (local, remote, or both).');
return 1;
}
if ($active && $scope !== null && $scope !== 'local') {
// --active filters on the author's users.last_active_at, which only
// exists for local accounts, so remote/both make no sense here.
$this->error('--active only applies to local accounts; --scope must be omitted or "local".');
return 1;
}
if ($id) {
$status = Status::find($id);
if (! $status) {
$this->error('No status found for id "'.$id.'".');
return 1;
}
$this->resyncOne($status);
return 0;
}
// Bulk mode (--all or --active): scan and only touch drifted statuses.
$dryRun = $this->option('dry-run');
$scopeLabel = $active
? 'posts by local accounts active in the last '.$activeDays.' days'
: $scope.' statuses';
if (! $dryRun && ! $this->option('force') && ! $this->confirm('Resync cached counts for '.$scopeLabel.'?', true)) {
$this->comment('Aborted.');
return 0;
}
$query = Status::whereNull('deleted_at');
if ($active) {
// Restrict to statuses authored by LOCAL profiles whose linked user
// logged in recently. Remote posts have no local user, so they are
// excluded here.
$cutoff = now()->subDays($activeDays);
$query->whereLocal(true)
->whereHas('profile.user', function ($q) use ($cutoff) {
$q->whereNotNull('last_active_at')
->where('last_active_at', '>=', $cutoff);
});
} elseif ($scope === 'local') {
$query->whereLocal(true);
} elseif ($scope === 'remote') {
$query->whereLocal(false);
}
// scope === 'both' applies no local/remote filter.
$fixed = 0;
$scanned = 0;
$query->lazyById(500)->each(function ($status) use (&$fixed, &$scanned) {
$scanned++;
if ($this->resyncOne($status)) {
$fixed++;
}
});
$this->newLine();
$this->info('Scanned '.$scanned.' statuses ('.$scopeLabel.'); '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.');
return 0;
}
/**
* Resolve the --active window in days, or null if the flag was not passed.
*/
protected function resolveActiveDays(): ?int
{
$values = (array) $this->option('active');
if (empty($values)) {
return null;
}
$last = end($values);
// `--active` with no value arrives as an empty string / null.
if ($last === null || $last === '') {
return self::DEFAULT_ACTIVE_DAYS;
}
$days = (int) $last;
return $days > 0 ? $days : self::DEFAULT_ACTIVE_DAYS;
}
/**
* Recompute (or report) the cached counts for a single status.
* Only emits output when drift is detected; silent otherwise.
*
* @return bool whether the status was drifted
*/
protected function resyncOne(Status $status): bool
{
// Which metrics to consider: a single --type, or all three.
$type = $this->option('type');
$metrics = $type !== null ? [$type] : self::METRICS;
// Compute drift only for the selected metrics, using the canonical
// source-of-truth logic shared with StatusService.
$drift = [];
if (in_array('likes', $metrics, true)) {
$drift['likes'] = [
'cached' => (int) $status->likes_count,
'live' => StatusService::recalculateLikeCount($status->id),
];
}
if (in_array('boosts', $metrics, true)) {
$drift['boosts'] = [
'cached' => (int) $status->reblogs_count,
'live' => StatusService::recalculateReblogCount($status->id),
];
}
if (in_array('comments', $metrics, true)) {
$drift['comments'] = [
'cached' => (int) $status->reply_count,
'live' => StatusService::recalculateReplyCount($status->id),
];
}
$drifted = array_filter($drift, fn ($m) => $m['cached'] !== $m['live']);
if (empty($drifted)) {
// No drift on the selected metrics: stay silent.
return false;
}
// Drift detected: report exactly what drifted.
$this->warn('status id '.$status->id.' drift detected:');
foreach ($drifted as $metric => $m) {
$this->line(' '.str_pad($metric.':', 11).'cached='.$m['cached'].' live='.$m['live']);
}
if ($this->option('dry-run')) {
return true;
}
// Inline recompute of the selected metrics via the shared reconciler.
StatusService::reconcileStatusCounts($status, $metrics);
$status->refresh();
$this->info(' resynced to likes='.$status->likes_count.', boosts='.$status->reblogs_count.', comments='.$status->reply_count.'.');
return true;
}
}

@ -69,6 +69,7 @@ full argument and option list.
| `media:fix-nonlocal-driver` | Repair filesystem records when `FILESYSTEM_DRIVER` is not set to local. |
| `app:fix-missing-user-profile` | Interactively create a missing profile for an affected user. |
| `admin:fixProfileCounts` | Resync a profile's cached counts (followers, following, statuses) from source tables; supports bulk `--all`/`--active`. |
| `admin:fixPostCounts` | Resync a post's cached counts (likes, boosts, comments) from source tables; supports bulk `--all`/`--active`. |
| `fix:usernames` | Fix invalid usernames. |
| `app:hashtag-related-generate` | Generate related-hashtag data for a given tag. |
| `media:fix` | Null out media `filter_class` values no longer present in `Filter::classes()`. Still relevant: image filters remain an active feature. |

@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Like;
use App\Models\Status;
use App\Transformer\Api\StatusStatelessTransformer;
use Illuminate\Support\Facades\Cache;
@ -394,4 +395,92 @@ class StatusService
return true;
}
/**
* Canonical source-of-truth like count for a status.
*/
public static function recalculateLikeCount($id): int
{
return (int) Like::whereStatusId($id)->count();
}
/**
* Canonical source-of-truth boost/reblog count for a status.
*/
public static function recalculateReblogCount($id): int
{
return (int) Status::whereReblogOfId($id)->count();
}
/**
* Canonical source-of-truth reply/comment count for a status.
*/
public static function recalculateReplyCount($id): int
{
return (int) Status::whereInReplyToId($id)->count();
}
/**
* Reconcile a status's cached count columns (likes_count, reblogs_count,
* reply_count) against source-of-truth tables. Only writes and busts the
* cache when a column actually drifted.
*
* @param array<int, string> $only Restrict to a subset of
* ['likes','boosts','comments'].
* @return array<string, array{cached:int,live:int,drifted:bool}>
* Per-metric before/after summary.
*/
public static function reconcileStatusCounts($status, array $only = ['likes', 'boosts', 'comments']): array
{
if (! $status instanceof Status) {
$status = Status::find($status);
}
if (! $status) {
return [];
}
$summary = [];
$changed = false;
if (in_array('likes', $only, true)) {
$cached = (int) $status->likes_count;
$live = self::recalculateLikeCount($status->id);
$drift = $cached !== $live;
if ($drift) {
$status->likes_count = $live;
$changed = true;
}
$summary['likes'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift];
}
if (in_array('boosts', $only, true)) {
$cached = (int) $status->reblogs_count;
$live = self::recalculateReblogCount($status->id);
$drift = $cached !== $live;
if ($drift) {
$status->reblogs_count = $live;
$changed = true;
}
$summary['boosts'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift];
}
if (in_array('comments', $only, true)) {
$cached = (int) $status->reply_count;
$live = self::recalculateReplyCount($status->id);
$drift = $cached !== $live;
if ($drift) {
$status->reply_count = $live;
$changed = true;
}
$summary['comments'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift];
}
if ($changed) {
$status->save();
self::del($status->id, true);
}
return $summary;
}
}

Loading…
Cancel
Save