user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], 'keyword' => ['nullable', 'string', 'max:200'], 'track_code' => ['nullable', 'string', 'max:64'], 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); $allowedCodes = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->pluck('track_code') ->unique() ->filter() ->values(); if ($allowedCodes->isEmpty()) { abort(403, '您暂无本场赛事的评审范围'); } $tracksMeta = CompetitionTrack::query() ->where('competition_id', $competition->id) ->whereIn('track_code', $allowedCodes) ->orderBy('sort') ->orderBy('id') ->get(['id', 'track_code', 'title']); $trackTitleByCode = $tracksMeta->keyBy('track_code'); $q = Application::query() ->where('competition_id', $competition->id) ->where('status', 'submitted') ->whereIn('track', $allowedCodes); if (! empty($data['track_code'])) { if (! $allowedCodes->contains($data['track_code'])) { abort(422, '无效赛道'); } $q->where('track', $data['track_code']); } if (isset($data['keyword']) && $data['keyword'] !== null && trim($data['keyword']) !== '') { $raw = trim($data['keyword']); $kw = '%'.addcslashes($raw, '%_\\').'%'; $q->where(function ($w) use ($kw): void { $w->where('player_name', 'like', $kw) ->orWhere('project_name', 'like', $kw) ->orWhere('school', 'like', $kw) ->orWhere('company_name', 'like', $kw) ->orWhere('contact_mobile', 'like', $kw) ->orWhereRaw( "CONCAT_WS(' ', COALESCE(location_country,''), COALESCE(location_province,''), COALESCE(location_city,''), COALESCE(oversea_country,'')) LIKE ?", [$kw] ); }); } $perPage = isset($data['per_page']) ? (int) $data['per_page'] : 20; $paginator = $q->orderByDesc('submitted_at') ->orderByDesc('id') ->paginate($perPage); $appIds = collect($paginator->items())->pluck('id')->all(); $scoresByAppId = ApplicationReviewScore::query() ->where('reviewer_id', $reviewer->id) ->whereIn('application_id', $appIds) ->get() ->keyBy('application_id'); $rows = []; foreach ($paginator->items() as $app) { /** @var Application $app */ $rows[] = $this->serializeListRow($app, $trackTitleByCode, $scoresByAppId->get($app->id)); } return response()->json([ 'data' => $rows, 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'tracks' => $tracksMeta->map(fn (CompetitionTrack $t) => [ 'track_code' => $t->track_code, 'title' => $t->title, ])->values(), ]); } /** * 单条报名详情(评审端只读)。 */ public function show(Request $request, Application $application): JsonResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权查看该项目'); } $application->load(['files']); $schema = $this->resolveEffectiveReviewSchema($competition); $myScore = ApplicationReviewScore::query() ->where('application_id', $application->id) ->where('reviewer_id', $reviewer->id) ->first(); $trackTitle = CompetitionTrack::query() ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->value('title'); return response()->json([ 'data' => $this->serializeDetail( $application, (string) ($trackTitle ?? $application->track ?? ''), $competition, $schema, $myScore ), ]); } /** * 评审员提交或更新本人对该报名的打分(payload 与赛事生效的评审 Schema 一致)。 */ public function submitScore(Request $request, Application $application): JsonResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], 'payload' => ['required', 'array'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权评审该项目'); } $schema = $this->resolveEffectiveReviewSchema($competition); $normalized = $this->normalizePayload($schema['schema_json'], $data['payload']); $lineTotal = $this->sumNumberScores($schema['schema_json'], $normalized); DB::transaction(function () use ($application, $reviewer, $schema, $normalized, $lineTotal): void { ApplicationReviewScore::query()->updateOrCreate( [ 'application_id' => $application->id, 'reviewer_id' => $reviewer->id, ], [ 'review_schema_id' => $schema['id'], 'payload_json' => $normalized, 'line_total' => $lineTotal, ] ); ApplicationReviewRecord::query()->firstOrCreate( ['application_id' => $application->id] ); }); $myScore = ApplicationReviewScore::query() ->where('application_id', $application->id) ->where('reviewer_id', $reviewer->id) ->first(); return response()->json([ 'message' => '评分已保存', 'data' => [ 'my_review_score' => $this->serializeMyReviewScore($myScore), 'score_display' => $this->formatScoreDisplay(true, $lineTotal), 'score_is_pending' => false, ], ]); } /** * 评审端下载附件(Content-Disposition 使用用户上传时的原始文件名)。 */ public function downloadFile(Request $request, Application $application, ApplicationFile $file): StreamedResponse { /** @var Reviewer $reviewer */ $reviewer = $request->user(); $data = $request->validate([ 'competition_slug' => ['required', 'string', 'max:64'], ]); $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->firstOrFail(); if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) { abort(404); } if ($application->status !== 'submitted') { abort(404); } $hasScope = ReviewerScope::query() ->where('reviewer_id', $reviewer->id) ->where('competition_id', $competition->id) ->where('track_code', $application->track ?? '') ->exists(); if (! $hasScope) { abort(403, '您无权下载该附件'); } return Storage::disk($file->disk)->download( $file->path, $this->safeDownloadFilename($file->original_name), ['Cache-Control' => 'private, no-store'] ); } private function safeDownloadFilename(?string $originalName): string { $base = $originalName !== null && trim($originalName) !== '' ? basename(str_replace(['\\', '/'], '_', $originalName)) : '附件'; return $base !== '' ? $base : '附件'; } /** * @param \Illuminate\Support\Collection $trackTitleByCode */ private function serializeListRow(Application $app, $trackTitleByCode, ?ApplicationReviewScore $myScore): array { $code = $app->track ?? ''; $trackTitle = $trackTitleByCode->get($code)?->title ?? $code; $hasMine = $myScore !== null; $line = $myScore?->line_total; return [ 'id' => $app->id, 'project_code' => $this->formatProjectCode($app), 'project_name' => $app->project_name ?? '', 'player_name' => $app->player_name ?? '', 'location_label' => $this->formatLocation($app), 'school' => $app->school ?? '', 'degree' => $app->degree ?? '', 'track_code' => $code, 'track_title' => $trackTitle, 'score_display' => $this->formatScoreDisplay($hasMine, $line), 'score_is_pending' => ! $hasMine, 'manage_status' => null, 'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at), 'competition_id' => $app->competition_id, ]; } /** * @param array{id: int|null, schema_json: array, is_default: bool} $schema */ private function serializeDetail( Application $app, string $trackTitle, Competition $competition, array $schema, ?ApplicationReviewScore $myScore ): array { $hasMine = $myScore !== null; $line = $myScore?->line_total; return [ 'id' => $app->id, 'project_code' => $this->formatProjectCode($app), 'project_name' => $app->project_name ?? '', 'player_name' => $app->player_name ?? '', 'school' => $app->school ?? '', 'degree' => $app->degree ?? '', 'contact_email' => $app->contact_email ?? '', 'contact_mobile' => $app->contact_mobile ?? '', 'company_name' => $app->company_name ?? '', 'track_code' => $app->track ?? '', 'track_title' => $trackTitle, 'location_country' => $app->location_country ?? '', 'location_province' => $app->location_province ?? '', 'location_city' => $app->location_city ?? '', 'oversea_country' => $app->oversea_country ?? '', 'location_label' => $this->formatLocation($app), 'intro' => $app->intro ?? '', 'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at), 'score_display' => $this->formatScoreDisplay($hasMine, $line), 'score_is_pending' => ! $hasMine, 'review_schema' => [ 'id' => $schema['id'], 'schema_json' => $schema['schema_json'], 'is_default' => $schema['is_default'], ], 'my_review_score' => $this->serializeMyReviewScore($myScore), 'pledge_content_html' => $competition->pledge_content_html ?? '', 'promise_signature' => $app->promise_signature, 'files' => $app->files->map(fn ($f) => [ 'id' => $f->id, 'kind' => $f->kind, 'original_name' => $f->original_name, 'size' => $f->size, 'url' => $f->publicUrl(), ])->values(), 'promise_signed_at' => $this->formatSubmittedAtForDisplay($app->promise_signed_at), 'promise_signed' => $app->promise_signed_at !== null, ]; } /** * @return array{id: int|null, schema_json: array, is_default: bool} */ private function resolveEffectiveReviewSchema(Competition $competition): array { $id = null; $isDefault = true; $schemaJson = DefaultReviewSchema::schemaJson(); $rid = $competition->review_form_schema_id; if ($rid !== null && (int) $rid > 0) { $def = FormSchemaDefinition::query() ->where('id', (int) $rid) ->where('competition_id', $competition->id) ->where('purpose', FormSchemaDefinition::PURPOSE_REVIEW) ->first(); $json = $def?->schema_json; if (is_array($json) && count($json) > 0) { $id = $def->id; $schemaJson = $json; $isDefault = false; } } return [ 'id' => $id, 'schema_json' => $schemaJson, 'is_default' => $isDefault, ]; } private function serializeMyReviewScore(?ApplicationReviewScore $myScore): ?array { if ($myScore === null) { return null; } return [ 'payload_json' => $myScore->payload_json, 'line_total' => (string) $myScore->line_total, 'updated_at' => $this->formatSubmittedAtForDisplay($myScore->updated_at), ]; } private function formatScoreDisplay(bool $hasMine, mixed $lineTotal): string { if (! $hasMine) { return '待评审'; } if ($lineTotal === null) { return '已评分'; } $n = is_numeric($lineTotal) ? (float) $lineTotal : 0.0; return '已评分 · '.rtrim(rtrim(number_format($n, 4, '.', ''), '0'), '.'); } /** * @param array $schemaJson * @param array $payload * @return array */ private function normalizePayload(array $schemaJson, array $payload): array { $allowedKeys = []; foreach ($schemaJson as $field) { if (! is_array($field)) { continue; } $k = $field['key'] ?? null; if (is_string($k) && $k !== '') { $allowedKeys[] = $k; } } foreach (array_keys($payload) as $rawKey) { if (! is_string($rawKey) || ! in_array($rawKey, $allowedKeys, true)) { throw ValidationException::withMessages([ 'payload' => ['存在未在评审表中的字段:'.(is_string($rawKey) ? $rawKey : '(无效键)')], ]); } } $out = []; foreach ($schemaJson as $field) { if (! is_array($field)) { continue; } $key = $field['key'] ?? null; $type = $field['type'] ?? null; if (! is_string($key) || $key === '') { continue; } if (! in_array($type, ['number', 'text', 'textarea'], true)) { throw ValidationException::withMessages([ 'payload' => ['评审表字段「'.$key.'」类型无效'], ]); } $label = is_string($field['label'] ?? null) ? (string) $field['label'] : $key; $required = ! empty($field['required']); $hasKey = array_key_exists($key, $payload); if ($type === 'number') { if (! $hasKey || $payload[$key] === null || $payload[$key] === '') { if ($required) { throw ValidationException::withMessages([ 'payload' => ['请填写'.$label], ]); } $out[$key] = null; continue; } if (! is_numeric($payload[$key])) { throw ValidationException::withMessages([ 'payload' => [$label.'须为数字'], ]); } $out[$key] = (float) $payload[$key]; continue; } if (! $hasKey) { if ($required) { throw ValidationException::withMessages([ 'payload' => ['请填写'.$label], ]); } $out[$key] = ''; continue; } $raw = $payload[$key]; if (is_string($raw)) { $s = trim($raw); } elseif ($raw === null) { $s = ''; } elseif (is_scalar($raw)) { $s = trim((string) $raw); } else { throw ValidationException::withMessages([ 'payload' => [$label.'格式不正确'], ]); } if ($required && $s === '') { throw ValidationException::withMessages([ 'payload' => ['请填写'.$label], ]); } $out[$key] = $s; } return $out; } /** * @param array $schemaJson * @param array $normalized */ private function sumNumberScores(array $schemaJson, array $normalized): float { $sum = 0.0; foreach ($schemaJson as $field) { if (! is_array($field)) { continue; } if (($field['type'] ?? '') !== 'number') { continue; } $key = $field['key'] ?? null; if (! is_string($key) || $key === '') { continue; } $v = $normalized[$key] ?? null; if ($v === null) { continue; } $sum += (float) $v; } return $sum; } private function formatSubmittedAtForDisplay(?CarbonInterface $dt): string { if ($dt === null) { return ''; } return $dt->copy()->timezone(self::DISPLAY_TIMEZONE)->format('Y-m-d H:i'); } private function formatProjectCode(Application $app): string { return sprintf('P%s-%04d', date('Y'), $app->id); } private function formatLocation(Application $app): string { $country = trim((string) ($app->location_country ?? '')); if ($country === '海外' && ($app->oversea_country ?? '') !== '') { return '海外 / '.$app->oversea_country; } $parts = array_values(array_filter([ $app->location_country !== null && $app->location_country !== '' ? $app->location_country : null, $app->location_province !== null && $app->location_province !== '' ? $app->location_province : null, $app->location_city !== null && $app->location_city !== '' ? $app->location_city : null, ], fn ($v) => $v !== null && $v !== '')); return count($parts) ? implode(' / ', $parts) : '—'; } }