|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Application;
|
|
|
use App\Models\ApplicationFile;
|
|
|
use App\Models\ApplicationReviewRecord;
|
|
|
use App\Models\ApplicationReviewScore;
|
|
|
use App\Models\Competition;
|
|
|
use App\Models\CompetitionTrack;
|
|
|
use App\Models\Reviewer;
|
|
|
use App\Models\ReviewerScope;
|
|
|
use App\Support\SignupDisplay;
|
|
|
use App\Support\TrackScoringSheet;
|
|
|
use Carbon\CarbonInterface;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
|
|
class ReviewApplicationController extends Controller
|
|
|
{
|
|
|
/** 与中国赛区运营/选手感知一致(库内可为 UTC,接口展示统一东八区) */
|
|
|
private const DISPLAY_TIMEZONE = 'Asia/Shanghai';
|
|
|
|
|
|
/**
|
|
|
* 评审员可见的报名列表(按 reviewer_scopes 限制赛道;仅已提交)。
|
|
|
*/
|
|
|
public function index(Request $request): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
'keyword' => ['nullable', 'string', 'max:200'],
|
|
|
'track_code' => ['nullable', 'string', 'max:64'],
|
|
|
'event_location' => ['nullable', 'string', 'max:64'],
|
|
|
'review_status' => ['nullable', 'string', 'in:pending,reviewed'],
|
|
|
'page' => ['nullable', 'integer', 'min:1'],
|
|
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
|
]);
|
|
|
|
|
|
$competition = $this->resolveReviewCompetition($data['competition_slug']);
|
|
|
|
|
|
$allowedScopes = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->get(['track_code', 'event_location']);
|
|
|
|
|
|
$allowedCodes = $allowedScopes->pluck('track_code')->unique()->filter()->values();
|
|
|
|
|
|
if ($allowedCodes->isEmpty()) {
|
|
|
abort(403, '您暂无本场赛事的评审范围');
|
|
|
}
|
|
|
|
|
|
$allowedLocations = $allowedScopes
|
|
|
->pluck('event_location')
|
|
|
->map(fn ($v) => trim((string) $v))
|
|
|
->filter()
|
|
|
->unique()
|
|
|
->values();
|
|
|
$hasUnscopedLocation = $allowedScopes->contains(
|
|
|
fn (ReviewerScope $scope) => trim((string) ($scope->event_location ?? '')) === ''
|
|
|
);
|
|
|
|
|
|
$tracksMeta = CompetitionTrack::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->whereIn('track_code', $allowedCodes)
|
|
|
->orderBy('sort')
|
|
|
->orderBy('id')
|
|
|
->get(['id', 'track_code', 'title']);
|
|
|
|
|
|
$trackTitleByCode = $tracksMeta->keyBy('track_code');
|
|
|
|
|
|
$q = Application::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('status', 'submitted')
|
|
|
->where('review_eligible', true)
|
|
|
->whereIn('track', $allowedCodes);
|
|
|
|
|
|
if (! $hasUnscopedLocation && $allowedLocations->isNotEmpty()) {
|
|
|
$q->whereIn('event_location', $allowedLocations->all());
|
|
|
}
|
|
|
|
|
|
if (! empty($data['track_code'])) {
|
|
|
if (! $allowedCodes->contains($data['track_code'])) {
|
|
|
abort(422, '无效赛道');
|
|
|
}
|
|
|
$q->where('track', $data['track_code']);
|
|
|
}
|
|
|
|
|
|
if (isset($data['event_location']) && trim((string) $data['event_location']) !== '') {
|
|
|
$q->where('event_location', trim((string) $data['event_location']));
|
|
|
}
|
|
|
|
|
|
if (isset($data['keyword']) && $data['keyword'] !== null && trim($data['keyword']) !== '') {
|
|
|
$raw = trim($data['keyword']);
|
|
|
$kw = '%'.addcslashes($raw, '%_\\').'%';
|
|
|
$q->where(function ($w) use ($kw): void {
|
|
|
$w->where('player_name', 'like', $kw)
|
|
|
->orWhere('project_name', 'like', $kw)
|
|
|
->orWhere('school', 'like', $kw)
|
|
|
->orWhere('company_name', 'like', $kw)
|
|
|
->orWhere('contact_mobile', 'like', $kw)
|
|
|
->orWhereRaw(
|
|
|
"CONCAT_WS(' ', COALESCE(location_country,''), COALESCE(location_province,''), COALESCE(location_city,''), COALESCE(oversea_country,'')) LIKE ?",
|
|
|
[$kw]
|
|
|
);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
$reviewStatus = $data['review_status'] ?? null;
|
|
|
if ($reviewStatus === 'pending') {
|
|
|
$q->whereDoesntHave('reviewScores', function ($w) use ($reviewer): void {
|
|
|
$w->where('reviewer_id', $reviewer->id);
|
|
|
});
|
|
|
} elseif ($reviewStatus === 'reviewed') {
|
|
|
$q->whereHas('reviewScores', function ($w) use ($reviewer): void {
|
|
|
$w->where('reviewer_id', $reviewer->id);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
$perPage = isset($data['per_page']) ? (int) $data['per_page'] : 20;
|
|
|
$paginator = $q->orderByDesc('submitted_at')
|
|
|
->orderByDesc('id')
|
|
|
->paginate($perPage);
|
|
|
|
|
|
$appIds = collect($paginator->items())->pluck('id')->all();
|
|
|
$scoresByAppId = ApplicationReviewScore::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->whereIn('application_id', $appIds)
|
|
|
->get()
|
|
|
->keyBy('application_id');
|
|
|
|
|
|
$rows = [];
|
|
|
foreach ($paginator->items() as $app) {
|
|
|
/** @var Application $app */
|
|
|
$rows[] = $this->serializeListRow($app, $competition, $trackTitleByCode, $scoresByAppId->get($app->id));
|
|
|
}
|
|
|
|
|
|
return response()->json([
|
|
|
'data' => $rows,
|
|
|
'meta' => [
|
|
|
'current_page' => $paginator->currentPage(),
|
|
|
'last_page' => $paginator->lastPage(),
|
|
|
'per_page' => $paginator->perPage(),
|
|
|
'total' => $paginator->total(),
|
|
|
],
|
|
|
'tracks' => $tracksMeta->map(fn (CompetitionTrack $t) => [
|
|
|
'track_code' => $t->track_code,
|
|
|
'title' => $t->title,
|
|
|
])->values(),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 单条报名详情(评审端只读)。
|
|
|
*/
|
|
|
public function show(Request $request, Application $application): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
]);
|
|
|
|
|
|
$competition = $this->resolveReviewCompetition($data['competition_slug']);
|
|
|
|
|
|
if ($application->competition_id !== $competition->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if (! $application->isReviewEligible()) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
$this->assertReviewerCanAccess($reviewer, $competition, $application, '您无权查看该项目');
|
|
|
|
|
|
$application->load(['files']);
|
|
|
|
|
|
$track = CompetitionTrack::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->first();
|
|
|
|
|
|
$scoringSheet = $this->resolveTrackScoringSheet($track);
|
|
|
|
|
|
$myScore = ApplicationReviewScore::query()
|
|
|
->where('application_id', $application->id)
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->first();
|
|
|
|
|
|
return response()->json([
|
|
|
'data' => $this->serializeDetail(
|
|
|
$application,
|
|
|
SignupDisplay::trackTitleForApplication($competition, $application),
|
|
|
$competition,
|
|
|
$scoringSheet,
|
|
|
$myScore
|
|
|
),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 评审员提交或更新本人对该报名的打分(payload.line_total,0~100)。
|
|
|
*/
|
|
|
public function submitScore(Request $request, Application $application): JsonResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
'payload' => ['required', 'array'],
|
|
|
]);
|
|
|
|
|
|
$competition = $this->resolveReviewCompetition($data['competition_slug']);
|
|
|
|
|
|
if ($application->competition_id !== $competition->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if (! $application->isReviewEligible()) {
|
|
|
abort(404, '该项目未参与评审');
|
|
|
}
|
|
|
|
|
|
$this->assertReviewerCanAccess($reviewer, $competition, $application, '您无权评审该项目');
|
|
|
|
|
|
$track = CompetitionTrack::query()
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->first();
|
|
|
|
|
|
$scoringSheet = $this->resolveTrackScoringSheet($track);
|
|
|
if ($scoringSheet === null) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => ['该赛道未配置打分表,暂不可评审'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$normalized = TrackScoringSheet::normalizeSubmitPayload($scoringSheet, $data['payload']);
|
|
|
if ($normalized['ok'] !== true) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'payload' => [$normalized['message']],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$lineTotal = $normalized['line_total'];
|
|
|
$payloadJson = $normalized['payload'];
|
|
|
|
|
|
DB::transaction(function () use ($application, $reviewer, $payloadJson, $lineTotal): void {
|
|
|
ApplicationReviewScore::query()->updateOrCreate(
|
|
|
[
|
|
|
'application_id' => $application->id,
|
|
|
'reviewer_id' => $reviewer->id,
|
|
|
],
|
|
|
[
|
|
|
'review_schema_id' => null,
|
|
|
'payload_json' => $payloadJson,
|
|
|
'line_total' => $lineTotal,
|
|
|
]
|
|
|
);
|
|
|
|
|
|
ApplicationReviewRecord::query()->firstOrCreate(
|
|
|
['application_id' => $application->id]
|
|
|
);
|
|
|
});
|
|
|
|
|
|
$myScore = ApplicationReviewScore::query()
|
|
|
->where('application_id', $application->id)
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->first();
|
|
|
|
|
|
return response()->json([
|
|
|
'message' => '评分已保存',
|
|
|
'data' => [
|
|
|
'my_review_score' => $this->serializeMyReviewScore($myScore),
|
|
|
'score_display' => $this->formatScoreDisplay(true, $lineTotal),
|
|
|
'score_is_pending' => false,
|
|
|
'scoring_allowed' => true,
|
|
|
],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 评审端下载附件(Content-Disposition 使用用户上传时的原始文件名)。
|
|
|
*/
|
|
|
public function downloadFile(Request $request, Application $application, ApplicationFile $file): StreamedResponse
|
|
|
{
|
|
|
/** @var Reviewer $reviewer */
|
|
|
$reviewer = $request->user();
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'competition_slug' => ['required', 'string', 'max:64'],
|
|
|
]);
|
|
|
|
|
|
$competition = $this->resolveReviewCompetition($data['competition_slug']);
|
|
|
|
|
|
if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if ($application->status !== 'submitted') {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
if (! $application->isReviewEligible()) {
|
|
|
abort(404);
|
|
|
}
|
|
|
|
|
|
$this->assertReviewerCanAccess($reviewer, $competition, $application, '您无权下载该附件');
|
|
|
|
|
|
$file->assertStoredFileExists();
|
|
|
|
|
|
return Storage::disk($file->disk)->download(
|
|
|
$file->path,
|
|
|
$file->clientDownloadName(),
|
|
|
['Cache-Control' => 'private, no-store']
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param \Illuminate\Support\Collection<string, CompetitionTrack> $trackTitleByCode
|
|
|
*/
|
|
|
private function serializeListRow(Application $app, Competition $competition, $trackTitleByCode, ?ApplicationReviewScore $myScore): array
|
|
|
{
|
|
|
$code = $app->track ?? '';
|
|
|
$trackTitle = SignupDisplay::trackTitleForApplication($competition, $app);
|
|
|
if ($trackTitle === '') {
|
|
|
$trackTitle = $trackTitleByCode->get($code)?->title ?? $code;
|
|
|
}
|
|
|
|
|
|
$hasMine = $myScore !== null;
|
|
|
$line = $myScore?->line_total;
|
|
|
|
|
|
return [
|
|
|
'id' => $app->id,
|
|
|
'project_code' => \App\Support\ProjectCode::resolve($app),
|
|
|
'project_name' => $app->project_name ?? '',
|
|
|
'player_name' => $app->player_name ?? '',
|
|
|
'location_label' => $this->formatLocation($app),
|
|
|
'school' => $app->school ?? '',
|
|
|
'degree' => $app->degree ?? '',
|
|
|
'track_code' => $code,
|
|
|
'track_title' => $trackTitle,
|
|
|
'event_location' => $app->event_location ?? '',
|
|
|
'score_display' => $this->formatScoreDisplay($hasMine, $line),
|
|
|
'score_is_pending' => ! $hasMine,
|
|
|
'manage_status' => null,
|
|
|
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
|
|
|
'competition_id' => $app->competition_id,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array{version: int, title: string, full_score: float, items: list<array{key: string, sort: int, category: string, title: string, criteria: string, max_score: float}>}|null $scoringSheet
|
|
|
*/
|
|
|
private function serializeDetail(
|
|
|
Application $app,
|
|
|
string $trackTitle,
|
|
|
Competition $competition,
|
|
|
?array $scoringSheet,
|
|
|
?ApplicationReviewScore $myScore
|
|
|
): array {
|
|
|
$hasMine = $myScore !== null;
|
|
|
$line = $myScore?->line_total;
|
|
|
$scoringAllowed = $scoringSheet !== null;
|
|
|
|
|
|
return [
|
|
|
'id' => $app->id,
|
|
|
'project_code' => \App\Support\ProjectCode::resolve($app),
|
|
|
'project_name' => $app->project_name ?? '',
|
|
|
'player_name' => $app->player_name ?? '',
|
|
|
'school' => $app->school ?? '',
|
|
|
'degree' => $app->degree ?? '',
|
|
|
'contact_email' => $app->contact_email ?? '',
|
|
|
'contact_mobile' => $app->contact_mobile ?? '',
|
|
|
'event_location' => $app->event_location ?? '',
|
|
|
'team_members' => $app->team_members ?? '',
|
|
|
'company_name' => $app->company_name ?? '',
|
|
|
'track_code' => $app->track ?? '',
|
|
|
'track_title' => $trackTitle,
|
|
|
'location_country' => $app->location_country ?? '',
|
|
|
'location_province' => $app->location_province ?? '',
|
|
|
'location_city' => $app->location_city ?? '',
|
|
|
'oversea_country' => $app->oversea_country ?? '',
|
|
|
'location_label' => $this->formatLocation($app),
|
|
|
'recommend' => $app->recommend ?? '',
|
|
|
'intro' => $app->intro ?? '',
|
|
|
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
|
|
|
'score_display' => $this->formatScoreDisplay($hasMine, $line),
|
|
|
'score_is_pending' => ! $hasMine,
|
|
|
'scoring_allowed' => $scoringAllowed,
|
|
|
'scoring_sheet' => $scoringSheet,
|
|
|
'scoring_blocked_reason' => $scoringAllowed ? null : '该赛道未配置打分表,暂不可评审',
|
|
|
// 兼容旧前端字段:不再使用赛事级评审 Schema
|
|
|
'review_schema' => [
|
|
|
'id' => null,
|
|
|
'schema_json' => [],
|
|
|
'is_default' => false,
|
|
|
],
|
|
|
'my_review_score' => $this->serializeMyReviewScore($myScore),
|
|
|
'pledge_content_html' => $competition->pledge_content_html ?? '',
|
|
|
'promise_signature' => $app->promise_signature,
|
|
|
'files' => $app->files->map(fn ($f) => [
|
|
|
'id' => $f->id,
|
|
|
'kind' => $f->kind,
|
|
|
'original_name' => $f->original_name,
|
|
|
'size' => $f->size,
|
|
|
'url' => $f->publicUrl(),
|
|
|
])->values(),
|
|
|
'promise_signed_at' => $this->formatSubmittedAtForDisplay($app->promise_signed_at),
|
|
|
'promise_signed' => $app->promise_signed_at !== null,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array{version: int, title: string, full_score: float, items: list<array{key: string, sort: int, category: string, title: string, criteria: string, max_score: float}>}|null
|
|
|
*/
|
|
|
private function resolveTrackScoringSheet(?CompetitionTrack $track): ?array
|
|
|
{
|
|
|
if ($track === null) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$raw = $track->scoring_sheet_json;
|
|
|
if (! is_array($raw)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$parsed = TrackScoringSheet::parseAndValidateConfig($raw);
|
|
|
|
|
|
return $parsed['ok'] === true ? $parsed['sheet'] : null;
|
|
|
}
|
|
|
|
|
|
private function serializeMyReviewScore(?ApplicationReviewScore $myScore): ?array
|
|
|
{
|
|
|
if ($myScore === null) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'payload_json' => $myScore->payload_json,
|
|
|
'line_total' => (string) $myScore->line_total,
|
|
|
'updated_at' => $this->formatSubmittedAtForDisplay($myScore->updated_at),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private function formatScoreDisplay(bool $hasMine, mixed $lineTotal): string
|
|
|
{
|
|
|
if (! $hasMine) {
|
|
|
return '待评审';
|
|
|
}
|
|
|
|
|
|
if ($lineTotal === null) {
|
|
|
return '已评分';
|
|
|
}
|
|
|
|
|
|
$n = is_numeric($lineTotal) ? (float) $lineTotal : 0.0;
|
|
|
|
|
|
return '已评分 · '.rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.');
|
|
|
}
|
|
|
|
|
|
private function assertReviewerCanAccess(
|
|
|
Reviewer $reviewer,
|
|
|
Competition $competition,
|
|
|
Application $application,
|
|
|
string $forbiddenMessage,
|
|
|
): void {
|
|
|
$scopes = ReviewerScope::query()
|
|
|
->where('reviewer_id', $reviewer->id)
|
|
|
->where('competition_id', $competition->id)
|
|
|
->where('track_code', $application->track ?? '')
|
|
|
->get(['event_location']);
|
|
|
|
|
|
if ($scopes->isEmpty()) {
|
|
|
abort(403, $forbiddenMessage);
|
|
|
}
|
|
|
|
|
|
$locations = $scopes
|
|
|
->pluck('event_location')
|
|
|
->map(fn ($v) => trim((string) $v))
|
|
|
->filter()
|
|
|
->unique()
|
|
|
->values();
|
|
|
$hasUnscoped = $scopes->contains(
|
|
|
fn (ReviewerScope $scope) => trim((string) ($scope->event_location ?? '')) === ''
|
|
|
);
|
|
|
$appLocation = trim((string) ($application->event_location ?? ''));
|
|
|
if (! $hasUnscoped && $locations->isNotEmpty() && ! $locations->contains($appLocation)) {
|
|
|
abort(403, $forbiddenMessage);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private function formatSubmittedAtForDisplay(?CarbonInterface $dt): string
|
|
|
{
|
|
|
if ($dt === null) {
|
|
|
return '';
|
|
|
}
|
|
|
|
|
|
return $dt->copy()->timezone(self::DISPLAY_TIMEZONE)->format('Y-m-d H:i');
|
|
|
}
|
|
|
|
|
|
private function formatLocation(Application $app): string
|
|
|
{
|
|
|
$country = trim((string) ($app->location_country ?? ''));
|
|
|
if ($country === '海外' && ($app->oversea_country ?? '') !== '') {
|
|
|
return '海外 / '.$app->oversea_country;
|
|
|
}
|
|
|
$parts = array_values(array_filter([
|
|
|
$app->location_country !== null && $app->location_country !== '' ? $app->location_country : null,
|
|
|
$app->location_province !== null && $app->location_province !== '' ? $app->location_province : null,
|
|
|
$app->location_city !== null && $app->location_city !== '' ? $app->location_city : null,
|
|
|
], fn ($v) => $v !== null && $v !== ''));
|
|
|
|
|
|
return count($parts) ? implode(' / ', $parts) : '—';
|
|
|
}
|
|
|
|
|
|
private function resolveReviewCompetition(string $slug): Competition
|
|
|
{
|
|
|
$competition = Competition::query()
|
|
|
->where('slug', $slug)
|
|
|
->where('published', true)
|
|
|
->firstOrFail();
|
|
|
|
|
|
if (! $competition->isReviewPortalEnabled()) {
|
|
|
abort(403, $competition->reviewPortalClosedMessage());
|
|
|
}
|
|
|
|
|
|
return $competition;
|
|
|
}
|
|
|
}
|