participantApplication($request); $app->assertMayEditSignup('file'); return $app; } public function store(Request $request): JsonResponse { $maxKb = (int) config('contest.file_max_kb', 20480); $data = $request->validate([ 'kind' => ['required', 'string', Rule::in(['plan', 'supporting'])], 'file' => ['required', 'file', 'max:'.$maxKb], ]); $app = $this->application($request); $app->loadMissing('competition'); $uploaded = $data['file']; $ext = strtolower($uploaded->getClientOriginalExtension()); $schemaRow = SignupFormFileRules::fileFieldRow($app->competition, $data['kind']); $allowedExt = SignupFormFileRules::effectiveAllowedExtensions($schemaRow); if (! in_array($ext, $allowedExt, true)) { throw ValidationException::withMessages([ 'file' => ['仅支持:'.implode('、', $allowedExt)], ]); } $maxFiles = SignupFormFileRules::maxCount($schemaRow); if ($maxFiles !== null) { $current = $app->files()->where('kind', $data['kind'])->count(); if ($current >= $maxFiles) { throw ValidationException::withMessages([ 'file' => ['最多可上传 '.$maxFiles.' 个文件'], ]); } } $path = $uploaded->store("applications/{$app->id}", 'public'); $file = ApplicationFile::create([ 'application_id' => $app->id, 'kind' => $data['kind'], 'disk' => 'public', 'path' => $path, 'original_name' => $uploaded->getClientOriginalName(), 'size' => $uploaded->getSize(), 'mime' => $uploaded->getClientMimeType(), ]); return response()->json([ 'id' => $file->id, 'kind' => $file->kind, 'original_name' => $file->original_name, 'size' => $file->size, 'url' => $file->participantPreviewSignedUrl(), ], 201); } /** * 选手附件预览(签名 URL,GET 无需 Bearer;由 API 在 json 中下发短期有效链接)。 */ public function downloadSigned(Request $request, ApplicationFile $file): StreamedResponse { if (! Storage::disk($file->disk)->exists($file->path)) { abort(404); } return Storage::disk($file->disk)->response( $file->path, $file->clientDownloadName(), [ 'Cache-Control' => 'private, no-store', 'Connection' => 'close', ], $file->preferredStreamDisposition(), ); } public function destroy(Request $request, ApplicationFile $file): JsonResponse { $competition = $this->resolvePublishedCompetitionFromRequest($request); $application = Application::query() ->where('user_id', $request->user()->id) ->where('competition_id', $competition->id) ->first(); if ($application === null || (int) $file->application_id !== (int) $application->id) { abort(404); } $application->assertMayEditSignup('file'); $disk = $file->disk; $path = $file->path; $fileId = $file->id; $file->delete(); $this->queueStorageFileRemoval($disk, $path, $fileId); return response()->json(['message' => 'deleted']); } private function queueStorageFileRemoval(string $disk, string $path, int $fileId): void { dispatch(static function () use ($disk, $path, $fileId): void { try { Storage::disk($disk)->delete($path); } catch (\Throwable $e) { Log::warning('application.file.storage_delete_failed', [ 'file_id' => $fileId, 'disk' => $disk, 'path' => $path, 'error' => $e->getMessage(), ]); } })->afterResponse(); } }