Files
portal/app/app/Http/Controllers/Api/ReportJobController.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

440 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\GenerateReportJob;
use App\Models\ReportJob;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\Response;
/**
* Reports API (schema §13.5). Все endpoint'ы под `auth:sanctum`.
*
* Квота CTO-7: на тенант не более 3 одновременных pending+processing
* (DoS-защита). Превышение → 422 «лимит исчерпан».
*
* Retry — CTO-6: 3 попытки на исходную задачу, окно 7 дней, только owner.
* Cancel — только pending (running выполняется sync на dev).
* Delete — terminal jobs (done|failed), также удаляет файл из storage.
* CTO-10: по истечении expires_at файл удаляется cron'ом
* `reports:cleanup-expired`, status='done' остаётся, file_path=NULL.
*/
class ReportJobController extends Controller
{
public const MAX_ACTIVE_JOBS_PER_TENANT = 3;
public const RETRY_MAX_ATTEMPTS = 3;
public const RETRY_WINDOW_DAYS = 7;
/**
* GET /api/reports/jobs?status=&limit=&offset=
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => ['nullable', 'string', Rule::in([
ReportJob::STATUS_PENDING,
ReportJob::STATUS_PROCESSING,
ReportJob::STATUS_DONE,
ReportJob::STATUS_FAILED,
])],
'limit' => 'nullable|integer|min:1|max:200',
'offset' => 'nullable|integer|min:0',
]);
/** @var User $user */
$user = $request->user();
$limit = (int) ($validated['limit'] ?? 50);
$offset = (int) ($validated['offset'] ?? 0);
return DB::transaction(function () use ($user, $validated, $limit, $offset): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$query = ReportJob::query()->where('tenant_id', $user->tenant_id);
if (isset($validated['status'])) {
$query->where('status', $validated['status']);
}
$total = (clone $query)->count();
$items = $query->orderByDesc('created_at')->orderByDesc('id')
->limit($limit)->offset($offset)->get();
$base = ReportJob::query()->where('tenant_id', $user->tenant_id);
$counts = [
'pending' => (clone $base)->where('status', ReportJob::STATUS_PENDING)->count(),
'processing' => (clone $base)->where('status', ReportJob::STATUS_PROCESSING)->count(),
'done' => (clone $base)->where('status', ReportJob::STATUS_DONE)->count(),
'failed' => (clone $base)->where('status', ReportJob::STATUS_FAILED)->count(),
];
return response()->json([
'jobs' => $items->map(fn (ReportJob $j) => $this->toResource($j))->all(),
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'counts' => $counts,
'quota' => [
'active' => $counts['pending'] + $counts['processing'],
'max_active' => self::MAX_ACTIVE_JOBS_PER_TENANT,
],
]);
});
}
/**
* GET /api/reports/jobs/{id}
*/
public function show(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$job = ReportJob::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($job === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
return response()->json(['job' => $this->toResource($job)]);
});
}
/**
* POST /api/reports/jobs {type, format, parameters: {...}}
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'type' => ['required', 'string', Rule::in(ReportJob::TYPES)],
'format' => ['required', 'string', Rule::in(ReportJob::FORMATS)],
'parameters' => 'required|array',
'parameters.date_from' => 'required|date',
'parameters.date_to' => 'required|date|after_or_equal:parameters.date_from',
'parameters.project_id' => 'nullable|integer|min:1',
'parameters.manager_id' => 'nullable|integer|min:1',
]);
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $validated): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
// CTO-7: квота 3 одновременных pending+processing на тенант.
$activeCount = ReportJob::query()
->where('tenant_id', $user->tenant_id)
->whereIn('status', ReportJob::ACTIVE_STATUSES)
->count();
if ($activeCount >= self::MAX_ACTIVE_JOBS_PER_TENANT) {
return response()->json([
'message' => 'Лимит активных отчётов исчерпан.',
'errors' => ['_quota' => [
sprintf(
'Максимум %d одновременных отчётов на тенант. Дождитесь завершения или удалите ненужные.',
self::MAX_ACTIVE_JOBS_PER_TENANT
),
]],
], 422);
}
$job = ReportJob::create([
'tenant_id' => $user->tenant_id,
'user_id' => $user->id,
'type' => $validated['type'],
'parameters' => [
'format' => $validated['format'],
'date_from' => $validated['parameters']['date_from'],
'date_to' => $validated['parameters']['date_to'],
'project_id' => $validated['parameters']['project_id'] ?? null,
'manager_id' => $validated['parameters']['manager_id'] ?? null,
],
'status' => ReportJob::STATUS_PENDING,
]);
// Sync queue на dev — Job выполняется немедленно.
// На prod queue.driver=redis/database — async через worker.
GenerateReportJob::dispatch($job->id, (int) $user->tenant_id);
return response()->json([
'job' => $this->toResource($job->fresh()),
], 201);
});
}
/**
* POST /api/reports/jobs/{id}/retry — CTO-6: 3 попытки за 7 дней, только owner.
* Создаёт НОВЫЙ ReportJob (parameters.retry_count = old + 1) + dispatch.
* Старый failed-job остаётся для истории.
*/
public function retry(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$original = ReportJob::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($original === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
if ($original->user_id !== $user->id) {
return response()->json(['message' => 'Повторно запустить может только владелец отчёта.'], 403);
}
if ($original->status !== ReportJob::STATUS_FAILED) {
return response()->json([
'message' => 'Повтор доступен только для отчётов со статусом «failed».',
'errors' => ['status' => ['Текущий статус: '.$original->status]],
], 422);
}
$retryCount = (int) ($original->parameters['retry_count'] ?? 0);
if ($retryCount >= self::RETRY_MAX_ATTEMPTS - 1) {
return response()->json([
'message' => 'Достигнут максимум попыток.',
'errors' => ['_retry' => [sprintf('Максимум %d попыток (CTO-6).', self::RETRY_MAX_ATTEMPTS)]],
], 422);
}
$cutoff = Carbon::now()->subDays(self::RETRY_WINDOW_DAYS);
if ($original->created_at !== null && $original->created_at->lt($cutoff)) {
return response()->json([
'message' => 'Окно повторов истекло.',
'errors' => ['_retry' => [sprintf('Повтор доступен в течение %d дней с создания.', self::RETRY_WINDOW_DAYS)]],
], 422);
}
// Квота тоже учитывается для retry — иначе можно «обойти» CTO-7 через retry-spam.
$activeCount = ReportJob::query()
->where('tenant_id', $user->tenant_id)
->whereIn('status', ReportJob::ACTIVE_STATUSES)
->count();
if ($activeCount >= self::MAX_ACTIVE_JOBS_PER_TENANT) {
return response()->json([
'message' => 'Лимит активных отчётов исчерпан.',
'errors' => ['_quota' => [sprintf('Максимум %d одновременных отчётов на тенант.', self::MAX_ACTIVE_JOBS_PER_TENANT)]],
], 422);
}
$params = $original->parameters ?? [];
$params['retry_count'] = $retryCount + 1;
$params['retry_of'] = $original->id;
$newJob = ReportJob::create([
'tenant_id' => $user->tenant_id,
'user_id' => $user->id,
'type' => $original->type,
'parameters' => $params,
'status' => ReportJob::STATUS_PENDING,
]);
GenerateReportJob::dispatch($newJob->id, (int) $user->tenant_id);
return response()->json([
'job' => $this->toResource($newJob->fresh()),
], 201);
});
}
/**
* POST /api/reports/jobs/{id}/cancel — отменить pending (running уже работает).
*/
public function cancel(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$job = ReportJob::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($job === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
if ($job->user_id !== $user->id) {
return response()->json(['message' => 'Отменить может только владелец отчёта.'], 403);
}
if ($job->status !== ReportJob::STATUS_PENDING) {
return response()->json([
'message' => 'Отменить можно только pending-задачу.',
'errors' => ['status' => ['Текущий статус: '.$job->status]],
], 422);
}
$job->update([
'status' => ReportJob::STATUS_FAILED,
'error_message' => 'Отменено пользователем.',
'finished_at' => Carbon::now(),
]);
return response()->json(['job' => $this->toResource($job->fresh())]);
});
}
/**
* DELETE /api/reports/jobs/{id} — удалить terminal job + файл.
*/
public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$job = ReportJob::query()
->where('id', $id)
->where('tenant_id', $user->tenant_id)
->first();
if ($job === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
if ($job->user_id !== $user->id) {
return response()->json(['message' => 'Удалить может только владелец отчёта.'], 403);
}
if (! in_array($job->status, ReportJob::TERMINAL_STATUSES, true)) {
return response()->json([
'message' => 'Удалить можно только завершённую задачу (done|failed).',
'errors' => ['status' => ['Текущий статус: '.$job->status]],
], 422);
}
if ($job->file_path !== null) {
Storage::disk('local')->delete($job->file_path);
$pdLog->record(
action: 'deleted',
subjectType: 'lead',
subjectId: null,
purpose: 'report_file_'.$job->id,
tenantId: (int) $job->tenant_id,
actorTenantUserId: (int) $user->id,
actorAdminUserId: null,
ip: $request->ip(),
);
}
$job->delete();
return response()->json(['message' => 'Удалено.']);
});
}
/**
* GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать
* готовый файл отчёта (F2, OPEN-И-20).
*
* Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
* `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
* Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя.
*/
public function download(Request $request, int $id): Response
{
$tenantId = (int) $request->query('tenant', '0');
return DB::transaction(function () use ($id, $tenantId): Response {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$job = ReportJob::query()
->where('id', $id)
->where('tenant_id', $tenantId)
->first();
if ($job === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
if ($job->status !== ReportJob::STATUS_DONE
|| $job->file_path === null
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
}
if (! Storage::disk('local')->exists($job->file_path)) {
return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
}
$extension = pathinfo($job->file_path, PATHINFO_EXTENSION);
return Storage::disk('local')->download(
$job->file_path,
sprintf('report-%d.%s', $job->id, $extension)
);
});
}
/**
* Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
* после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
*/
private function downloadUrl(ReportJob $job): ?string
{
if ($job->status !== ReportJob::STATUS_DONE
|| $job->file_path === null
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
return null;
}
return URL::temporarySignedRoute(
'reports.download',
Carbon::now()->addHours(24),
['id' => $job->id, 'tenant' => $job->tenant_id],
);
}
/** @return array<string, mixed> */
private function toResource(ReportJob $job): array
{
return [
'id' => $job->id,
'type' => $job->type,
'parameters' => $job->parameters,
'status' => $job->status,
'file_path' => $job->file_path,
'file_size' => $job->file_size,
'generation_seconds' => $job->generation_seconds,
'error_message' => $job->error_message,
'created_at' => $job->created_at?->toIso8601String(),
'finished_at' => $job->finished_at?->toIso8601String(),
'expires_at' => $job->expires_at?->toIso8601String(),
'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(),
'retry_count' => (int) ($job->parameters['retry_count'] ?? 0),
'retry_max' => self::RETRY_MAX_ATTEMPTS,
'download_url' => $this->downloadUrl($job),
];
}
}