|
|
<?php
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
use App\Models\Application;
|
|
|
use App\Models\ApplicationFile;
|
|
|
use App\Models\Competition;
|
|
|
use App\Models\ReviewerScope;
|
|
|
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 ZipArchive;
|
|
|
|
|
|
class AdminApplicationExportService
|
|
|
{
|
|
|
public const MODE_XLSX = 'xlsx';
|
|
|
|
|
|
public const MODE_SELECTED = 'selected';
|
|
|
|
|
|
public const MODE_ALL = 'all';
|
|
|
|
|
|
public function partSize(): int
|
|
|
{
|
|
|
return max(1, (int) config('contest.export.part_size', 10));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $filters
|
|
|
*/
|
|
|
public function countFiltered(Competition $competition, array $filters): int
|
|
|
{
|
|
|
return $this->buildFilteredQuery($competition, $filters)->count();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $filters
|
|
|
* @return list<int>
|
|
|
*/
|
|
|
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<int>
|
|
|
*/
|
|
|
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<string, mixed> $filters
|
|
|
* @return Collection<int, Application>
|
|
|
*/
|
|
|
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<string, string> $trackTitles
|
|
|
* @param array<string, mixed> $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([
|
|
|
'项目编号',
|
|
|
'状态',
|
|
|
'项目名称',
|
|
|
'负责人',
|
|
|
'学校',
|
|
|
'学历',
|
|
|
'手机号',
|
|
|
'邮箱',
|
|
|
'组别',
|
|
|
'企业名称',
|
|
|
'赛道',
|
|
|
'所在地',
|
|
|
'报名渠道',
|
|
|
'推荐方',
|
|
|
'项目简介',
|
|
|
'提交时间',
|
|
|
'附件数量',
|
|
|
'进程',
|
|
|
'总分',
|
|
|
'平均分',
|
|
|
'评分详情',
|
|
|
]));
|
|
|
|
|
|
$reviewersByTrack = $this->reviewersByTrackForCompetition($competition);
|
|
|
|
|
|
$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, $trackTitles, $reviewersByTrack): void {
|
|
|
foreach ($applications as $app) {
|
|
|
$writer->addRow(Row::fromValues(
|
|
|
$this->applicationExportRow($app, $trackTitles, $reviewersByTrack)
|
|
|
));
|
|
|
}
|
|
|
}, 'id');
|
|
|
|
|
|
$writer->close();
|
|
|
|
|
|
return $xlsxPath;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, true> $usedFolderNames
|
|
|
* @param array<string, true> $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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 按赛道预加载评委名单(含姓名),用于导出进程与评分详情。
|
|
|
*
|
|
|
* @return Collection<string, list<array{id: int, name: string}>>
|
|
|
*/
|
|
|
private function reviewersByTrackForCompetition(Competition $competition): Collection
|
|
|
{
|
|
|
$grouped = [];
|
|
|
ReviewerScope::query()
|
|
|
->with('reviewer')
|
|
|
->where('competition_id', $competition->id)
|
|
|
->orderBy('id')
|
|
|
->get()
|
|
|
->each(function (ReviewerScope $scope) use (&$grouped): void {
|
|
|
$track = (string) ($scope->track_code ?? '');
|
|
|
if ($track === '') {
|
|
|
return;
|
|
|
}
|
|
|
$reviewerId = (int) $scope->reviewer_id;
|
|
|
$name = trim((string) ($scope->reviewer?->name ?? ''));
|
|
|
if ($name === '') {
|
|
|
$name = '评委#'.$reviewerId;
|
|
|
}
|
|
|
$grouped[$track] ??= [];
|
|
|
$grouped[$track][$reviewerId] = [
|
|
|
'id' => $reviewerId,
|
|
|
'name' => $name,
|
|
|
];
|
|
|
});
|
|
|
|
|
|
return collect($grouped)->map(fn (array $rows) => array_values($rows));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param Collection<string, string> $trackTitles
|
|
|
* @param Collection<string, list<array{id: int, name: string}>> $reviewersByTrack
|
|
|
* @return list<scalar|null>
|
|
|
*/
|
|
|
private function applicationExportRow(Application $app, Collection $trackTitles, Collection $reviewersByTrack): array
|
|
|
{
|
|
|
$channel = $app->signupChannel
|
|
|
? $app->signupChannel->channel_name.'('.$app->signupChannel->channel_code.')'
|
|
|
: ($app->signup_channel_code ?: '');
|
|
|
|
|
|
$trackReviewers = $reviewersByTrack->get((string) ($app->track ?? '')) ?? [];
|
|
|
$required = count($trackReviewers);
|
|
|
$scoresByReviewerId = [];
|
|
|
if ($app->relationLoaded('reviewScores')) {
|
|
|
foreach ($app->reviewScores as $score) {
|
|
|
$scoresByReviewerId[(int) $score->reviewer_id] = $score;
|
|
|
}
|
|
|
}
|
|
|
$completed = count($scoresByReviewerId);
|
|
|
$progress = $required > 0 ? ($completed.'/'.$required) : '-';
|
|
|
$fullyCompleted = $required > 0 && $completed >= $required;
|
|
|
|
|
|
$numericScores = [];
|
|
|
foreach ($scoresByReviewerId as $score) {
|
|
|
if ($score->line_total !== null && is_numeric($score->line_total)) {
|
|
|
$numericScores[] = (float) $score->line_total;
|
|
|
}
|
|
|
}
|
|
|
$teamSum = $numericScores === [] ? null : array_sum($numericScores);
|
|
|
$teamAvg = $numericScores === [] ? null : ($teamSum / count($numericScores));
|
|
|
$totalCell = $fullyCompleted ? $this->formatExportScore($teamSum) : '待评审';
|
|
|
$avgCell = $fullyCompleted ? $this->formatExportScore($teamAvg) : '-';
|
|
|
|
|
|
$scoreDetails = collect($trackReviewers)
|
|
|
->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(';');
|
|
|
|
|
|
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->recommend ?? '',
|
|
|
$app->intro ?? '',
|
|
|
$app->submitted_at?->format('Y-m-d H:i:s') ?? '',
|
|
|
(int) ($app->files_count ?? $app->files->count()),
|
|
|
$progress,
|
|
|
$totalCell,
|
|
|
$avgCell,
|
|
|
$scoreDetails,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private function formatExportScore(mixed $value): string
|
|
|
{
|
|
|
if ($value === null || $value === '') {
|
|
|
return '—';
|
|
|
}
|
|
|
if (! is_numeric($value)) {
|
|
|
return (string) $value;
|
|
|
}
|
|
|
|
|
|
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.') ?: '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);
|
|
|
}
|
|
|
|
|
|
$entryGroup = trim((string) ($filters['entry_group'] ?? ''));
|
|
|
if ($entryGroup !== '') {
|
|
|
$query->where('entry_group', $entryGroup);
|
|
|
}
|
|
|
|
|
|
$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<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 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';
|
|
|
}
|
|
|
}
|