Files
portal/app/app/Http/Controllers/Api/ReportJobController.php
T

429 lines
17 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 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);
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);
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): 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 (! 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);
}
$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),
];
}
}