'name', 'user.mobile' => 'mobile', 'user.email' => 'email', 'user.company' => 'company', ]; private function ensureParticipantMayEditSignup(Application $app): void { $app->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(); } /** * @return list */ private function allowedTrackCodes(Competition $competition): array { return array_values(array_unique([ ...$this->enabledTrackCodes($competition), SignupDisplay::TRACK_OTHER_CODE, ])); } /** 报名表 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 { return SignupApplicationFieldRegistry::schemaHasKey($competition, $key); } /** * @return list */ private function selectValuesFromSignupSchema(Competition $competition, string $key): array { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows)) { return []; } foreach ($rows as $row) { if (! is_array($row) || ($row['key'] ?? '') !== $key) { 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 []; } /** * @return list */ private function eventLocationOptionValues(): array { $raw = config('contest.event_locations', ['上海', '苏州', '深圳']); if (! is_array($raw) || count($raw) === 0) { return ['上海', '苏州', '深圳']; } return array_values(array_filter(array_map('strval', $raw), fn (string $v) => $v !== '')); } /** * @return list */ private function allowedEventLocationValues(Competition $competition): array { $fromSchema = $this->selectValuesFromSignupSchema($competition, 'event_location'); return array_values(array_unique([ ...$fromSchema, ...SignupOptionCatalog::acceptedValues(SignupOptionCatalog::eventLocations(), 'event_location'), ])); } /** * schema 中某字段是否标记为必填。 */ private function signupSchemaFieldRequired(Competition $competition, string $key): bool { $competition->loadMissing('formSchema'); $rows = $competition->formSchema?->schema_json; if (! is_array($rows)) { return false; } foreach ($rows as $row) { if (is_array($row) && ($row['key'] ?? '') === $key) { return (bool) ($row['required'] ?? false); } } return false; } 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); $this->normalizeSignupOptionInputs($request); $trackCodes = $this->allowedTrackCodes($competition); $degrees = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::degrees(), 'degree'); $countries = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::locationCountries(), 'location_country'); $companyRules = ['nullable', 'string', 'max:255']; $trackRules = ['nullable', 'string', Rule::in($trackCodes)]; $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, 'track_other' => ['nullable', 'string', 'max:255'], '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, 'event_location')) { $rules['event_location'] = ['nullable', 'string', Rule::in($this->allowedEventLocationValues($competition))]; } if ($this->signupSchemaHasKey($competition, 'team_members')) { $rules['team_members'] = ['nullable', 'string', 'max:500']; } foreach (SignupApplicationFieldRegistry::scalarColumnKeys() as $schemaOnlyKey) { if (in_array($schemaOnlyKey, [ 'player_name', 'school', 'degree', 'contact_email', 'contact_mobile', 'company_name', 'project_name', 'track', 'location_country', 'location_province', 'location_city', 'oversea_country', 'intro', 'event_location', 'team_members', ], true)) { continue; } if ($this->signupSchemaHasKey($competition, $schemaOnlyKey)) { $rules[$schemaOnlyKey] = ['nullable', 'string', 'max:255']; } } if ($this->signupSchemaRequiresCommitment($competition)) { $rules['commitment_accepted'] = ['sometimes', 'boolean']; $rules['promise_signature'] = ['nullable', 'string', 'max:2097152']; } $rules = array_merge($rules, $this->extraSchemaScalarRules($competition, array_keys($rules))); $data = $request->validate($rules); $commitmentAccepted = $data['commitment_accepted'] ?? null; $promiseSignature = $data['promise_signature'] ?? null; unset($data['commitment_accepted'], $data['promise_signature']); $this->persistSignupScalarAndAnswers($app, $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; } } $this->syncUnsubmittedSignupLocale($request, $app); $app->save(); $this->syncParticipantProfile($request->user(), $data, false); $app->load(['files']); return response()->json($this->transform($app, false, false)); } public function submit(Request $request): JsonResponse { $competition = $this->resolvePublishedCompetitionFromRequest($request); $app = $this->currentApplication($request); $this->ensureParticipantMayEditSignup($app); $this->normalizeSignupOptionInputs($request); $trackCodes = $this->allowedTrackCodes($competition); $degrees = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::degrees(), 'degree'); $countries = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::locationCountries(), 'location_country'); $companyRules = ['nullable', 'string', 'max:255']; if ($this->signupSchemaHasKey($competition, 'company_name')) { $companyRules = $this->signupSchemaFieldRequired($competition, 'company_name') ? ['required', 'string', 'max:255'] : ['nullable', 'string', 'max:255']; } $introRules = ['nullable', 'string', 'max:5000']; if ($this->signupSchemaHasKey($competition, 'intro') && $this->signupSchemaFieldRequired($competition, 'intro')) { $introRules = ['required', 'string', 'max:5000']; } $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)], 'track_other' => ['nullable', 'string', 'max:255'], 'location_country' => ['required', 'string', Rule::in($countries)], 'location_province' => ['nullable', 'string', 'max:100', Rule::requiredIf( fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'cn' )], 'location_city' => ['nullable', 'string', 'max:100', Rule::requiredIf( fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'cn' )], 'oversea_country' => ['nullable', 'string', 'max:100', Rule::requiredIf( fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'overseas' )], 'intro' => $introRules, ]; if ($this->signupSchemaHasKey($competition, 'event_location')) { $rules['event_location'] = ['required', 'string', Rule::in($this->allowedEventLocationValues($competition))]; } if ($this->signupSchemaHasKey($competition, 'team_members')) { $rules['team_members'] = $this->signupSchemaFieldRequired($competition, 'team_members') ? ['required', 'string', 'max:500'] : ['nullable', 'string', 'max:500']; } foreach (SignupApplicationFieldRegistry::scalarColumnKeys() as $schemaOnlyKey) { if (in_array($schemaOnlyKey, [ 'player_name', 'school', 'degree', 'contact_email', 'contact_mobile', 'company_name', 'project_name', 'track', 'location_country', 'location_province', 'location_city', 'oversea_country', 'intro', 'event_location', 'team_members', ], true)) { continue; } if ($this->signupSchemaHasKey($competition, $schemaOnlyKey)) { $rules[$schemaOnlyKey] = ['nullable', 'string', 'max:255']; } } if ($this->signupSchemaRequiresCommitment($competition)) { $rules['commitment_accepted'] = ['required', 'accepted']; $rules['promise_signature'] = ['required', 'string', 'max:2097152']; } $rules = array_merge($rules, $this->extraSchemaScalarRules($competition, array_keys($rules))); $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.' 个文件'], ]); } } $this->persistSignupScalarAndAnswers($app, $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(); } $this->lockSubmittedSignupLocale($request, $app); $app->status = 'submitted'; $app->submitted_at = now(); $app->save(); $this->syncParticipantProfile($request->user(), $data, true); $app->load(['files']); $response = $this->transform($app); $successNotice = $this->successNotice($competition); if ($successNotice !== null) { $response['success_notice'] = $successNotice; } $channelCallback = $this->channelCallback($app, $request); if ($channelCallback !== null) { $response['channel_callback'] = $channelCallback; } return response()->json($response); } /** * @return array{enabled: true, message: string}|null */ private function successNotice(Competition $competition): ?array { $settings = is_array($competition->settings) ? $competition->settings : []; $notice = $settings['success_notice'] ?? null; if (! is_array($notice)) { return null; } if (($notice['enabled'] ?? false) !== true) { return null; } $message = trim((string) ($notice['message'] ?? '')); if ($message === '') { return null; } return [ 'enabled' => true, 'message' => mb_substr($message, 0, 1000), ]; } /** * @return array|null */ private function channelCallback(Application $app, Request $request): ?array { if ($app->signup_channel_id === null && trim((string) $app->signup_channel_code) === '') { return null; } $context = [ 'channel_code' => $app->signup_channel_code, 'competition_id' => $app->competition_id, 'mobile_masked' => $this->maskMobile((string) $request->user()?->mobile), 'state' => $app->signup_channel_state, 'user_id' => $app->user_id, 'application_id' => $app->id, 'result' => 'failed', 'error_code' => null, 'client_ip' => $request->ip(), 'callback_result' => 'failed', 'created_at' => now()->toIso8601String(), ]; try { $channel = $app->signup_channel_id !== null ? SignupChannel::query()->find($app->signup_channel_id) : null; if ($channel === null && trim((string) $app->signup_channel_code) !== '') { $channel = SignupChannel::query() ->where('channel_code', $app->signup_channel_code) ->first(); } if ($channel === null || $channel->competition_id !== $app->competition_id) { $context['error_code'] = 'CHANNEL_NOT_FOUND'; return null; } $context['channel_code'] = $channel->channel_code; if ($channel->status !== SignupChannel::STATUS_ENABLED) { $context['error_code'] = 'CHANNEL_DISABLED'; return null; } $callbackType = $channel->success_callback_type ?: SignupChannel::CALLBACK_TYPE_WEB; if ($callbackType === SignupChannel::CALLBACK_TYPE_NONE) { $context['error_code'] = 'CALLBACK_DISABLED'; return null; } if ($callbackType === SignupChannel::CALLBACK_TYPE_WEB && trim((string) $channel->success_callback_url) === '') { $context['error_code'] = 'CALLBACK_MISSING'; return null; } if ($callbackType === SignupChannel::CALLBACK_TYPE_MINI_PROGRAM) { $method = $channel->mini_program_callback_method ?: SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO; $callback = [ 'type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM, 'path' => ChannelCallbackUrlBuilder::buildMiniProgramPath( $app, $channel, $method !== SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB ), 'method' => $method, ]; } else { $callback = [ 'type' => SignupChannel::CALLBACK_TYPE_WEB, 'redirect_url' => ChannelCallbackUrlBuilder::build($app, $channel), ]; } $context['result'] = 'success'; $context['callback_result'] = 'success'; return $callback; } catch (Throwable $exception) { $context['error_code'] = 'CALLBACK_INVALID'; Log::warning('channel.callback exception', [ 'exception' => $exception::class, ]); return null; } finally { Log::info('channel.callback', $context); } } private function maskMobile(string $mobile): string { return preg_match('/^\d{11}$/', $mobile) ? substr($mobile, 0, 3).'****'.substr($mobile, -4) : '***'; } /** * 报名联系邮箱保存在 applications.contact_email;同步到 users.email 只是资料补全。 * users.email 有唯一索引,若邮箱已属于其他账号,跳过同步,避免报名保存/提交被唯一键冲突阻断。 * * @param array $data */ private function syncParticipantProfile(User $user, array $data, bool $overwriteName): void { $updates = []; $name = trim((string) ($data['player_name'] ?? '')); if ($name !== '' && ($overwriteName || empty($user->name))) { $updates['name'] = $name; } $email = trim((string) ($data['contact_email'] ?? '')); if ($email !== '' && ($overwriteName || empty($user->email) || $user->email === $email)) { $emailOwnedByOtherUser = User::query() ->where('email', $email) ->whereKeyNot($user->id) ->exists(); if (! $emailOwnedByOtherUser) { $updates['email'] = $email; } } if ($updates !== []) { $user->update($updates); } } private function isBlankSignupValue(mixed $value): bool { return $value === null || (is_string($value) && trim($value) === ''); } /** * @param array $payload * @return array */ private function applySignupPrefillSources(Application $app, array $payload): array { $app->loadMissing(['user', 'competition.formSchema']); $rows = $app->competition?->formSchema?->schema_json; if (! is_array($rows)) { return $payload; } foreach ($rows as $row) { if (! is_array($row)) { continue; } $key = trim((string) ($row['key'] ?? '')); $source = trim((string) ($row['prefill_from'] ?? '')); if ($key === '' || $source === '' || ! array_key_exists($source, self::USER_PREFILL_SOURCES)) { continue; } if (! array_key_exists($key, $payload) || ! $this->isBlankSignupValue($payload[$key])) { continue; } $userColumn = self::USER_PREFILL_SOURCES[$source]; $value = $app->user?->{$userColumn}; if ($this->isBlankSignupValue($value)) { continue; } $payload[$key] = $value; } return $payload; } private function transform(Application $app, bool $includeSignature = true, bool $prefillBlanksFromUser = true): array { $payload = array_merge([ 'id' => $app->id, 'competition_id' => $app->competition_id, 'status' => $app->status, 'signup_locale' => $app->signup_locale ?: 'zh-CN', 'signup_locale_locked' => $app->isSubmitted(), 'promise_signed_at' => $app->promise_signed_at?->toIso8601String(), 'submitted_at' => $app->submitted_at?->toIso8601String(), 'participant_may_edit' => $app->participantMayEditSignup(), 'participant_may_edit_files' => $app->participantMayEditFiles(), 'files' => $app->files->map(fn ($f) => [ 'id' => $f->id, 'kind' => $f->kind, 'original_name' => $f->original_name, 'size' => $f->size, 'url' => $f->participantPreviewSignedUrl(), ])->values(), ], SignupApplicationFieldRegistry::scalarAttributesFromApplication($app)); $answers = is_array($app->answers_json) ? $app->answers_json : []; foreach ($answers as $key => $value) { if (! array_key_exists($key, $payload)) { $payload[$key] = $value; } } if ($includeSignature) { $payload['promise_signature'] = $app->promise_signature; } return $prefillBlanksFromUser ? $this->applySignupPrefillSources($app, $payload) : $payload; } private function normalizeSignupOptionInputs(Request $request): void { $merge = []; foreach (['degree', 'location_country', 'event_location'] as $kind) { if ($request->exists($kind)) { $merge[$kind] = SignupOptionCatalog::normalize($kind, is_string($request->input($kind)) ? $request->input($kind) : null); } } if ($merge !== []) { $request->merge($merge); } } /** * @param array $data */ private function persistSignupScalarAndAnswers(Application $app, array $data): void { if (array_key_exists('track', $data) && (string) ($data['track'] ?? '') !== SignupDisplay::TRACK_OTHER_CODE) { $data['track_other'] = ''; } $app->fill(SignupApplicationFieldRegistry::filterFillableScalarData($data)); $answers = SignupApplicationFieldRegistry::filterAnswersJsonData($data); if ($answers !== []) { $app->answers_json = array_merge(is_array($app->answers_json) ? $app->answers_json : [], $answers); } } /** * @param list $existingKeys * @return array> */ private function extraSchemaScalarRules(Competition $competition, array $existingKeys): array { $competition->loadMissing('formSchema'); $schema = $competition->formSchema?->schema_json; $keys = SignupApplicationFieldRegistry::keysFromSchemaJson(is_array($schema) ? $schema : null); $skip = array_flip([ ...$existingKeys, ...SignupApplicationFieldRegistry::FILE_FIELD_KEYS, SignupApplicationFieldRegistry::COMMITMENT_KEY, SignupDisplay::TRACK_OTHER_FIELD, ]); $rules = []; foreach ($keys as $key) { if (isset($skip[$key])) { continue; } $rules[$key] = ['nullable', 'string', 'max:5000']; } return $rules; } private function requestPortalLocale(Request $request): ?string { $lang = $request->query('lang') ?? $request->input('lang'); if (! is_string($lang) || trim($lang) === '') { return null; } return PortalLocale::fromRequest($lang); } private function syncUnsubmittedSignupLocale(Request $request, Application $app): void { if ($app->isSubmitted()) { return; } $locale = $this->requestPortalLocale($request); if ($locale === null) { return; } CompetitionPortalLocale::remember( (int) $app->user_id, (int) $app->competition_id, PortalLocale::PORTAL_PARTICIPANT, $locale ); if (Schema::hasColumn($app->getTable(), 'signup_locale')) { $app->signup_locale = $locale; } } private function lockSubmittedSignupLocale(Request $request, Application $app): void { $locale = $this->requestPortalLocale($request) ?? PortalLocale::ZH; CompetitionPortalLocale::remember( (int) $app->user_id, (int) $app->competition_id, PortalLocale::PORTAL_PARTICIPANT, $locale ); if (Schema::hasColumn($app->getTable(), 'signup_locale')) { $app->signup_locale = $locale; } } }