$filters */ public function countFiltered(Competition $competition, array $filters): int { return $this->buildFilteredQuery($competition, $filters)->count(); } /** * @param array $filters */ public function partCount(Competition $competition, array $filters, string $mode = self::MODE_ALL): int { if ($mode === self::MODE_XLSX) { return $this->countFiltered($competition, $filters) > 0 ? 1 : 0; } $attachmentIds = $this->attachmentApplicationIds($competition, $filters, $mode); if (count($attachmentIds) === 0) { return 0; } return (int) ceil(count($attachmentIds) / $this->partSize()); } public function modeIncludesAttachments(string $mode): bool { return in_array($mode, [self::MODE_SELECTED, self::MODE_ALL], true); } /** * @param array $filters */ public function assertExportable(Competition $competition, array $filters, string $mode = self::MODE_ALL): void { $applicationCount = $this->countFiltered($competition, $filters); if ($applicationCount === 0) { throw ValidationException::withMessages([ 'export' => ['当前筛选条件下没有可导出的报名记录'], ]); } if ($mode === self::MODE_SELECTED) { $selectedIds = $this->normalizeApplicationIds($filters['application_ids'] ?? []); if (count($selectedIds) === 0) { throw ValidationException::withMessages([ 'application_ids' => ['请至少选择一个项目以导出附件'], ]); } $validCount = $this->buildFilteredQuery($competition, $filters) ->whereIn('id', $selectedIds) ->count(); if ($validCount !== count($selectedIds)) { throw ValidationException::withMessages([ 'application_ids' => ['所选项目不在当前筛选结果内或不存在'], ]); } } if (! $this->modeIncludesAttachments($mode)) { return; } $attachmentIds = $this->attachmentApplicationIds($competition, $filters, $mode); $stats = ApplicationFile::query() ->whereIn('application_id', $attachmentIds) ->selectRaw('COUNT(*) as file_count, COALESCE(SUM(size), 0) as total_bytes') ->first(); $maxFiles = (int) config('contest.export.max_files', 5000); $maxBytes = (int) config('contest.export.max_total_bytes', 2 * 1024 * 1024 * 1024); $fileCount = (int) ($stats->file_count ?? 0); $totalBytes = (int) ($stats->total_bytes ?? 0); if ($fileCount > $maxFiles) { throw ValidationException::withMessages([ 'export' => ["附件数量过多({$fileCount} 个),超过导出上限 {$maxFiles},请缩小筛选范围"], ]); } if ($totalBytes > $maxBytes) { $maxLabel = $this->formatBytes($maxBytes); throw ValidationException::withMessages([ 'export' => ["附件总体积过大({$this->formatBytes($totalBytes)}),超过导出上限 {$maxLabel},请缩小筛选范围"], ]); } } /** * @param array $filters */ public function buildXlsxExportPath(Competition $competition, array $filters): string { $timeLimit = (int) config('contest.export.time_limit', 0); if ($timeLimit >= 0) { @set_time_limit($timeLimit); } $trackTitles = $competition->tracks()->pluck('title', 'track_code'); return $this->buildXlsxPathForAllFiltered($competition, $filters, $trackTitles); } public function buildXlsxFilename(Competition $competition): string { $prefix = preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $competition->name) ?: '赛事'; return sprintf('%s报名信息_%s.xlsx', $prefix, now()->format('Ymd_His')); } /** * @param array $filters */ public function buildPartZipPath(Competition $competition, array $filters, int $part, string $mode = self::MODE_ALL): string { $timeLimit = (int) config('contest.export.time_limit', 0); if ($timeLimit >= 0) { @set_time_limit($timeLimit); } $applications = $this->applicationsForPart($competition, $filters, $part, $mode); if ($applications->isEmpty()) { throw ValidationException::withMessages([ 'part' => ['导出分卷不存在或已无数据'], ]); } $trackTitles = $competition->tracks()->pluck('title', 'track_code'); $xlsxPath = $part === 1 ? $this->buildXlsxPathForAllFiltered($competition, $filters, $trackTitles) : null; $tmp = tempnam(sys_get_temp_dir(), 'app_export_zip_'); if ($tmp === false) { if ($xlsxPath !== null) { @unlink($xlsxPath); } throw new \RuntimeException('无法创建 ZIP 临时文件'); } $zipPath = $tmp.'.zip'; @unlink($tmp); $zip = new ZipArchive; if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($xlsxPath !== null) { @unlink($xlsxPath); } throw new \RuntimeException('无法创建 ZIP 文件'); } try { if ($xlsxPath !== null) { $zip->addFile($xlsxPath, '报名信息.xlsx'); } $usedFolderNames = []; $usedZipEntryNames = []; foreach ($applications as $application) { $this->appendApplicationFilesToZipArchive( $zip, $application, $usedFolderNames, $usedZipEntryNames, ); } if ($zip->close() !== true) { throw new \RuntimeException('ZIP 文件写入失败'); } } catch (\Throwable $e) { $zip->close(); @unlink($zipPath); if ($xlsxPath !== null) { @unlink($xlsxPath); } throw $e; } if ($xlsxPath !== null) { @unlink($xlsxPath); } return $zipPath; } public function buildPartFilename(Competition $competition, int $part, int $partCount): string { $prefix = preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $competition->name) ?: '赛事'; return sprintf( '%s报名信息导出_第%d-%d部分_%s.zip', $prefix, $part, $partCount, now()->format('Ymd_His') ); } /** * @param array $filters * @return list */ private function attachmentApplicationIds(Competition $competition, array $filters, string $mode): array { if ($mode === self::MODE_SELECTED) { return $this->normalizeApplicationIds($filters['application_ids'] ?? []); } return $this->buildFilteredQuery($competition, $filters) ->orderBy('id') ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); } /** * @param mixed $raw * @return list */ private function normalizeApplicationIds(mixed $raw): array { if (! is_array($raw)) { return []; } $ids = []; foreach ($raw as $id) { if (is_numeric($id)) { $ids[] = (int) $id; } } return array_values(array_unique(array_filter($ids, fn (int $id) => $id > 0))); } /** * @param array $filters * @return Collection */ private function applicationsForPart(Competition $competition, array $filters, int $part, string $mode): Collection { if ($part < 1) { return collect(); } $attachmentIds = $this->attachmentApplicationIds($competition, $filters, $mode); if (count($attachmentIds) === 0) { return collect(); } $partSize = $this->partSize(); $offset = ($part - 1) * $partSize; $ids = array_slice($attachmentIds, $offset, $partSize); if (count($ids) === 0) { return collect(); } return Application::query() ->whereIn('id', $ids) ->with(['files']) ->orderBy('id') ->get(); } /** * @param Collection $trackTitles * @param array $filters */ private function buildXlsxPathForAllFiltered(Competition $competition, array $filters, Collection $trackTitles): string { $tmp = tempnam(sys_get_temp_dir(), 'app_export_xlsx_'); if ($tmp === false) { throw new \RuntimeException('无法创建 Excel 临时文件'); } $xlsxPath = $tmp.'.xlsx'; @unlink($tmp); $writer = new Writer; $writer->openToFile($xlsxPath); $competition->loadMissing('formSchema'); $knownExportKeys = [ 'project_name', 'player_name', 'school', 'degree', 'contact_mobile', 'contact_email', 'event_location', 'team_members', 'company_name', 'track', 'location_country', 'recommend', 'intro', ]; $extraFieldKeys = []; $extraHeaders = []; foreach (SignupSchemaLayout::adminListFields($competition) as $field) { if (in_array($field['key'], $knownExportKeys, true)) { continue; } $extraHeaders[] = $field['label']; if ($field['type'] === 'select') { $extraHeaders[] = $field['label'].'编码'; } $extraFieldKeys[] = $field; } $writer->addRow(Row::fromValues(array_merge([ '项目编号', '状态', '项目名称', '申请人姓名', '毕业院校', '最高学历', '申请人电话', '注册邮箱', '意向参赛地点', '团队成员', '企业名称', '细分赛道', '项目所在地', '推荐方', '项目简介', '提交时间', '附件数量', '进程', '填报语言', '总分', '平均分', '评分详情', ], $extraHeaders))); $assignments = ReviewAssignmentIndex::forCompetition((int) $competition->id); $chunkSize = max(20, (int) config('contest.export.chunk_size', 20)); $this->buildFilteredQuery($competition, $filters) ->with(['signupChannel', 'reviewScores.reviewer']) ->withCount('files') ->orderBy('id') ->chunkById($chunkSize, function ($applications) use ($writer, $competition, $trackTitles, $assignments, $extraFieldKeys): void { foreach ($applications as $app) { $writer->addRow(Row::fromValues( $this->applicationExportRow($app, $competition, $trackTitles, $assignments, $extraFieldKeys) )); } }, 'id'); $writer->close(); return $xlsxPath; } /** * @param array $usedFolderNames * @param array $usedZipEntryNames */ private function appendApplicationFilesToZipArchive( ZipArchive $zip, Application $application, array &$usedFolderNames, array &$usedZipEntryNames, ): void { $folder = $this->uniqueFolderName($application, $usedFolderNames); $usedFolderNames[$folder] = true; $zip->addEmptyDir($folder); foreach ($application->files as $file) { $absolutePath = $this->resolveFileAbsolutePath($file); if ($absolutePath === null) { continue; } $entryName = $this->uniqueFileNameInFolderTracked($folder, $file, $usedZipEntryNames); $zip->addFile($absolutePath, $entryName); } } /** * @param Collection $trackTitles * @param list $extraFieldKeys * @return list */ private function applicationExportRow(Application $app, Competition $competition, Collection $trackTitles, ReviewAssignmentIndex $assignments, array $extraFieldKeys = []): array { $assignedReviewers = $assignments->reviewersFor($app); $scoresByReviewerId = []; if ($app->relationLoaded('reviewScores')) { foreach ($app->reviewScores as $score) { $scoresByReviewerId[(int) $score->reviewer_id] = $score; } } $progressInfo = $assignments->progress($app, array_keys($scoresByReviewerId)); $progress = $progressInfo['progress']; $fullyCompleted = $progressInfo['fully_completed']; $numericScores = []; foreach ($scoresByReviewerId as $score) { if ($score->line_total !== null && is_numeric($score->line_total)) { $numericScores[] = (float) $score->line_total; } } $teamSum = $numericScores === [] ? null : TrackScoringSheet::roundScore(array_sum($numericScores)); $teamAvg = $numericScores === [] ? null : TrackScoringSheet::roundScore($teamSum / count($numericScores)); $totalCell = $fullyCompleted ? $this->formatExportScore($teamSum) : '待评审'; $avgCell = $teamAvg === null ? '-' : $this->formatExportScore($teamAvg); $scoreDetails = collect($assignedReviewers) ->map(function (array $reviewer) use ($scoresByReviewerId): string { $name = $reviewer['name']; $score = $scoresByReviewerId[$reviewer['id']] ?? null; if ($score === null) { return $name.' 未评审'; } return $name.' '.$this->formatExportScore($score->line_total); }) ->implode(';'); $row = [ ProjectCode::resolve($app), $app->status === 'submitted' ? '已提交' : '草稿', $app->project_name ?? '', $app->player_name ?? '', $app->school ?? '', SignupDisplay::applicationFieldLabel($competition, $app, 'degree') ?: ($app->degree ?? ''), $app->contact_mobile ?? '', $app->contact_email ?? '', SignupDisplay::applicationFieldLabel($competition, $app, 'event_location') ?: ($app->event_location ?? ''), $app->team_members ?? '', $app->company_name ?? '', SignupDisplay::trackTitleForApplication($competition, $app) ?: ($trackTitles->get($app->track ?? '') ?? ($app->track ?? '')), SignupDisplay::formatLocation($app, SignupDisplay::applicationLocale($app)), $app->recommend ?? '', $app->intro ?? '', $app->submitted_at?->format('Y-m-d H:i:s') ?? '', (int) ($app->files_count ?? $app->files->count()), $progress, SignupDisplay::localeTag(SignupDisplay::applicationLocale($app)), $totalCell, $avgCell, $scoreDetails, ]; $display = SignupSchemaLayout::applicationDisplayMap($competition, $app); foreach ($extraFieldKeys as $field) { $row[] = $display[$field['key']] ?? ''; if ($field['type'] === 'select') { $raw = SignupDisplay::applicationFieldValue($app, $field['key']); $row[] = is_scalar($raw) ? (string) $raw : ''; } } return $row; } private function formatExportScore(mixed $value): string { if ($value === null || $value === '') { return '—'; } if (! is_numeric($value)) { return (string) $value; } return TrackScoringSheet::formatScore($value); } /** * @param array $filters */ public function buildFilteredQuery(Competition $competition, array $filters): Builder { $query = Application::query()->where('competition_id', $competition->id); $status = trim((string) ($filters['status'] ?? '')); if ($status !== '') { $query->where('status', $status); } $track = trim((string) ($filters['track'] ?? '')); if ($track !== '') { $query->where('track', $track); } $eventLocation = trim((string) ($filters['event_location'] ?? '')); if ($eventLocation !== '') { $query->where('event_location', $eventLocation); } SignupSchemaLayout::applyFilters($query, $competition, $filters); $signupLocale = trim((string) ($filters['signup_locale'] ?? '')); if ($signupLocale !== '' && Schema::hasColumn((new Application)->getTable(), 'signup_locale')) { $query->where('signup_locale', $signupLocale); } $channelId = $filters['signup_channel_id'] ?? null; if ($channelId !== null && $channelId !== '') { $query->where('signup_channel_id', (int) $channelId); } $publicSourceChannelId = $filters['public_source_channel_id'] ?? null; if ($publicSourceChannelId !== null && $publicSourceChannelId !== '') { $query->whereHas('user', function (Builder $userQuery) use ($publicSourceChannelId): void { $userQuery->where('public_source_channel_id', (int) $publicSourceChannelId); }); } $keyword = trim((string) ($filters['keyword'] ?? '')); if ($keyword !== '') { $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $keyword).'%'; $query->where(function ($q) use ($like): void { $q->where('player_name', 'like', $like) ->orWhere('project_name', 'like', $like) ->orWhere('school', 'like', $like) ->orWhere('contact_mobile', 'like', $like) ->orWhere('company_name', 'like', $like) ->orWhere('project_code', 'like', $like) ->orWhere('recommend', 'like', $like); }); } $reviewResult = trim((string) ($filters['review_result'] ?? '')); if ($reviewResult === 'pending') { $query->whereNull('review_result'); } elseif ($reviewResult !== '') { $query->where('review_result', $reviewResult); } if (array_key_exists('review_eligible', $filters) && $filters['review_eligible'] !== null && $filters['review_eligible'] !== '') { $query->where('review_eligible', filter_var($filters['review_eligible'], FILTER_VALIDATE_BOOLEAN)); } return $query; } private function resolveFileAbsolutePath(ApplicationFile $file): ?string { $disk = Storage::disk($file->disk); if (! $disk->exists($file->path)) { return null; } $absolutePath = $disk->path($file->path); if (! is_file($absolutePath)) { return null; } return $absolutePath; } 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) !== ''))); } /** * @param array $usedFolderNames */ private function uniqueFolderName(Application $application, array &$usedFolderNames): string { $base = $this->sanitizePathSegment( ProjectCode::resolve($application).($application->player_name ?? '未知') ); $folder = $base; $suffix = 2; while (isset($usedFolderNames[$folder])) { $folder = $base.'_'.$suffix; $suffix++; } return $folder; } /** * @param array $usedZipEntryNames */ private function uniqueFileNameInFolderTracked( string $folder, ApplicationFile $file, array &$usedZipEntryNames, ): string { $name = $file->clientDownloadName(); $candidate = $folder.'/'.$name; if (! isset($usedZipEntryNames[$candidate])) { $usedZipEntryNames[$candidate] = true; return $candidate; } $ext = pathinfo($name, PATHINFO_EXTENSION); $base = pathinfo($name, PATHINFO_FILENAME); $suffix = 2; while (true) { $next = $folder.'/'.($ext !== '' ? $base.'_'.$suffix.'.'.$ext : $base.'_'.$suffix); if (! isset($usedZipEntryNames[$next])) { $usedZipEntryNames[$next] = true; return $next; } $suffix++; } } private function sanitizePathSegment(string $value): string { $value = trim(str_replace(['\\', '/'], '_', $value)); $value = preg_replace('/[<>:"|?*]/u', '_', $value) ?? $value; $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? $value; return $value !== '' ? $value : '未命名'; } private function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes.'B'; } if ($bytes < 1024 * 1024) { return round($bytes / 1024, 1).'KB'; } if ($bytes < 1024 * 1024 * 1024) { return round($bytes / 1024 / 1024, 1).'MB'; } return round($bytes / 1024 / 1024 / 1024, 2).'GB'; } }