|
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Api\Concerns\ResolvesParticipantApplication;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
|
|
|
use App\Models\Application;
|
|
|
|
|
|
use App\Models\ApplicationFile;
|
|
|
|
|
|
use App\Support\SignupFormFileRules;
|
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
|
use Illuminate\Validation\Rule;
|
|
|
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
|
|
|
|
|
|
|
|
class ApplicationFileController extends Controller
|
|
|
|
|
|
{
|
|
|
|
|
|
use ResolvesParticipantApplication;
|
|
|
|
|
|
|
|
|
|
|
|
private function application(Request $request): Application
|
|
|
|
|
|
{
|
|
|
|
|
|
$app = $this->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.' 个文件'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// public 磁盘 throw=false 时写失败可能返回 false(落库成 path="0"),目录不可写等场景也会抛异常
|
|
|
|
|
|
try {
|
|
|
|
|
|
$path = $uploaded->store("applications/{$app->id}", 'public');
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
Log::error('application.file.store_exception', [
|
|
|
|
|
|
'application_id' => $app->id,
|
|
|
|
|
|
'kind' => $data['kind'],
|
|
|
|
|
|
'original_name' => $uploaded->getClientOriginalName(),
|
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
|
|
'file' => ['文件保存失败,请稍后重试'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (! is_string($path) || $path === '' || $path === '0' || ! Storage::disk('public')->exists($path)) {
|
|
|
|
|
|
Log::error('application.file.store_failed', [
|
|
|
|
|
|
'application_id' => $app->id,
|
|
|
|
|
|
'kind' => $data['kind'],
|
|
|
|
|
|
'original_name' => $uploaded->getClientOriginalName(),
|
|
|
|
|
|
'path_result' => is_string($path) ? $path : var_export($path, true),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
|
|
'file' => ['文件保存失败,请稍后重试'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$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
|
|
|
|
|
|
{
|
|
|
|
|
|
$file->assertStoredFileExists();
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|