Files
portal/app/app/Console/Commands/ReportsCleanupExpired.php
T
Дмитрий fb4e711b4a fix(rls): close 4 dev↔prod RLS gaps in cron/jobs (hole #7 Phase B)
Found by docs/audit/2026-05-23-rls-gap-audit.md. Each touched an RLS-protected
table on the default connection in cron/queue context (no tenant GUC) — crash or
silent misbehaviour on prod (crm_app_user, not BYPASSRLS), hidden on dev (superuser).

- RemindersDispatchDue (Pattern B): gather pending via pgsql_supplier, then
  per-reminder DB::transaction + SET LOCAL app.current_tenant_id (isolation kept).
- ReportsCleanupExpired (Pattern A): SaaS-admin cron → report_jobs + pd_processing_log
  via pgsql_supplier (BYPASSRLS).
- GenerateReportJob (Pattern B): +readonly int $tenantId ctor param, wrap handle()
  in DB::transaction + SET LOCAL; both ReportJobController dispatch sites updated.
- ProcessWebhookJob::failed (Pattern A): failed_webhook_jobs insert via pgsql_supplier
  → webhook failures now logged, incidents:watch-failures can see them.

Tests +SharesSupplierPdo trait. 118 passed / 0 failed. My 5 src files pass larastan
isolated (0 errors).
2026-05-23 10:16:46 +03:00

110 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ReportJob;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Cron-команда удаления expired report-files.
*
* Идёт по `report_jobs` где `status='done' AND expires_at < NOW() AND
* file_path IS NOT NULL`. Для каждой строки: удаляет файл из disk('local')
* + UPDATE file_path=NULL.
*
* CTO-10: статус 'done' СОХРАНЯЕТСЯ; только file_path обнуляется. Это
* позволяет UI показать «истёк» при отсутствии file_path. Сама запись
* остаётся в истории.
*
* Failed jobs игнорируем — у них file_path обычно NULL, нечего чистить;
* пользователь сам удалит через DELETE /api/reports/jobs/{id}.
*
* Запускается ежесуточно через Windows Task Scheduler / cron.
*
* --dry-run печатает плановые удаления без реальных операций.
*/
class ReportsCleanupExpired extends Command
{
/** @var string */
protected $signature = 'reports:cleanup-expired
{--dry-run : Не удалять, только напечатать список плановых удалений}
{--limit=1000 : Максимум jobs за один запуск}';
/** @var string */
protected $description = 'Удаление expired report-files (CTO-10): file_path=NULL, status="done" сохраняется';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
// Cross-tenant gather via BYPASSRLS connection — crm_app_user on prod cannot
// evaluate current_setting('app.current_tenant_id') without a GUC set.
$rows = DB::connection('pgsql_supplier')
->table('report_jobs')
->select(['id', 'tenant_id', 'file_path', 'expires_at'])
->where('status', ReportJob::STATUS_DONE)
->whereNotNull('file_path')
->where('expires_at', '<', Carbon::now())
->orderBy('expires_at')
->limit($limit)
->get();
if ($rows->isEmpty()) {
$this->info('Нет expired report-files для удаления.');
return self::SUCCESS;
}
$count = 0;
foreach ($rows as $row) {
$this->line(sprintf(
'[%s] tenant=%d job=%d path=%s expired_at=%s',
$dryRun ? 'DRY' : 'DEL',
$row->tenant_id,
$row->id,
$row->file_path,
$row->expires_at ?? '?',
));
if (! $dryRun) {
Storage::disk('local')->delete($row->file_path);
// Both writes go through pgsql_supplier (BYPASSRLS) — this is a
// SaaS-admin cron, not a per-user action, so no tenant GUC is
// required. Same pattern as IncidentsWatchFailures, Reset*.
DB::connection('pgsql_supplier')->table('pd_processing_log')->insert([
'tenant_id' => $row->tenant_id,
'subject_type' => 'lead',
'subject_id' => null,
'action' => 'deleted',
'purpose' => 'report_cleanup_expired_'.$row->id,
'actor_tenant_user_id' => null,
'actor_admin_user_id' => null,
'ip_address' => null,
'created_at' => now(),
]);
DB::connection('pgsql_supplier')
->table('report_jobs')
->where('id', $row->id)
->update(['file_path' => null]);
}
$count++;
}
$this->info(sprintf(
'%s %d файлов отчётов.',
$dryRun ? 'Будет удалено:' : 'Удалено:',
$count
));
return self::SUCCESS;
}
}