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.

667 lines
23 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 App\Support\ReviewAssignmentIndex;
use App\Support\SignupDisplay;
use App\Support\SignupSchemaLayout;
use App\Support\TrackScoringSheet;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
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);
$competition->loadMissing('formSchema');
$knownExportKeys = [
'project_name', 'player_name', 'school', 'degree', 'contact_mobile', 'contact_email',
'event_location', 'team_members', 'company_name', 'track', 'location_country',
'recommend', 'intro',
];
$extraFieldKeys = [];
$extraHeaders = [];
foreach (SignupSchemaLayout::adminListFields($competition) as $field) {
if (in_array($field['key'], $knownExportKeys, true)) {
continue;
}
$extraHeaders[] = $field['label'];
if ($field['type'] === 'select') {
$extraHeaders[] = $field['label'].'编码';
}
$extraFieldKeys[] = $field;
}
$writer->addRow(Row::fromValues(array_merge([
'项目编号',
'状态',
'项目名称',
'申请人姓名',
'毕业院校',
'最高学历',
'申请人电话',
'注册邮箱',
'意向参赛地点',
'团队成员',
'企业名称',
'细分赛道',
'项目所在地',
'推荐方',
'项目简介',
'提交时间',
'附件数量',
'进程',
'填报语言',
'总分',
'平均分',
'评分详情',
], $extraHeaders)));
$assignments = ReviewAssignmentIndex::forCompetition((int) $competition->id);
$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, $competition, $trackTitles, $assignments, $extraFieldKeys): void {
foreach ($applications as $app) {
$writer->addRow(Row::fromValues(
$this->applicationExportRow($app, $competition, $trackTitles, $assignments, $extraFieldKeys)
));
}
}, '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);
}
}
/**
* @param Collection<string, string> $trackTitles
* @param list<array{key: string, type: string, label: string}> $extraFieldKeys
* @return list<scalar|null>
*/
private function applicationExportRow(Application $app, Competition $competition, Collection $trackTitles, ReviewAssignmentIndex $assignments, array $extraFieldKeys = []): array
{
$assignedReviewers = $assignments->reviewersFor($app);
$scoresByReviewerId = [];
if ($app->relationLoaded('reviewScores')) {
foreach ($app->reviewScores as $score) {
$scoresByReviewerId[(int) $score->reviewer_id] = $score;
}
}
$progressInfo = $assignments->progress($app, array_keys($scoresByReviewerId));
$progress = $progressInfo['progress'];
$fullyCompleted = $progressInfo['fully_completed'];
$numericScores = [];
foreach ($scoresByReviewerId as $score) {
if ($score->line_total !== null && is_numeric($score->line_total)) {
$numericScores[] = (float) $score->line_total;
}
}
$teamSum = $numericScores === [] ? null : TrackScoringSheet::roundScore(array_sum($numericScores));
$teamAvg = $numericScores === [] ? null : TrackScoringSheet::roundScore($teamSum / count($numericScores));
$totalCell = $fullyCompleted ? $this->formatExportScore($teamSum) : '待评审';
$avgCell = $teamAvg === null ? '-' : $this->formatExportScore($teamAvg);
$scoreDetails = collect($assignedReviewers)
->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('');
$row = [
ProjectCode::resolve($app),
$app->status === 'submitted' ? '已提交' : '草稿',
$app->project_name ?? '',
$app->player_name ?? '',
$app->school ?? '',
SignupDisplay::applicationFieldLabel($competition, $app, 'degree') ?: ($app->degree ?? ''),
$app->contact_mobile ?? '',
$app->contact_email ?? '',
SignupDisplay::applicationFieldLabel($competition, $app, 'event_location') ?: ($app->event_location ?? ''),
$app->team_members ?? '',
$app->company_name ?? '',
SignupDisplay::trackTitleForApplication($competition, $app) ?: ($trackTitles->get($app->track ?? '') ?? ($app->track ?? '')),
SignupDisplay::formatLocation($app, SignupDisplay::applicationLocale($app)),
$app->recommend ?? '',
$app->intro ?? '',
$app->submitted_at?->format('Y-m-d H:i:s') ?? '',
(int) ($app->files_count ?? $app->files->count()),
$progress,
SignupDisplay::localeTag(SignupDisplay::applicationLocale($app)),
$totalCell,
$avgCell,
$scoreDetails,
];
$display = SignupSchemaLayout::applicationDisplayMap($competition, $app);
foreach ($extraFieldKeys as $field) {
$row[] = $display[$field['key']] ?? '';
if ($field['type'] === 'select') {
$raw = SignupDisplay::applicationFieldValue($app, $field['key']);
$row[] = is_scalar($raw) ? (string) $raw : '';
}
}
return $row;
}
private function formatExportScore(mixed $value): string
{
if ($value === null || $value === '') {
return '—';
}
if (! is_numeric($value)) {
return (string) $value;
}
return TrackScoringSheet::formatScore($value);
}
/**
* @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);
}
$eventLocation = trim((string) ($filters['event_location'] ?? ''));
if ($eventLocation !== '') {
$query->where('event_location', $eventLocation);
}
SignupSchemaLayout::applyFilters($query, $competition, $filters);
$signupLocale = trim((string) ($filters['signup_locale'] ?? ''));
if ($signupLocale !== '' && Schema::hasColumn((new Application)->getTable(), 'signup_locale')) {
$query->where('signup_locale', $signupLocale);
}
$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';
}
}