Files
portal/app/app/Jobs/GenerateReportJob.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

120 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\ReportJob;
use App\Services\Reports\ReportGeneratorRegistry;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* Обёртка над генерацией отчёта. Меняет статус pending → processing → done|failed,
* пишет file_path/file_size/generation_seconds.
*
* `tries=1` — retry-логика custom (CTO-6: 3 попытки за 7 дней через UI кнопку
* "Повторить", не auto-retry queue worker'ом — иначе квота CTO-7 размывается).
*
* `expires_at` = NOW + 30 дней (default; при необходимости вынесем в
* system_settings.report_retention_days).
*/
class GenerateReportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public function __construct(
public readonly int $reportJobId,
public readonly int $tenantId,
) {}
public function handle(ReportGeneratorRegistry $registry): void
{
// SET LOCAL inside a transaction establishes the tenant GUC for the
// duration of this block — required by RLS on report_jobs for
// crm_app_user (non-BYPASSRLS) on production.
DB::transaction(function () use ($registry): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
$job = ReportJob::query()->find($this->reportJobId);
if ($job === null) {
Log::warning('GenerateReportJob: report_job not found', ['id' => $this->reportJobId]);
return;
}
if (! in_array($job->status, ReportJob::ACTIVE_STATUSES, true)) {
// Уже terminal — повторный dispatch (например, Horizon retry) пропускаем.
return;
}
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
$startedAt = microtime(true);
try {
$params = $job->parameters ?? [];
$format = (string) ($params['format'] ?? 'csv');
if (! $registry->isSupported($job->type, $format)) {
$this->markFailed($job, "Неподдерживаемая комбинация: {$job->type}/{$format}", $startedAt);
return;
}
$provider = $registry->provider($job->type);
$formatter = $registry->formatter($format);
$headers = $provider->headers();
$rows = $provider->rows($job);
$content = $formatter->format($headers, $rows);
$relativePath = sprintf(
'reports/%d/%d.%s',
$job->tenant_id,
$job->id,
$formatter->fileExtension()
);
Storage::disk('local')->put($relativePath, $content);
$job->update([
'status' => ReportJob::STATUS_DONE,
'file_path' => $relativePath,
'file_size' => strlen($content),
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
'finished_at' => Carbon::now(),
'expires_at' => Carbon::now()->addDays(30),
]);
} catch (Throwable $e) {
$this->markFailed($job, mb_substr($e->getMessage(), 0, 1000), $startedAt);
Log::error('GenerateReportJob failed', [
'id' => $this->reportJobId,
'exception' => $e,
]);
}
});
}
private function markFailed(ReportJob $job, string $message, float $startedAt): void
{
$job->update([
'status' => ReportJob::STATUS_FAILED,
'error_message' => $message,
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
'finished_at' => Carbon::now(),
]);
}
}