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.

542 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\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 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);
$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 ($trackTitles, $stats, $reviewerCountsByTrack) {
$app->loadMissing('signupChannel');
return $this->toListRow($app, $trackTitles, $stats[(int) $app->id] ?? null, $reviewerCountsByTrack);
});
return response()->json($paginator);
}
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);
$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);
$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): array
{
return $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'],
'entry_group' => ['sometimes', 'nullable', 'string', 'max:64'],
'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'],
]);
}
/**
* @return array<string, mixed>
*/
private function validatedExportFilters(Request $request): 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'],
'entry_group' => ['sometimes', 'nullable', 'string', 'max:64'],
'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 $filters;
}
/**
* @return array<string, mixed>
*/
private function validatedListFilters(Request $request): array
{
return $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'],
'entry_group' => ['sometimes', 'nullable', 'string', 'max:64'],
'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'])],
]);
}
/**
* @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, $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;
return [
'id' => $app->id,
'project_code' => ProjectCode::resolve($app),
'status' => $app->status,
'project_name' => $app->project_name ?? '',
'player_name' => $app->player_name ?? '',
'school' => $app->school ?? '',
'degree' => $app->degree ?? '',
'contact_mobile' => $app->contact_mobile ?? '',
'submitter_name' => $app->user?->name ?? '',
'submitter_mobile' => $app->user?->mobile ?? '',
'entry_group' => $app->entry_group ?? '',
'track_code' => $app->track ?? '',
'track_title' => $trackTitles->get($app->track ?? '') ?? ($app->track ?? ''),
'location_label' => $this->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(),
];
}
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, $trackTitles, $stats, $reviewerCountsByTrack);
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 ?? '',
'intro' => $app->intro ?? '',
'recommend' => $app->recommend ?? '',
'promise_signed_at' => $app->promise_signed_at?->toIso8601String(),
'promise_signed' => $app->promise_signed_at !== null,
'pledge_content_html' => $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 '';
}
}