$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); $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 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 * @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 ?? $app->files->count()), ]; } /** * @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 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'; } }