$filters */ public function assertExportable(Competition $competition, array $filters): void { $query = $this->buildFilteredQuery($competition, $filters); $applicationCount = (clone $query)->count(); if ($applicationCount === 0) { throw ValidationException::withMessages([ 'export' => ['当前筛选条件下没有可导出的报名记录'], ]); } $applicationIds = (clone $query)->select('applications.id'); $stats = ApplicationFile::query() ->whereIn('application_id', $applicationIds) ->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 * @param resource $outputStream */ public function streamZip(Competition $competition, array $filters, $outputStream): void { $timeLimit = (int) config('contest.export.time_limit', 0); if ($timeLimit >= 0) { @set_time_limit($timeLimit); } $trackTitles = $competition->tracks()->pluck('title', 'track_code'); $xlsxPath = $this->buildXlsxPathChunked($competition, $filters, $trackTitles); try { $zip = new ZipStream( operationMode: OperationMode::NORMAL, outputStream: $outputStream, sendHttpHeaders: false, enableZip64: true, flushOutput: true, ); $zip->addFileFromPath( fileName: '报名信息.xlsx', path: $xlsxPath, compressionMethod: CompressionMethod::STORE, ); $usedFolderNames = []; $usedZipEntryNames = []; $chunkSize = max(5, (int) config('contest.export.chunk_size', 20)); $this->buildFilteredQuery($competition, $filters) ->with(['files']) ->orderBy('id') ->chunkById($chunkSize, function ($applications) use ($zip, &$usedFolderNames, &$usedZipEntryNames): void { foreach ($applications as $application) { $this->appendApplicationFilesToZip( $zip, $application, $usedFolderNames, $usedZipEntryNames, ); } }, 'id'); $zip->finish(); } finally { @unlink($xlsxPath); } } /** * @param array $usedFolderNames * @param array $usedZipEntryNames */ private function appendApplicationFilesToZip( ZipStream $zip, Application $application, array &$usedFolderNames, array &$usedZipEntryNames, ): void { $folder = $this->uniqueFolderName($application, $usedFolderNames); $usedFolderNames[$folder] = true; $zip->addDirectory($folder.'/'); foreach ($application->files as $file) { $absolutePath = $this->resolveFileAbsolutePath($file); if ($absolutePath === null) { continue; } $entryName = $this->uniqueFileNameInFolderTracked($folder, $file, $usedZipEntryNames); $zip->addFileFromPath( fileName: $entryName, path: $absolutePath, compressionMethod: $this->compressionMethodForPath($absolutePath), ); } } /** * @param Collection $trackTitles * @param array $filters */ private function buildXlsxPathChunked(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); $writer->addRow(Row::fromValues([ '项目编号', '状态', '项目名称', '负责人', '学校', '学历', '手机号', '邮箱', '组别', '企业名称', '赛道', '所在地', '报名渠道', '项目简介', '提交时间', '附件数量', ])); $chunkSize = max(20, (int) config('contest.export.chunk_size', 20)); $this->buildFilteredQuery($competition, $filters) ->with(['signupChannel']) ->withCount('files') ->orderBy('id') ->chunkById($chunkSize, function ($applications) use ($writer, $trackTitles): void { foreach ($applications as $app) { $writer->addRow(Row::fromValues($this->applicationExportRow($app, $trackTitles))); } }, 'id'); $writer->close(); return $xlsxPath; } /** * @param Collection $trackTitles * @return list */ private function applicationExportRow(Application $app, Collection $trackTitles): array { $channel = $app->signupChannel ? $app->signupChannel->channel_name.'('.$app->signupChannel->channel_code.')' : ($app->signup_channel_code ?: ''); return [ ProjectCode::resolve($app), $app->status === 'submitted' ? '已提交' : '草稿', $app->project_name ?? '', $app->player_name ?? '', $app->school ?? '', $app->degree ?? '', $app->contact_mobile ?? '', $app->contact_email ?? '', $app->entry_group ?? '', $app->company_name ?? '', $trackTitles->get($app->track ?? '') ?? ($app->track ?? ''), $this->formatLocation($app), $channel, $app->intro ?? '', $app->submitted_at?->format('Y-m-d H:i:s') ?? '', (int) ($app->files_count ?? 0), ]; } /** * @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); } $channelId = $filters['signup_channel_id'] ?? null; if ($channelId !== null && $channelId !== '') { $query->where('signup_channel_id', (int) $channelId); } $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); }); } 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 compressionMethodForPath(string $absolutePath): CompressionMethod { $ext = strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION)); return in_array($ext, ['zip', 'rar', '7z', 'gz', 'pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx'], true) ? CompressionMethod::STORE : CompressionMethod::DEFLATE; } 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'; } }