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.

617 lines
21 KiB

1 month 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\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,
1 month ago
$file->clientDownloadName(),
1 month 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,
'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) : '—';
}
}