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).
120 lines
4.3 KiB
PHP
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(),
|
|
]);
|
|
}
|
|
}
|