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.

624 lines
21 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\FormSchemaDefinition;
use App\Models\Reviewer;
use App\Models\ReviewerScope;
use App\Support\DefaultReviewSchema;
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'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$competition = Competition::query()
->where('slug', $data['competition_slug'])
->where('published', true)
->firstOrFail();
$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')
->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['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]
);
});
}
$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 = Competition::query()
->where('slug', $data['competition_slug'])
->where('published', true)
->firstOrFail();
if ($application->competition_id !== $competition->id) {
abort(404);
}
if ($application->status !== 'submitted') {
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']);
$schema = $this->resolveEffectiveReviewSchema($competition);
$myScore = ApplicationReviewScore::query()
->where('application_id', $application->id)
->where('reviewer_id', $reviewer->id)
->first();
$trackTitle = CompetitionTrack::query()
->where('competition_id', $competition->id)
->where('track_code', $application->track ?? '')
->value('title');
return response()->json([
'data' => $this->serializeDetail(
$application,
(string) ($trackTitle ?? $application->track ?? ''),
$competition,
$schema,
$myScore
),
]);
}
/**
* 评审员提交或更新本人对该报名的打分payload 与赛事生效的评审 Schema 一致)。
*/
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 = Competition::query()
->where('slug', $data['competition_slug'])
->where('published', true)
->firstOrFail();
if ($application->competition_id !== $competition->id) {
abort(404);
}
if ($application->status !== 'submitted') {
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, '您无权评审该项目');
}
$schema = $this->resolveEffectiveReviewSchema($competition);
$normalized = $this->normalizePayload($schema['schema_json'], $data['payload']);
$lineTotal = $this->sumNumberScores($schema['schema_json'], $normalized);
DB::transaction(function () use ($application, $reviewer, $schema, $normalized, $lineTotal): void {
ApplicationReviewScore::query()->updateOrCreate(
[
'application_id' => $application->id,
'reviewer_id' => $reviewer->id,
],
[
'review_schema_id' => $schema['id'],
'payload_json' => $normalized,
'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,
],
]);
}
/**
* 评审端下载附件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 = Competition::query()
->where('slug', $data['competition_slug'])
->where('published', true)
->firstOrFail();
if ($application->competition_id !== $competition->id || $file->application_id !== $application->id) {
abort(404);
}
if ($application->status !== 'submitted') {
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, '您无权下载该附件');
}
return Storage::disk($file->disk)->download(
$file->path,
$this->safeDownloadFilename($file->original_name),
['Cache-Control' => 'private, no-store']
);
}
private function safeDownloadFilename(?string $originalName): string
{
$base = $originalName !== null && trim($originalName) !== '' ? basename(str_replace(['\\', '/'], '_', $originalName)) : '附件';
return $base !== '' ? $base : '附件';
}
/**
* @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' => $this->formatProjectCode($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,
'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{id: int|null, schema_json: array<int, mixed>, is_default: bool} $schema
*/
private function serializeDetail(
Application $app,
string $trackTitle,
Competition $competition,
array $schema,
?ApplicationReviewScore $myScore
): array {
$hasMine = $myScore !== null;
$line = $myScore?->line_total;
return [
'id' => $app->id,
'project_code' => $this->formatProjectCode($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 ?? '',
'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),
'intro' => $app->intro ?? '',
'submitted_at' => $this->formatSubmittedAtForDisplay($app->submitted_at),
'score_display' => $this->formatScoreDisplay($hasMine, $line),
'score_is_pending' => ! $hasMine,
'review_schema' => [
'id' => $schema['id'],
'schema_json' => $schema['schema_json'],
'is_default' => $schema['is_default'],
],
'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{id: int|null, schema_json: array<int, mixed>, is_default: bool}
*/
private function resolveEffectiveReviewSchema(Competition $competition): array
{
$id = null;
$isDefault = true;
$schemaJson = DefaultReviewSchema::schemaJson();
$rid = $competition->review_form_schema_id;
if ($rid !== null && (int) $rid > 0) {
$def = FormSchemaDefinition::query()
->where('id', (int) $rid)
->where('competition_id', $competition->id)
->where('purpose', FormSchemaDefinition::PURPOSE_REVIEW)
->first();
$json = $def?->schema_json;
if (is_array($json) && count($json) > 0) {
$id = $def->id;
$schemaJson = $json;
$isDefault = false;
}
}
return [
'id' => $id,
'schema_json' => $schemaJson,
'is_default' => $isDefault,
];
}
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, 4, '.', ''), '0'), '.');
}
/**
* @param array<int, mixed> $schemaJson
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizePayload(array $schemaJson, array $payload): array
{
$allowedKeys = [];
foreach ($schemaJson as $field) {
if (! is_array($field)) {
continue;
}
$k = $field['key'] ?? null;
if (is_string($k) && $k !== '') {
$allowedKeys[] = $k;
}
}
foreach (array_keys($payload) as $rawKey) {
if (! is_string($rawKey) || ! in_array($rawKey, $allowedKeys, true)) {
throw ValidationException::withMessages([
'payload' => ['存在未在评审表中的字段:'.(is_string($rawKey) ? $rawKey : '(无效键)')],
]);
}
}
$out = [];
foreach ($schemaJson as $field) {
if (! is_array($field)) {
continue;
}
$key = $field['key'] ?? null;
$type = $field['type'] ?? null;
if (! is_string($key) || $key === '') {
continue;
}
if (! in_array($type, ['number', 'text', 'textarea'], true)) {
throw ValidationException::withMessages([
'payload' => ['评审表字段「'.$key.'」类型无效'],
]);
}
$label = is_string($field['label'] ?? null) ? (string) $field['label'] : $key;
$required = ! empty($field['required']);
$hasKey = array_key_exists($key, $payload);
if ($type === 'number') {
if (! $hasKey || $payload[$key] === null || $payload[$key] === '') {
if ($required) {
throw ValidationException::withMessages([
'payload' => ['请填写'.$label],
]);
}
$out[$key] = null;
continue;
}
if (! is_numeric($payload[$key])) {
throw ValidationException::withMessages([
'payload' => [$label.'须为数字'],
]);
}
$out[$key] = (float) $payload[$key];
continue;
}
if (! $hasKey) {
if ($required) {
throw ValidationException::withMessages([
'payload' => ['请填写'.$label],
]);
}
$out[$key] = '';
continue;
}
$raw = $payload[$key];
if (is_string($raw)) {
$s = trim($raw);
} elseif ($raw === null) {
$s = '';
} elseif (is_scalar($raw)) {
$s = trim((string) $raw);
} else {
throw ValidationException::withMessages([
'payload' => [$label.'格式不正确'],
]);
}
if ($required && $s === '') {
throw ValidationException::withMessages([
'payload' => ['请填写'.$label],
]);
}
$out[$key] = $s;
}
return $out;
}
/**
* @param array<int, mixed> $schemaJson
* @param array<string, mixed> $normalized
*/
private function sumNumberScores(array $schemaJson, array $normalized): float
{
$sum = 0.0;
foreach ($schemaJson as $field) {
if (! is_array($field)) {
continue;
}
if (($field['type'] ?? '') !== 'number') {
continue;
}
$key = $field['key'] ?? null;
if (! is_string($key) || $key === '') {
continue;
}
$v = $normalized[$key] ?? null;
if ($v === null) {
continue;
}
$sum += (float) $v;
}
return $sum;
}
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 formatProjectCode(Application $app): string
{
return sprintf('P%s-%04d', date('Y'), $app->id);
}
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) : '—';
}
}