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

3 months ago
<?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;
3 months ago
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;
3 months ago
class AdminApplicationExportService
{
/**
* @param array<string, mixed> $filters
*/
3 months ago
public function assertExportable(Competition $competition, array $filters): void
3 months ago
{
3 months ago
$query = $this->buildFilteredQuery($competition, $filters);
$applicationCount = (clone $query)->count();
3 months ago
3 months ago
if ($applicationCount === 0) {
throw ValidationException::withMessages([
'export' => ['当前筛选条件下没有可导出的报名记录'],
]);
3 months ago
}
3 months ago
$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},请缩小筛选范围"],
]);
3 months ago
}
3 months ago
if ($totalBytes > $maxBytes) {
$maxLabel = $this->formatBytes($maxBytes);
throw ValidationException::withMessages([
'export' => ["附件总体积过大({$this->formatBytes($totalBytes)}),超过导出上限 {$maxLabel},请缩小筛选范围"],
]);
}
}
3 months ago
3 months ago
/**
* @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);
}
3 months ago
3 months ago
$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);
3 months ago
}
3 months ago
}
3 months ago
3 months ago
/**
* @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;
}
3 months ago
3 months ago
$entryName = $this->uniqueFileNameInFolderTracked($folder, $file, $usedZipEntryNames);
$zip->addFileFromPath(
fileName: $entryName,
path: $absolutePath,
compressionMethod: $this->compressionMethodForPath($absolutePath),
);
}
3 months ago
}
/**
* @param Collection<string, string> $trackTitles
3 months ago
* @param array<string, mixed> $filters
3 months ago
*/
3 months ago
private function buildXlsxPathChunked(Competition $competition, array $filters, Collection $trackTitles): string
3 months ago
{
3 months ago
$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([
3 months ago
'项目编号',
'状态',
'项目名称',
'负责人',
'学校',
'学历',
'手机号',
'邮箱',
'组别',
'企业名称',
'赛道',
'所在地',
'报名渠道',
'项目简介',
'提交时间',
'附件数量',
3 months ago
]));
$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');
3 months ago
3 months ago
$writer->close();
3 months ago
return $xlsxPath;
}
/**
3 months ago
* @param Collection<string, string> $trackTitles
* @return list<scalar|null>
3 months ago
*/
3 months ago
private function applicationExportRow(Application $app, Collection $trackTitles): array
3 months ago
{
3 months ago
$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),
];
3 months ago
}
/**
* @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;
}
3 months ago
/**
* @param array<string, true> $usedZipEntryNames
*/
private function uniqueFileNameInFolderTracked(
string $folder,
ApplicationFile $file,
array &$usedZipEntryNames,
): string {
3 months ago
$name = $file->clientDownloadName();
$candidate = $folder.'/'.$name;
3 months ago
if (! isset($usedZipEntryNames[$candidate])) {
$usedZipEntryNames[$candidate] = true;
3 months ago
return $candidate;
}
$ext = pathinfo($name, PATHINFO_EXTENSION);
$base = pathinfo($name, PATHINFO_FILENAME);
$suffix = 2;
while (true) {
$next = $folder.'/'.($ext !== '' ? $base.'_'.$suffix.'.'.$ext : $base.'_'.$suffix);
3 months ago
if (! isset($usedZipEntryNames[$next])) {
$usedZipEntryNames[$next] = true;
3 months ago
return $next;
}
$suffix++;
}
}
3 months ago
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;
}
3 months ago
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 : '未命名';
}
3 months ago
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';
}
3 months ago
}