You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

372 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services;
use App\Models\Application;
use App\Models\ApplicationFile;
use App\Models\Competition;
use App\Support\ProjectCode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipStream\CompressionMethod;
use ZipStream\OperationMode;
use ZipStream\ZipStream;
class AdminApplicationExportService
{
/**
* @param array<string, mixed> $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<string, mixed> $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<string, true> $usedFolderNames
* @param array<string, true> $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<string, string> $trackTitles
* @param array<string, mixed> $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<string, string> $trackTitles
* @return list<scalar|null>
*/
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<string, mixed> $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<string, true> $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<string, true> $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';
}
}