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.

522 lines
18 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;
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\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']);
$allowedCodes = ReviewerScope::query()
->where('reviewer_id', $reviewer->id)
->where('competition_id', $competition->id)
->pluck('track_code')
->unique()
->filter()
->values();
if ($allowedCodes->isEmpty()) {
abort(403, '您暂无本场赛事的评审范围');
}
$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 (! 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, $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);
}
$hasScope = ReviewerScope::query()
->where('reviewer_id', $reviewer->id)
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->exists();
if (! $hasScope) {
abort(403, '您无权查看该项目');
}
$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,
(string) ($track?->title ?? $application->track ?? ''),
$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, '该项目未参与评审');
}
$hasScope = ReviewerScope::query()
->where('reviewer_id', $reviewer->id)
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->exists();
if (! $hasScope) {
abort(403, '您无权评审该项目');
}
$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);
}
$hasScope = ReviewerScope::query()
->where('reviewer_id', $reviewer->id)
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->exists();
if (! $hasScope) {
abort(403, '您无权下载该附件');
}
$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, $trackTitleByCode, ?ApplicationReviewScore $myScore): array
{
$code = $app->track ?? '';
$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 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;
}
}