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

5 months ago
<?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;
2 months ago
use App\Support\TrackScoringSheet;
5 months ago
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'],
1 month ago
'event_location' => ['nullable', 'string', 'max:64'],
3 months ago
'review_status' => ['nullable', 'string', 'in:pending,reviewed'],
5 months ago
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$competition = $this->resolveReviewCompetition($data['competition_slug']);
5 months ago
$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')
2 months ago
->where('review_eligible', true)
5 months ago
->whereIn('track', $allowedCodes);
if (! empty($data['track_code'])) {
if (! $allowedCodes->contains($data['track_code'])) {
abort(422, '无效赛道');
}
$q->where('track', $data['track_code']);
}
1 month ago
if (isset($data['event_location']) && trim((string) $data['event_location']) !== '') {
$q->where('event_location', trim((string) $data['event_location']));
2 months ago
}
5 months ago
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]
);
});
}
3 months ago
$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);
});
}
5 months ago
$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']);
5 months ago
if ($application->competition_id !== $competition->id) {
abort(404);
}
if ($application->status !== 'submitted') {
abort(404);
}
2 months ago
if (! $application->isReviewEligible()) {
abort(404);
}
5 months ago
$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']);
2 months ago
$track = CompetitionTrack::query()
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->first();
$scoringSheet = $this->resolveTrackScoringSheet($track);
5 months ago
$myScore = ApplicationReviewScore::query()
->where('application_id', $application->id)
->where('reviewer_id', $reviewer->id)
->first();
return response()->json([
'data' => $this->serializeDetail(
$application,
2 months ago
(string) ($track?->title ?? $application->track ?? ''),
5 months ago
$competition,
2 months ago
$scoringSheet,
5 months ago
$myScore
),
]);
}
/**
2 months ago
* 评审员提交或更新本人对该报名的打分(payload.line_total,0~100)。
5 months ago
*/
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']);
5 months ago
if ($application->competition_id !== $competition->id) {
abort(404);
}
if ($application->status !== 'submitted') {
abort(404);
}
2 months ago
if (! $application->isReviewEligible()) {
abort(404, '该项目未参与评审');
}
5 months ago
$hasScope = ReviewerScope::query()
->where('reviewer_id', $reviewer->id)
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->exists();
if (! $hasScope) {
abort(403, '您无权评审该项目');
}
2 months ago
$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']],
]);
}
5 months ago
2 months ago
$lineTotal = $normalized['line_total'];
$payloadJson = $normalized['payload'];
5 months ago
2 months ago
DB::transaction(function () use ($application, $reviewer, $payloadJson, $lineTotal): void {
5 months ago
ApplicationReviewScore::query()->updateOrCreate(
[
'application_id' => $application->id,
'reviewer_id' => $reviewer->id,
],
[
2 months ago
'review_schema_id' => null,
'payload_json' => $payloadJson,
5 months ago
'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,
2 months ago
'scoring_allowed' => true,
5 months ago
],
]);
}
/**
* 评审端下载附件(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']);
5 months ago
if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) {
abort(404);
}
if ($application->status !== 'submitted') {
abort(404);
}
2 months ago
if (! $application->isReviewEligible()) {
abort(404);
}
5 months ago
$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();
5 months ago
return Storage::disk($file->disk)->download(
$file->path,
5 months ago
$file->clientDownloadName(),
5 months ago
['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,
3 months ago
'project_code' => \App\Support\ProjectCode::resolve($app),
5 months ago
'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,
1 month ago
'event_location' => $app->event_location ?? '',
5 months ago
'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,
];
}
/**
2 months ago
* @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
5 months ago
*/
private function serializeDetail(
Application $app,
string $trackTitle,
Competition $competition,
2 months ago
?array $scoringSheet,
5 months ago
?ApplicationReviewScore $myScore
): array {
$hasMine = $myScore !== null;
$line = $myScore?->line_total;
2 months ago
$scoringAllowed = $scoringSheet !== null;
5 months ago
return [
'id' => $app->id,
3 months ago
'project_code' => \App\Support\ProjectCode::resolve($app),
5 months ago
'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 ?? '',
1 month ago
'event_location' => $app->event_location ?? '',
'team_members' => $app->team_members ?? '',
5 months ago
'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),
3 months ago
'recommend' => $app->recommend ?? '',
5 months ago
'intro' => $app->intro ?? '',
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
'score_display' => $this->formatScoreDisplay($hasMine, $line),
'score_is_pending' => ! $hasMine,
2 months ago
'scoring_allowed' => $scoringAllowed,
'scoring_sheet' => $scoringSheet,
'scoring_blocked_reason' => $scoringAllowed ? null : '该赛道未配置打分表,暂不可评审',
// 兼容旧前端字段:不再使用赛事级评审 Schema
5 months ago
'review_schema' => [
2 months ago
'id' => null,
'schema_json' => [],
'is_default' => false,
5 months ago
],
'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,
];
}
/**
2 months ago
* @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
5 months ago
*/
2 months ago
private function resolveTrackScoringSheet(?CompetitionTrack $track): ?array
5 months ago
{
2 months ago
if ($track === null) {
return null;
5 months ago
}
2 months ago
$raw = $track->scoring_sheet_json;
if (! is_array($raw)) {
return null;
}
$parsed = TrackScoringSheet::parseAndValidateConfig($raw);
return $parsed['ok'] === true ? $parsed['sheet'] : null;
5 months ago
}
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;
2 months ago
return '已评分 · '.rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.');
5 months ago
}
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;
}
5 months ago
}