429 lines
17 KiB
PHP
429 lines
17 KiB
PHP
<?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),
|
||
];
|
||
}
|
||
}
|