fb4e711b4a
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).
110 lines
4.1 KiB
PHP
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;
|
|
}
|
|
}
|