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.

596 lines
26 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\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\AdminUser;
use App\Models\Application;
use App\Models\ApplicationFile;
use App\Models\ApplicationReviewScore;
use App\Models\Competition;
use App\Models\ReviewerScope;
use App\Models\User;
use App\Services\AdminApplicationExportService;
use App\Support\ProjectCode;
use App\Support\SignupDisplay;
use App\Support\SignupSchemaLayout;
use App\Support\PortalLocale;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ApplicationController extends Controller
{
public function __construct(
private readonly AdminApplicationExportService $exportService,
) {}
public function index(Request $request, Competition $competition): JsonResponse
{
$data = $this->validatedListFilters($request, $competition);
$competition->loadMissing(['formSchema', 'tracks']);
$query = $this->exportService->buildFilteredQuery($competition, $data)
->with(['user.publicSourceChannel'])
->withCount(['files', 'reviewScores']);
$this->applyListSorting($query, $data);
$perPage = min((int) ($data['per_page'] ?? 15), 100);
$paginator = $query->paginate($perPage);
$stats = $this->reviewStatsForApplicationIds(
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all()
);
$trackTitles = $competition->tracks()->pluck('title', 'track_code');
$reviewerCountsByTrack = ReviewerScope::query()
->select('track_code', DB::raw('COUNT(*) as total'))
->where('competition_id', $competition->id)
->groupBy('track_code')
->pluck('total', 'track_code');
$paginator->getCollection()->transform(function (Application $app) use ($competition, $trackTitles, $stats, $reviewerCountsByTrack) {
$app->loadMissing('signupChannel');
return $this->toListRow($app, $competition, $trackTitles, $stats[(int) $app->id] ?? null, $reviewerCountsByTrack);
});
$payload = $paginator->toArray();
$payload['schema_list_fields'] = SignupSchemaLayout::adminListFields($competition);
$payload['schema_filter_fields'] = SignupSchemaLayout::adminFilterFields($competition);
return response()->json($payload);
}
public function updateReviewEligible(Request $request, Competition $competition, Application $application): JsonResponse
{
if ((int) $application->competition_id !== (int) $competition->id) {
abort(404);
}
$data = $request->validate([
'review_eligible' => ['required', 'boolean'],
]);
$application->forceFill([
'review_eligible' => (bool) $data['review_eligible'],
])->save();
return response()->json([
'id' => $application->id,
'review_eligible' => $application->isReviewEligible(),
]);
}
public function batchReviewResult(Request $request, Competition $competition): JsonResponse
{
$data = $request->validate([
'application_ids' => ['required', 'array', 'min:1'],
'application_ids.*' => ['integer', 'min:1'],
'result' => ['required', 'string', Rule::in(['passed', 'rejected'])],
'note' => ['nullable', 'string', 'max:5000'],
]);
$ids = array_values(array_unique(array_map('intval', $data['application_ids'])));
$apps = Application::query()
->where('competition_id', $competition->id)
->whereIn('id', $ids)
->get();
if ($apps->count() !== count($ids)) {
throw ValidationException::withMessages(['application_ids' => ['存在无效的项目编号']]);
}
$reviewerCountsByTrack = ReviewerScope::query()
->select('track_code', DB::raw('COUNT(*) as total'))
->where('competition_id', $competition->id)
->groupBy('track_code')
->pluck('total', 'track_code');
$stats = $this->reviewStatsForApplicationIds($ids);
foreach ($apps as $app) {
if (! $this->isReviewFullyCompleted($app, $stats[(int) $app->id] ?? null, $reviewerCountsByTrack)) {
throw ValidationException::withMessages([
'application_ids' => ['仅评审已全部完成的项目可设置通过/未通过状态'],
]);
}
}
Application::query()
->where('competition_id', $competition->id)
->whereIn('id', $ids)
->update([
'review_result' => $data['result'],
'review_result_note' => $data['note'] ?? null,
'review_result_at' => now(),
]);
return response()->json(['updated' => count($ids)]);
}
public function exportMeta(Request $request, Competition $competition): JsonResponse
{
$filters = $this->validatedExportMetaFilters($request, $competition);
$mode = (string) $filters['mode'];
$this->exportService->assertExportable($competition, $filters, $mode);
$total = $this->exportService->countFiltered($competition, $filters);
$partSize = $this->exportService->partSize();
$partCount = $this->exportService->partCount($competition, $filters, $mode);
return response()->json([
'total' => $total,
'part_size' => $partSize,
'part_count' => $partCount,
'mode' => $mode,
'includes_attachments' => $this->exportService->modeIncludesAttachments($mode),
]);
}
public function export(Request $request, Competition $competition): BinaryFileResponse
{
$filters = $this->validatedExportFilters($request, $competition);
$mode = (string) $filters['mode'];
$this->exportService->assertExportable($competition, $filters, $mode);
if ($mode === AdminApplicationExportService::MODE_XLSX) {
$xlsxPath = $this->exportService->buildXlsxExportPath($competition, $filters);
return response()->download($xlsxPath, $this->exportService->buildXlsxFilename($competition), [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
])->deleteFileAfterSend(true);
}
$part = (int) ($filters['part'] ?? 1);
$partCount = $this->exportService->partCount($competition, $filters, $mode);
$zipPath = $this->exportService->buildPartZipPath($competition, $filters, $part, $mode);
$filename = $this->exportService->buildPartFilename($competition, $part, $partCount);
return response()->download($zipPath, $filename, [
'Content-Type' => 'application/zip',
'Cache-Control' => 'private, no-store',
])->deleteFileAfterSend(true);
}
public function show(Competition $competition, Application $application): JsonResponse
{
if ((int) $application->competition_id !== (int) $competition->id) {
abort(404);
}
$application->loadMissing(['files', 'reviewScores.reviewer', 'user.publicSourceChannel', 'signupChannel']);
$trackTitles = $competition->tracks()->pluck('title', 'track_code');
$stats = $this->reviewStatsForApplicationIds([(int) $application->id]);
$reviewerCountsByTrack = ReviewerScope::query()
->select('track_code', DB::raw('COUNT(*) as total'))
->where('competition_id', $competition->id)
->groupBy('track_code')
->pluck('total', 'track_code');
return response()->json(
$this->toDetail($application, $competition, $trackTitles, $stats[(int) $application->id] ?? null, $reviewerCountsByTrack)
);
}
public function downloadFile(Competition $competition, Application $application, ApplicationFile $file): StreamedResponse
{
if ((int) $application->competition_id !== (int) $competition->id
|| (int) $file->application_id !== (int) $application->id) {
abort(404);
}
$file->assertStoredFileExists();
return Storage::disk($file->disk)->download(
$file->path,
$file->clientDownloadName(),
['Cache-Control' => 'private, no-store']
);
}
/**
* superadmin 代登选手端:签发短期选手 Token,前端打开报名页写入后使用。
*/
public function impersonate(Request $request, Competition $competition, Application $application): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->user();
if (! $admin->isSuperAdmin()) {
abort(403, '仅 superadmin 可进入选手端');
}
if ((int) $application->competition_id !== (int) $competition->id) {
abort(404);
}
$application->loadMissing('user');
/** @var User|null $participant */
$participant = $application->user;
if ($participant === null) {
abort(422, '该报名未关联选手账号,无法进入选手端');
}
$ttlMinutes = 30;
$participant->tokens()->where('name', 'admin-impersonation')->delete();
$accessToken = $participant->createToken(
'admin-impersonation',
['impersonation'],
now()->addMinutes($ttlMinutes),
);
Log::info('admin.impersonate_participant', [
'admin_id' => $admin->id,
'admin_username' => $admin->username,
'application_id' => $application->id,
'competition_id' => $competition->id,
'participant_user_id' => $participant->id,
'participant_mobile' => $participant->mobile,
]);
return response()->json([
'token' => $accessToken->plainTextToken,
'token_type' => 'Bearer',
'expires_in' => $ttlMinutes * 60,
'competition_slug' => $competition->slug,
'application_id' => $application->id,
'participant' => [
'id' => $participant->id,
'mobile' => $participant->mobile,
'name' => $participant->name,
],
]);
}
/**
* @return array<string, mixed>
*/
private function validatedExportMetaFilters(Request $request, Competition $competition): array
{
return array_merge($request->validate([
'mode' => ['required', 'string', Rule::in([
AdminApplicationExportService::MODE_XLSX,
AdminApplicationExportService::MODE_SELECTED,
AdminApplicationExportService::MODE_ALL,
])],
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
'event_location' => ['sometimes', 'nullable', 'string', 'max:64'],
'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])],
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])],
// 查询串可能是 true/false/1/0 字符串
'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])],
'application_ids' => ['sometimes', 'array'],
'application_ids.*' => ['integer', 'min:1'],
]), SignupSchemaLayout::extractFilterValues($request, $competition));
}
/**
* @return array<string, mixed>
*/
private function validatedExportFilters(Request $request, Competition $competition): array
{
$filters = $request->validate([
'mode' => ['required', 'string', Rule::in([
AdminApplicationExportService::MODE_XLSX,
AdminApplicationExportService::MODE_SELECTED,
AdminApplicationExportService::MODE_ALL,
])],
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
'event_location' => ['sometimes', 'nullable', 'string', 'max:64'],
'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])],
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])],
'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])],
'application_ids' => ['sometimes', 'array'],
'application_ids.*' => ['integer', 'min:1'],
'part' => ['sometimes', 'integer', 'min:1'],
]);
if (($filters['mode'] ?? '') !== AdminApplicationExportService::MODE_XLSX && ! isset($filters['part'])) {
throw ValidationException::withMessages([
'part' => ['请指定导出分卷序号'],
]);
}
return array_merge($filters, SignupSchemaLayout::extractFilterValues($request, $competition));
}
/**
* @return array<string, mixed>
*/
private function validatedListFilters(Request $request, Competition $competition): array
{
return array_merge($request->validate([
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
'event_location' => ['sometimes', 'nullable', 'string', 'max:64'],
'signup_locale' => ['sometimes', 'nullable', 'string', Rule::in(['', PortalLocale::ZH, PortalLocale::EN])],
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'review_result' => ['sometimes', 'nullable', 'string', Rule::in(['passed', 'rejected', 'pending'])],
'review_eligible' => ['sometimes', 'nullable', Rule::in([true, false, 1, 0, '1', '0', 'true', 'false'])],
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in(['team_avg', 'submitted_at'])],
'sort_dir' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
]), SignupSchemaLayout::extractFilterValues($request, $competition));
}
/**
* @param \Illuminate\Database\Eloquent\Builder<Application> $query
* @param array<string, mixed> $data
*/
private function applyListSorting($query, array $data): void
{
$sortBy = trim((string) ($data['sort_by'] ?? ''));
$sortDir = strtolower(trim((string) ($data['sort_dir'] ?? 'desc'))) === 'asc' ? 'asc' : 'desc';
if ($sortBy === 'team_avg') {
$avgSub = ApplicationReviewScore::query()
->select('application_id', DB::raw('AVG(line_total) as team_avg'))
->groupBy('application_id');
$query->leftJoinSub($avgSub, 'review_avg_sort', function ($join): void {
$join->on('applications.id', '=', 'review_avg_sort.application_id');
})
->select('applications.*')
->orderByRaw('review_avg_sort.team_avg IS NULL')
->orderBy('review_avg_sort.team_avg', $sortDir)
->orderByDesc('applications.id');
return;
}
$query->orderByDesc('submitted_at')->orderByDesc('id');
}
/**
* @param array{submitted_review_count?: int, team_sum?: float|null, team_avg?: float|null}|null $stats
* @param \Illuminate\Support\Collection<string, int> $reviewerCountsByTrack
*/
private function isReviewFullyCompleted(Application $app, ?array $stats, $reviewerCountsByTrack): bool
{
$required = (int) ($reviewerCountsByTrack[(string) ($app->track ?? '')] ?? 0);
$completed = (int) ($stats['submitted_review_count'] ?? 0);
return $required > 0 && $completed >= $required;
}
/**
* @param list<int> $ids
* @return array<int, array{submitted_review_count: int, team_sum: float|null, team_avg: float|null}>
*/
private function reviewStatsForApplicationIds(array $ids): array
{
if (count($ids) === 0) {
return [];
}
return ApplicationReviewScore::query()
->select([
'application_id',
DB::raw('COUNT(*) as submitted_review_count'),
DB::raw('SUM(line_total) as team_sum'),
DB::raw('AVG(line_total) as team_avg'),
])
->whereIn('application_id', $ids)
->groupBy('application_id')
->get()
->mapWithKeys(fn ($row) => [
(int) $row->application_id => [
'submitted_review_count' => (int) $row->submitted_review_count,
'team_sum' => $row->team_sum !== null ? (float) $row->team_sum : null,
'team_avg' => $row->team_avg !== null ? (float) $row->team_avg : null,
],
])
->all();
}
private function toListRow(Application $app, Competition $competition, $trackTitles, ?array $stats, $reviewerCountsByTrack = null): array
{
$required = (int) ($reviewerCountsByTrack?->get((string) ($app->track ?? '')) ?? 0);
$completed = (int) ($stats['submitted_review_count'] ?? 0);
$fullyCompleted = $required > 0 && $completed >= $required;
$locale = SignupDisplay::applicationLocale($app);
return [
'id' => $app->id,
'project_code' => ProjectCode::resolve($app),
'status' => $app->status,
'signup_locale' => $locale,
'signup_locale_label' => SignupDisplay::localeTag($locale),
'project_name' => $app->project_name ?? '',
'player_name' => $app->player_name ?? '',
'school' => $app->school ?? '',
'degree' => $app->degree ?? '',
'degree_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'degree'),
'location_country_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'location_country'),
'contact_mobile' => $app->contact_mobile ?? '',
'submitter_name' => $app->user?->name ?? '',
'submitter_mobile' => $app->user?->mobile ?? '',
'event_location' => $app->event_location ?? '',
'event_location_label' => SignupDisplay::applicationFieldLabel($competition, $app, 'event_location'),
'team_members' => $app->team_members ?? '',
'track_code' => $app->track ?? '',
'track_title' => SignupDisplay::trackTitleForApplication($competition, $app)
?: ($trackTitles->get($app->track ?? '') ?? ($app->track ?? '')),
'location_label' => SignupDisplay::formatLocation($app),
'signup_channel' => $app->signupChannel ? [
'id' => $app->signupChannel->id,
'channel_code' => $app->signupChannel->channel_code,
'channel_name' => $app->signupChannel->channel_name,
] : null,
'signup_channel_code' => $app->signup_channel_code,
'recommend' => $app->recommend ?? '',
'submitted_at' => $app->submitted_at?->toIso8601String(),
'updated_at' => $app->updated_at?->toIso8601String(),
'files_count' => (int) ($app->files_count ?? 0),
'submitted_review_count' => $completed,
'review_required_count' => $required,
'review_progress' => $required > 0 ? ($completed.'/'.$required) : '-',
'review_fully_completed' => $fullyCompleted,
'team_sum' => $stats['team_sum'] ?? null,
'team_avg' => $stats['team_avg'] ?? null,
'review_result' => $app->review_result,
'review_result_note' => $app->review_result_note,
'review_result_at' => $app->review_result_at?->toIso8601String(),
'review_eligible' => $app->isReviewEligible(),
'public_source_channel' => $app->user?->publicSourceChannel ? [
'id' => $app->user->publicSourceChannel->id,
'source_code' => $app->user->publicSourceChannel->source_code,
'source_name' => $app->user->publicSourceChannel->source_name,
] : null,
'public_source_code' => $app->user?->public_source_code,
'public_source_attributed_at' => $app->user?->public_source_attributed_at?->toIso8601String(),
'schema_display' => SignupSchemaLayout::applicationDisplayMap($competition, $app),
];
}
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) !== '')));
}
private function toDetail(Application $app, Competition $competition, $trackTitles, ?array $stats, $reviewerCountsByTrack = null): array
{
$base = $this->toListRow($app, $competition, $trackTitles, $stats, $reviewerCountsByTrack);
$locale = SignupDisplay::applicationLocale($app);
return $base + [
'competition' => [
'id' => $competition->id,
'slug' => $competition->slug,
'name' => $competition->name,
],
'participant' => [
'id' => $app->user?->id,
'mobile' => $app->user?->mobile,
'name' => $app->user?->name,
'email' => $app->user?->email,
'company' => $app->user?->company,
],
'contact_email' => $app->contact_email ?? '',
'degree' => $app->degree ?? '',
'company_name' => $app->company_name ?? '',
'location_country' => $app->location_country ?? '',
'location_province' => $app->location_province ?? '',
'location_city' => $app->location_city ?? '',
'oversea_country' => $app->oversea_country ?? '',
'event_location' => $app->event_location ?? '',
'team_members' => $app->team_members ?? '',
'intro' => $app->intro ?? '',
'recommend' => $app->recommend ?? '',
'answers_json' => is_array($app->answers_json) ? $app->answers_json : [],
'schema_fields' => $this->extraSchemaDetailFields($competition, $app),
'promise_signed_at' => $app->promise_signed_at?->toIso8601String(),
'promise_signed' => $app->promise_signed_at !== null,
'pledge_content_html' => $locale === PortalLocale::EN && filled($competition->pledge_content_html_en)
? $competition->pledge_content_html_en
: ($competition->pledge_content_html ?? ''),
'promise_signature' => $app->promise_signature,
'signup_channel_state' => $app->signup_channel_state,
'files' => $app->files->map(fn (ApplicationFile $file) => [
'id' => $file->id,
'kind' => $file->kind,
'original_name' => $file->original_name,
'size' => $file->size,
'mime' => $file->mime,
])->values(),
'review_scores' => $app->reviewScores->map(fn (ApplicationReviewScore $score) => [
'id' => $score->id,
'reviewer_id' => $score->reviewer_id,
'reviewer_name' => $score->reviewer?->name,
'line_total' => $score->line_total !== null ? (string) $score->line_total : null,
'payload_json' => is_array($score->payload_json) ? $score->payload_json : [],
'comment' => $this->extractReviewComment(is_array($score->payload_json) ? $score->payload_json : []),
'updated_at' => $score->updated_at?->format('Y-m-d H:i:s'),
])->values(),
];
}
/**
* @param array<string, mixed> $payload
*/
private function extractReviewComment(array $payload): string
{
foreach (['comment', 'review_comment', 'remark'] as $key) {
$v = $payload[$key] ?? null;
if (is_string($v) && trim($v) !== '') {
return trim($v);
}
}
return '';
}
/**
* @return list<array{key: string, label: string, value: string}>
*/
private function extraSchemaDetailFields(Competition $competition, Application $app): array
{
$shown = [
'project_name', 'player_name', 'contact_email', 'contact_mobile', 'school', 'degree',
'track', 'company_name', 'location_country', 'location_province', 'location_city',
'oversea_country', 'event_location', 'team_members', 'recommend', 'intro',
];
$skip = array_flip($shown);
$out = [];
$display = SignupSchemaLayout::applicationDisplayMap($competition, $app);
foreach (SignupSchemaLayout::adminListFields($competition) as $field) {
if (isset($skip[$field['key']])) {
continue;
}
$out[] = [
'key' => $field['key'],
'label' => $field['label'],
'value' => (string) ($display[$field['key']] ?? ''),
];
}
return $out;
}
}