validatedListFilters($request, $competition); $competition->loadMissing(['formSchema', 'tracks']); $query = $this->exportService->buildFilteredQuery($competition, $data) ->with(['user.publicSourceChannel']) ->withCount(['files', 'reviewScores']); $this->applyListSorting($query, $data); $perPage = min((int) ($data['per_page'] ?? 15), 100); $paginator = $query->paginate($perPage); $stats = $this->reviewStatsForApplicationIds( $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all() ); $trackTitles = $competition->tracks()->pluck('title', 'track_code'); $reviewerCountsByTrack = ReviewerScope::query() ->select('track_code', DB::raw('COUNT(*) as total')) ->where('competition_id', $competition->id) ->groupBy('track_code') ->pluck('total', 'track_code'); $paginator->getCollection()->transform(function (Application $app) use ($competition, $trackTitles, $stats, $reviewerCountsByTrack) { $app->loadMissing('signupChannel'); return $this->toListRow($app, $competition, $trackTitles, $stats[(int) $app->id] ?? null, $reviewerCountsByTrack); }); $payload = $paginator->toArray(); $payload['schema_list_fields'] = SignupSchemaLayout::adminListFields($competition); $payload['schema_filter_fields'] = SignupSchemaLayout::adminFilterFields($competition); return response()->json($payload); } public function updateReviewEligible(Request $request, Competition $competition, Application $application): JsonResponse { if ((int) $application->competition_id !== (int) $competition->id) { abort(404); } $data = $request->validate([ 'review_eligible' => ['required', 'boolean'], ]); $application->forceFill([ 'review_eligible' => (bool) $data['review_eligible'], ])->save(); return response()->json([ 'id' => $application->id, 'review_eligible' => $application->isReviewEligible(), ]); } public function batchReviewResult(Request $request, Competition $competition): JsonResponse { $data = $request->validate([ 'application_ids' => ['required', 'array', 'min:1'], 'application_ids.*' => ['integer', 'min:1'], 'result' => ['required', 'string', Rule::in(['passed', 'rejected'])], 'note' => ['nullable', 'string', 'max:5000'], ]); $ids = array_values(array_unique(array_map('intval', $data['application_ids']))); $apps = Application::query() ->where('competition_id', $competition->id) ->whereIn('id', $ids) ->get(); if ($apps->count() !== count($ids)) { throw ValidationException::withMessages(['application_ids' => ['存在无效的项目编号']]); } $reviewerCountsByTrack = ReviewerScope::query() ->select('track_code', DB::raw('COUNT(*) as total')) ->where('competition_id', $competition->id) ->groupBy('track_code') ->pluck('total', 'track_code'); $stats = $this->reviewStatsForApplicationIds($ids); foreach ($apps as $app) { if (! $this->isReviewFullyCompleted($app, $stats[(int) $app->id] ?? null, $reviewerCountsByTrack)) { throw ValidationException::withMessages([ 'application_ids' => ['仅评审已全部完成的项目可设置通过/未通过状态'], ]); } } Application::query() ->where('competition_id', $competition->id) ->whereIn('id', $ids) ->update([ 'review_result' => $data['result'], 'review_result_note' => $data['note'] ?? null, 'review_result_at' => now(), ]); return response()->json(['updated' => count($ids)]); } public function exportMeta(Request $request, Competition $competition): JsonResponse { $filters = $this->validatedExportMetaFilters($request, $competition); $mode = (string) $filters['mode']; $this->exportService->assertExportable($competition, $filters, $mode); $total = $this->exportService->countFiltered($competition, $filters); $partSize = $this->exportService->partSize(); $partCount = $this->exportService->partCount($competition, $filters, $mode); return response()->json([ 'total' => $total, 'part_size' => $partSize, 'part_count' => $partCount, 'mode' => $mode, 'includes_attachments' => $this->exportService->modeIncludesAttachments($mode), ]); } public function export(Request $request, Competition $competition): BinaryFileResponse { $filters = $this->validatedExportFilters($request, $competition); $mode = (string) $filters['mode']; $this->exportService->assertExportable($competition, $filters, $mode); if ($mode === AdminApplicationExportService::MODE_XLSX) { $xlsxPath = $this->exportService->buildXlsxExportPath($competition, $filters); return response()->download($xlsxPath, $this->exportService->buildXlsxFilename($competition), [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control' => 'private, no-store', ])->deleteFileAfterSend(true); } $part = (int) ($filters['part'] ?? 1); $partCount = $this->exportService->partCount($competition, $filters, $mode); $zipPath = $this->exportService->buildPartZipPath($competition, $filters, $part, $mode); $filename = $this->exportService->buildPartFilename($competition, $part, $partCount); return response()->download($zipPath, $filename, [ 'Content-Type' => 'application/zip', 'Cache-Control' => 'private, no-store', ])->deleteFileAfterSend(true); } public function show(Competition $competition, Application $application): JsonResponse { if ((int) $application->competition_id !== (int) $competition->id) { abort(404); } $application->loadMissing(['files', 'reviewScores.reviewer', 'user.publicSourceChannel', 'signupChannel']); $trackTitles = $competition->tracks()->pluck('title', 'track_code'); $stats = $this->reviewStatsForApplicationIds([(int) $application->id]); $reviewerCountsByTrack = ReviewerScope::query() ->select('track_code', DB::raw('COUNT(*) as total')) ->where('competition_id', $competition->id) ->groupBy('track_code') ->pluck('total', 'track_code'); return response()->json( $this->toDetail($application, $competition, $trackTitles, $stats[(int) $application->id] ?? null, $reviewerCountsByTrack) ); } public function downloadFile(Competition $competition, Application $application, ApplicationFile $file): StreamedResponse { if ((int) $application->competition_id !== (int) $competition->id || (int) $file->application_id !== (int) $application->id) { abort(404); } $file->assertStoredFileExists(); return Storage::disk($file->disk)->download( $file->path, $file->clientDownloadName(), ['Cache-Control' => 'private, no-store'] ); } /** * superadmin 代登选手端:签发短期选手 Token,前端打开报名页写入后使用。 */ public function impersonate(Request $request, Competition $competition, Application $application): JsonResponse { /** @var AdminUser $admin */ $admin = $request->user(); if (! $admin->isSuperAdmin()) { abort(403, '仅 superadmin 可进入选手端'); } if ((int) $application->competition_id !== (int) $competition->id) { abort(404); } $application->loadMissing('user'); /** @var User|null $participant */ $participant = $application->user; if ($participant === null) { abort(422, '该报名未关联选手账号,无法进入选手端'); } $ttlMinutes = 30; $participant->tokens()->where('name', 'admin-impersonation')->delete(); $accessToken = $participant->createToken( 'admin-impersonation', ['impersonation'], now()->addMinutes($ttlMinutes), ); Log::info('admin.impersonate_participant', [ 'admin_id' => $admin->id, 'admin_username' => $admin->username, 'application_id' => $application->id, 'competition_id' => $competition->id, 'participant_user_id' => $participant->id, 'participant_mobile' => $participant->mobile, ]); return response()->json([ 'token' => $accessToken->plainTextToken, 'token_type' => 'Bearer', 'expires_in' => $ttlMinutes * 60, 'competition_slug' => $competition->slug, 'application_id' => $application->id, 'participant' => [ 'id' => $participant->id, 'mobile' => $participant->mobile, 'name' => $participant->name, ], ]); } /** * @return array */ private function validatedExportMetaFilters(Request $request, Competition $competition): array { return array_merge($request->validate([ 'mode' => ['required', 'string', Rule::in([ AdminApplicationExportService::MODE_XLSX, AdminApplicationExportService::MODE_SELECTED, AdminApplicationExportService::MODE_ALL, ])], 'status' => ['sometimes', 'nullable', 'string', 'max:32'], 'track' => ['sometimes', 'nullable', 'string', 'max:100'], 'event_location' => ['sometimes', 'nullable', 'string', 'max:64'], 'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])], 'keyword' => ['sometimes', 'nullable', 'string', 'max:100'], 'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])], // 查询串可能是 true/false/1/0 字符串 'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])], 'application_ids' => ['sometimes', 'array'], 'application_ids.*' => ['integer', 'min:1'], ]), SignupSchemaLayout::extractFilterValues($request, $competition)); } /** * @return array */ private function validatedExportFilters(Request $request, Competition $competition): array { $filters = $request->validate([ 'mode' => ['required', 'string', Rule::in([ AdminApplicationExportService::MODE_XLSX, AdminApplicationExportService::MODE_SELECTED, AdminApplicationExportService::MODE_ALL, ])], 'status' => ['sometimes', 'nullable', 'string', 'max:32'], 'track' => ['sometimes', 'nullable', 'string', 'max:100'], 'event_location' => ['sometimes', 'nullable', 'string', 'max:64'], 'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])], 'keyword' => ['sometimes', 'nullable', 'string', 'max:100'], 'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])], 'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])], 'application_ids' => ['sometimes', 'array'], 'application_ids.*' => ['integer', 'min:1'], 'part' => ['sometimes', 'integer', 'min:1'], ]); if (($filters['mode'] ?? '') !== AdminApplicationExportService::MODE_XLSX && ! isset($filters['part'])) { throw ValidationException::withMessages([ 'part' => ['请指定导出分卷序号'], ]); } return array_merge($filters, SignupSchemaLayout::extractFilterValues($request, $competition)); } /** * @return array */ private function validatedListFilters(Request $request, Competition $competition): array { return array_merge($request->validate([ 'page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], 'status' => ['sometimes', 'nullable', 'string', 'max:32'], 'track' => ['sometimes', 'nullable', 'string', 'max:100'], 'event_location' => ['sometimes', 'nullable', 'string', 'max:64'], 'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])], 'keyword' => ['sometimes', 'nullable', 'string', 'max:100'], 'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])], 'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])], 'sort_by' => ['sometimes', 'nullable', 'string', Rule::in(['team_avg', 'submitted_at'])], 'sort_dir' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])], ]), SignupSchemaLayout::extractFilterValues($request, $competition)); } /** * @param \Illuminate\Database\Eloquent\Builder $query * @param array $data */ private function applyListSorting($query, array $data): void { $sortBy = trim((string) ($data['sort_by'] ?? '')); $sortDir = strtolower(trim((string) ($data['sort_dir'] ?? 'desc'))) === 'asc' ? 'asc' : 'desc'; if ($sortBy === 'team_avg') { $avgSub = ApplicationReviewScore::query() ->select('application_id', DB::raw('AVG(line_total) as team_avg')) ->groupBy('application_id'); $query->leftJoinSub($avgSub, 'review_avg_sort', function ($join): void { $join->on('applications.id', '=', 'review_avg_sort.application_id'); }) ->select('applications.*') ->orderByRaw('review_avg_sort.team_avg IS NULL') ->orderBy('review_avg_sort.team_avg', $sortDir) ->orderByDesc('applications.id'); return; } $query->orderByDesc('submitted_at')->orderByDesc('id'); } /** * @param array{submitted_review_count?: int, team_sum?: float|null, team_avg?: float|null}|null $stats * @param \Illuminate\Support\Collection $reviewerCountsByTrack */ private function isReviewFullyCompleted(Application $app, ?array $stats, $reviewerCountsByTrack): bool { $required = (int) ($reviewerCountsByTrack[(string) ($app->track ?? '')] ?? 0); $completed = (int) ($stats['submitted_review_count'] ?? 0); return $required > 0 && $completed >= $required; } /** * @param list $ids * @return array */ private function reviewStatsForApplicationIds(array $ids): array { if (count($ids) === 0) { return []; } return ApplicationReviewScore::query() ->select([ 'application_id', DB::raw('COUNT(*) as submitted_review_count'), DB::raw('SUM(line_total) as team_sum'), DB::raw('AVG(line_total) as team_avg'), ]) ->whereIn('application_id', $ids) ->groupBy('application_id') ->get() ->mapWithKeys(fn ($row) => [ (int) $row->application_id => [ 'submitted_review_count' => (int) $row->submitted_review_count, 'team_sum' => $row->team_sum !== null ? (float) $row->team_sum : null, 'team_avg' => $row->team_avg !== null ? (float) $row->team_avg : null, ], ]) ->all(); } private function toListRow(Application $app, Competition $competition, $trackTitles, ?array $stats, $reviewerCountsByTrack = null): array { $required = (int) ($reviewerCountsByTrack?->get((string) ($app->track ?? '')) ?? 0); $completed = (int) ($stats['submitted_review_count'] ?? 0); $fullyCompleted = $required > 0 && $completed >= $required; $locale = SignupDisplay::applicationLocale($app); return [ 'id' => $app->id, 'project_code' => ProjectCode::resolve($app), 'status' => $app->status, 'signup_locale' => $locale, 'signup_locale_label' => SignupDisplay::localeTag($locale), 'project_name' => $app->project_name ?? '', 'player_name' => $app->player_name ?? '', 'school' => $app->school ?? '', 'degree' => $app->degree ?? '', 'degree_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'degree'), 'location_country_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'location_country'), 'contact_mobile' => $app->contact_mobile ?? '', 'submitter_name' => $app->user?->name ?? '', 'submitter_mobile' => $app->user?->mobile ?? '', 'event_location' => $app->event_location ?? '', 'event_location_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'event_location'), 'team_members' => $app->team_members ?? '', 'track_code' => $app->track ?? '', 'track_title' => SignupDisplay::trackTitleForApplication($competition, $app) ?: ($trackTitles->get($app->track ?? '') ?? ($app->track ?? '')), 'location_label' => SignupDisplay::formatLocation($app), 'signup_channel' => $app->signupChannel ? [ 'id' => $app->signupChannel->id, 'channel_code' => $app->signupChannel->channel_code, 'channel_name' => $app->signupChannel->channel_name, ] : null, 'signup_channel_code' => $app->signup_channel_code, 'recommend' => $app->recommend ?? '', 'submitted_at' => $app->submitted_at?->toIso8601String(), 'updated_at' => $app->updated_at?->toIso8601String(), 'files_count' => (int) ($app->files_count ?? 0), 'submitted_review_count' => $completed, 'review_required_count' => $required, 'review_progress' => $required > 0 ? ($completed.'/'.$required) : '-', 'review_fully_completed' => $fullyCompleted, 'team_sum' => $stats['team_sum'] ?? null, 'team_avg' => $stats['team_avg'] ?? null, 'review_result' => $app->review_result, 'review_result_note' => $app->review_result_note, 'review_result_at' => $app->review_result_at?->toIso8601String(), 'review_eligible' => $app->isReviewEligible(), 'public_source_channel' => $app->user?->publicSourceChannel ? [ 'id' => $app->user->publicSourceChannel->id, 'source_code' => $app->user->publicSourceChannel->source_code, 'source_name' => $app->user->publicSourceChannel->source_name, ] : null, 'public_source_code' => $app->user?->public_source_code, 'public_source_attributed_at' => $app->user?->public_source_attributed_at?->toIso8601String(), 'schema_display' => SignupSchemaLayout::applicationDisplayMap($competition, $app), ]; } private function formatLocation(Application $app): string { $country = trim((string) ($app->location_country ?? '')); if ($country === '海外' && ($app->oversea_country ?? '') !== '') { return '海外 / '.$app->oversea_country; } return implode(' / ', array_values(array_filter([ $app->location_country, $app->location_province, $app->location_city, ], fn ($v) => $v !== null && trim((string) $v) !== ''))); } private function toDetail(Application $app, Competition $competition, $trackTitles, ?array $stats, $reviewerCountsByTrack = null): array { $base = $this->toListRow($app, $competition, $trackTitles, $stats, $reviewerCountsByTrack); $locale = SignupDisplay::applicationLocale($app); return $base + [ 'competition' => [ 'id' => $competition->id, 'slug' => $competition->slug, 'name' => $competition->name, ], 'participant' => [ 'id' => $app->user?->id, 'mobile' => $app->user?->mobile, 'name' => $app->user?->name, 'email' => $app->user?->email, 'company' => $app->user?->company, ], 'contact_email' => $app->contact_email ?? '', 'degree' => $app->degree ?? '', 'company_name' => $app->company_name ?? '', 'location_country' => $app->location_country ?? '', 'location_province' => $app->location_province ?? '', 'location_city' => $app->location_city ?? '', 'oversea_country' => $app->oversea_country ?? '', 'event_location' => $app->event_location ?? '', 'team_members' => $app->team_members ?? '', 'intro' => $app->intro ?? '', 'recommend' => $app->recommend ?? '', 'answers_json' => is_array($app->answers_json) ? $app->answers_json : [], 'schema_fields' => $this->extraSchemaDetailFields($competition, $app), 'promise_signed_at' => $app->promise_signed_at?->toIso8601String(), 'promise_signed' => $app->promise_signed_at !== null, 'pledge_content_html' => $locale === PortalLocale::EN && filled($competition->pledge_content_html_en) ? $competition->pledge_content_html_en : ($competition->pledge_content_html ?? ''), 'promise_signature' => $app->promise_signature, 'signup_channel_state' => $app->signup_channel_state, 'files' => $app->files->map(fn (ApplicationFile $file) => [ 'id' => $file->id, 'kind' => $file->kind, 'original_name' => $file->original_name, 'size' => $file->size, 'mime' => $file->mime, ])->values(), 'review_scores' => $app->reviewScores->map(fn (ApplicationReviewScore $score) => [ 'id' => $score->id, 'reviewer_id' => $score->reviewer_id, 'reviewer_name' => $score->reviewer?->name, 'line_total' => $score->line_total !== null ? (string) $score->line_total : null, 'payload_json' => is_array($score->payload_json) ? $score->payload_json : [], 'comment' => $this->extractReviewComment(is_array($score->payload_json) ? $score->payload_json : []), 'updated_at' => $score->updated_at?->format('Y-m-d H:i:s'), ])->values(), ]; } /** * @param array $payload */ private function extractReviewComment(array $payload): string { foreach (['comment', 'review_comment', 'remark'] as $key) { $v = $payload[$key] ?? null; if (is_string($v) && trim($v) !== '') { return trim($v); } } return ''; } /** * @return list */ private function extraSchemaDetailFields(Competition $competition, Application $app): array { $shown = [ 'project_name', 'player_name', 'contact_email', 'contact_mobile', 'school', 'degree', 'track', 'company_name', 'location_country', 'location_province', 'location_city', 'oversea_country', 'event_location', 'team_members', 'recommend', 'intro', ]; $skip = array_flip($shown); $out = []; $display = SignupSchemaLayout::applicationDisplayMap($competition, $app); foreach (SignupSchemaLayout::adminListFields($competition) as $field) { if (isset($skip[$field['key']])) { continue; } $out[] = [ 'key' => $field['key'], 'label' => $field['label'], 'value' => (string) ($display[$field['key']] ?? ''), ]; } return $out; } }