assertMayEditSignup('status'); } private function currentApplication(Request $request): Application { return $this->participantApplication($request); } /** * @return list */ private function enabledTrackCodes(Competition $competition): array { return $competition->tracks() ->where('is_enabled', true) ->pluck('track_code') ->all(); } /** 报名表 schema 是否包含参赛承诺书勾选(与选手端 key 一致) */ private function signupSchemaRequiresCommitment(Competition $competition): bool { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows) || count($rows) === 0) { return true; } foreach ($rows as $row) { if (is_array($row) && ($row['key'] ?? '') === 'commitment_accepted') { return true; } } return false; } /** 当前赛事报名表 schema 是否包含指定字段 key */ private function signupSchemaHasKey(Competition $competition, string $key): bool { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows) || count($rows) === 0) { // 与选手端 normalizeSignupSchema([]) 兜底 DEFAULT_SIGNUP_FORM_SCHEMA 一致(含 entry_group) return $key === 'entry_group'; } foreach ($rows as $row) { if (is_array($row) && ($row['key'] ?? '') === $key) { return true; } } return false; } /** * @return list */ private function entryGroupOptionValues(): array { $raw = config('contest.entry_groups', ['创新组', '创业组']); if (! is_array($raw) || count($raw) === 0) { return ['创新组', '创业组']; } return array_values(array_filter(array_map('strval', $raw), fn (string $v) => $v !== '')); } /** * 报名表 schema 中 entry_group 下拉允许的「值」(与选项 `value` 一致,如 entry_group1)。 * * @return list */ private function entryGroupSelectValuesFromSignupSchema(Competition $competition): array { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows)) { return []; } foreach ($rows as $row) { if (! is_array($row) || ($row['key'] ?? '') !== 'entry_group') { continue; } $opts = $row['options'] ?? []; if (! is_array($opts)) { return []; } $out = []; foreach ($opts as $opt) { if (is_array($opt) && array_key_exists('value', $opt)) { $s = trim((string) $opt['value']); if ($s !== '') { $out[] = $s; } } elseif (is_string($opt)) { $s = trim($opt); if ($s !== '') { $out[] = $s; } } } return array_values(array_unique($out)); } return []; } /** * 参赛组别取何值时企业名称必填:优先读 company_name 的 required_when,否则读 config。 * * @return list */ private function companyNameRequiredWhenEntryGroupValues(Competition $competition): array { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows)) { $rows = []; } foreach ($rows as $row) { if (! is_array($row) || ($row['key'] ?? '') !== 'company_name') { continue; } $rw = $row['required_when'] ?? null; if (! is_array($rw) || ($rw['field'] ?? '') !== 'entry_group') { break; } $vals = $rw['values'] ?? []; if (! is_array($vals)) { break; } $out = []; foreach ($vals as $v) { $s = trim((string) $v); if ($s !== '') { $out[] = $s; } } if (count($out) > 0) { return array_values(array_unique($out)); } break; } $cv = trim((string) config('contest.entry_group_company_required_value', '创业组')); return $cv !== '' ? [$cv] : []; } /** * @return list */ private function allowedEntryGroupValues(Competition $competition): array { $fromSchema = $this->entryGroupSelectValuesFromSignupSchema($competition); if (count($fromSchema) > 0) { return $fromSchema; } return $this->entryGroupOptionValues(); } public function show(Request $request): JsonResponse { $app = $this->currentApplication($request); $app->load(['files']); return response()->json($this->transform($app)); } public function update(Request $request): JsonResponse { $competition = $this->resolvePublishedCompetitionFromRequest($request); $app = $this->currentApplication($request); $this->ensureParticipantMayEditSignup($app); $trackCodes = $this->enabledTrackCodes($competition); $degrees = config('contest.degrees', []); $countries = config('contest.location_countries', []); $companyRules = ['nullable', 'string', 'max:255']; if ($this->signupSchemaHasKey($competition, 'entry_group')) { $reqVals = $this->companyNameRequiredWhenEntryGroupValues($competition); if (count($reqVals) > 0) { $companyRules[] = Rule::requiredIf(function () use ($request, $reqVals): bool { $eg = (string) $request->input('entry_group', ''); return in_array($eg, $reqVals, true); }); } } $trackRules = count($trackCodes) ? ['nullable', 'string', Rule::in($trackCodes)] : ['nullable', 'string', 'max:100']; $rules = [ 'player_name' => ['nullable', 'string', 'max:120'], 'school' => ['nullable', 'string', 'max:200'], 'degree' => ['nullable', 'string', Rule::in($degrees)], 'contact_email' => ['nullable', 'email', 'max:255'], 'contact_mobile' => ['nullable', 'regex:/^1[3-9]\d{9}$/'], 'company_name' => $companyRules, 'project_name' => ['nullable', 'string', 'max:255'], 'track' => $trackRules, 'location_country' => ['nullable', 'string', Rule::in($countries)], 'location_province' => ['nullable', 'string', 'max:100'], 'location_city' => ['nullable', 'string', 'max:100'], 'oversea_country' => ['nullable', 'string', 'max:100'], 'intro' => ['nullable', 'string', 'max:5000'], ]; if ($this->signupSchemaHasKey($competition, 'entry_group')) { $rules['entry_group'] = ['required', 'string', Rule::in($this->allowedEntryGroupValues($competition))]; } if ($this->signupSchemaRequiresCommitment($competition)) { $rules['commitment_accepted'] = ['sometimes', 'boolean']; $rules['promise_signature'] = ['nullable', 'string', 'max:2097152']; } $data = $request->validate($rules); $commitmentAccepted = $data['commitment_accepted'] ?? null; $promiseSignature = $data['promise_signature'] ?? null; unset($data['commitment_accepted'], $data['promise_signature']); $app->fill($data); if ($this->signupSchemaRequiresCommitment($competition)) { if ($commitmentAccepted === true) { if (! is_string($promiseSignature) || trim($promiseSignature) === '') { throw ValidationException::withMessages([ 'promise_signature' => ['请完成手写签名后再保存'], ]); } $app->promise_signed_at = now(); $app->promise_signature = $promiseSignature; } elseif ($commitmentAccepted === false) { $app->promise_signed_at = null; $app->promise_signature = null; } } if (! empty($data['player_name']) && empty($request->user()->name)) { $request->user()->update(['name' => $data['player_name']]); } if (! empty($data['contact_email']) && empty($request->user()->email)) { $request->user()->update(['email' => $data['contact_email']]); } $app->save(); $app->load(['files']); return response()->json($this->transform($app)); } public function submit(Request $request): JsonResponse { $competition = $this->resolvePublishedCompetitionFromRequest($request); $app = $this->currentApplication($request); $this->ensureParticipantMayEditSignup($app); $trackCodes = $this->enabledTrackCodes($competition); if (count($trackCodes) === 0) { throw ValidationException::withMessages([ 'track' => ['本场赛事尚未配置可用赛道,请联系管理员'], ]); } $degrees = config('contest.degrees', []); $countries = config('contest.location_countries', []); $companyRules = ['nullable', 'string', 'max:255']; if ($this->signupSchemaHasKey($competition, 'entry_group')) { $reqVals = $this->companyNameRequiredWhenEntryGroupValues($competition); if (count($reqVals) > 0) { $companyRules[] = Rule::requiredIf(function () use ($request, $reqVals): bool { $eg = (string) $request->input('entry_group', ''); return in_array($eg, $reqVals, true); }); } } $rules = [ 'player_name' => ['required', 'string', 'max:120'], 'school' => ['required', 'string', 'max:200'], 'degree' => ['required', 'string', Rule::in($degrees)], 'contact_email' => ['required', 'email', 'max:255'], 'contact_mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'], 'company_name' => $companyRules, 'project_name' => ['required', 'string', 'max:255'], 'track' => ['required', 'string', Rule::in($trackCodes)], 'location_country' => ['required', 'string', Rule::in($countries)], 'location_province' => ['nullable', 'string', 'max:100', 'required_if:location_country,中国'], 'location_city' => ['nullable', 'string', 'max:100', 'required_if:location_country,中国'], 'oversea_country' => ['nullable', 'string', 'max:100', 'required_if:location_country,海外'], 'intro' => ['nullable', 'string', 'max:5000'], ]; if ($this->signupSchemaHasKey($competition, 'entry_group')) { $rules['entry_group'] = ['required', 'string', Rule::in($this->allowedEntryGroupValues($competition))]; } if ($this->signupSchemaRequiresCommitment($competition)) { $rules['commitment_accepted'] = ['required', 'accepted']; $rules['promise_signature'] = ['required', 'string', 'max:2097152']; } $data = $request->validate($rules); $promiseSignature = $data['promise_signature'] ?? ''; unset($data['commitment_accepted'], $data['promise_signature']); $planCount = $app->files()->where('kind', 'plan')->count(); if ($planCount < 1) { throw ValidationException::withMessages([ 'files' => ['请至少上传一份商业计划书'], ]); } $planRow = SignupFormFileRules::fileFieldRow($competition, 'plan'); $maxPlanFiles = SignupFormFileRules::maxCount($planRow); if ($maxPlanFiles !== null && $planCount > $maxPlanFiles) { throw ValidationException::withMessages([ 'files' => ['商业计划书最多可上传 '.$maxPlanFiles.' 个文件'], ]); } if ($this->signupSchemaHasKey($competition, 'supporting')) { $supportingCount = $app->files()->where('kind', 'supporting')->count(); $supRow = SignupFormFileRules::fileFieldRow($competition, 'supporting'); $maxSup = SignupFormFileRules::maxCount($supRow); if ($maxSup !== null && $supportingCount > $maxSup) { throw ValidationException::withMessages([ 'files' => ['佐证材料最多可上传 '.$maxSup.' 个文件'], ]); } } $app->fill($data); if ($this->signupSchemaRequiresCommitment($competition)) { if (! is_string($promiseSignature) || trim($promiseSignature) === '') { throw ValidationException::withMessages([ 'promise_signature' => ['请完成参赛承诺书手写签名'], ]); } $app->promise_signature = $promiseSignature; $app->promise_signed_at = now(); } $app->status = 'submitted'; $app->submitted_at = now(); $app->save(); $request->user()->update([ 'name' => $data['player_name'], 'email' => $data['contact_email'], ]); $app->load(['files']); return response()->json($this->transform($app)); } private function transform(Application $app): array { return [ 'id' => $app->id, 'competition_id' => $app->competition_id, 'status' => $app->status, 'player_name' => $app->player_name, 'school' => $app->school, 'degree' => $app->degree, 'contact_email' => $app->contact_email, 'contact_mobile' => $app->contact_mobile, 'entry_group' => $app->entry_group, 'company_name' => $app->company_name, 'project_name' => $app->project_name, 'track' => $app->track, 'location_country' => $app->location_country, 'location_province' => $app->location_province, 'location_city' => $app->location_city, 'oversea_country' => $app->oversea_country, 'intro' => $app->intro, 'promise_signed_at' => $app->promise_signed_at?->toIso8601String(), 'promise_signature' => $app->promise_signature, 'submitted_at' => $app->submitted_at?->toIso8601String(), 'participant_may_edit' => $app->participantMayEditSignup(), 'files' => $app->files->map(fn ($f) => [ 'id' => $f->id, 'kind' => $f->kind, 'original_name' => $f->original_name, 'size' => $f->size, 'url' => $f->participantPreviewSignedUrl(), ])->values(), ]; } }