parameters($request); $context = $this->logContext($request, $parameters); try { $this->assertRequiredParameters($parameters); $channel = $this->resolveChannel($parameters['channel_code']); $context['channel_code'] = $channel->channel_code; $context['competition_id'] = $channel->competition_id; $competition = $this->resolveCompetition($channel); $this->assertUnambiguousParameters($parameters, $channel); $this->assertTimestamp($parameters['timestamp']); $this->assertHash($parameters, $channel); if ($channel->entry_encryption_enabled) { $parameters = $this->decryptSensitiveParameters($parameters, $channel); $context['mobile_masked'] = $this->maskMobile($parameters['mobile']); } $this->assertMobile($parameters['mobile']); [$user, $application, $token] = DB::transaction(function () use ($channel, $competition, $parameters): array { $enteredAt = now(); $user = User::query()->firstOrCreate( ['mobile' => $parameters['mobile']], ['name' => null, 'email' => null, 'password' => null] ); if ($user->wasRecentlyCreated) { $user->fill([ 'first_channel_id' => $channel->id, 'first_channel_code' => $channel->channel_code, 'first_channel_entered_at' => $enteredAt, ]); } $user->fill([ 'last_channel_id' => $channel->id, 'last_channel_code' => $channel->channel_code, 'last_channel_entered_at' => $enteredAt, ])->save(); $application = Application::query()->firstOrCreate( [ 'user_id' => $user->id, 'competition_id' => $competition->id, ], ['status' => 'draft'] ); $application->fill([ 'signup_channel_id' => $channel->id, 'signup_channel_code' => $channel->channel_code, 'signup_channel_state' => $parameters['state'], 'signup_channel_entered_at' => $enteredAt, ])->save(); $user->tokens()->delete(); $token = $user->createToken('web')->plainTextToken; return [$user, $application, $token]; }); $context['user_id'] = $user->id; $context['application_id'] = $application->id; $this->logEntry('success', $context); return $this->secureView('channel.entry-handoff', [ 'token' => $token, 'user' => $this->handoffUser($user), 'competitionSlug' => $competition->slug, 'redirectUrl' => url('/admin/c/'.rawurlencode($competition->slug).'/apply'), ]); } catch (ChannelEntryException $exception) { $context['error_code'] = $exception->errorCode; $this->logEntry('failed', $context); return $this->secureView('channel.entry-error', [ 'errorCode' => $exception->errorCode, 'message' => $exception->getMessage(), ]); } catch (Throwable $exception) { $context['error_code'] = 'CHANNEL_ENTRY_FAILED'; $this->logEntry('failed', $context); Log::error('channel.entry exception', [ 'exception' => $exception::class, ]); return $this->secureView('channel.entry-error', [ 'errorCode' => 'CHANNEL_ENTRY_FAILED', 'message' => '暂时无法进入报名系统,请从小程序重新进入。', ]); } } /** * @return array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} */ private function parameters(Request $request): array { return [ 'channel_code' => $this->trimmed($request->query('channel_code')), 'timestamp' => $this->trimmed($request->query('timestamp')), 'name' => $this->trimmed($request->query('name')), 'mobile' => $this->trimmed($request->query('mobile')), 'company_name' => $this->trimmed($request->query('company_name')), 'state' => $this->trimmed($request->query('state')), 'hash' => $this->trimmed($request->query('hash')), ]; } /** * @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters */ private function assertRequiredParameters(array $parameters): void { if ($parameters['channel_code'] === '') { throw new ChannelEntryException('CHANNEL_MISSING', '渠道参数无效。'); } foreach (['timestamp', 'name', 'hash'] as $parameter) { if ($parameters[$parameter] === '') { throw new ChannelEntryException('PARAM_MISSING', '渠道参数无效。'); } } if ($parameters['mobile'] === '') { throw new ChannelEntryException('MOBILE_INVALID', '手机号信息异常。'); } } private function resolveChannel(string $channelCode): SignupChannel { $channel = SignupChannel::query() ->with('competition') ->where('channel_code', $channelCode) ->first(); if ($channel === null) { throw new ChannelEntryException('CHANNEL_NOT_FOUND', '渠道不可用。'); } if ($channel->status !== SignupChannel::STATUS_ENABLED) { throw new ChannelEntryException('CHANNEL_DISABLED', '渠道不可用。'); } return $channel; } private function resolveCompetition(SignupChannel $channel): Competition { $competition = $channel->competition; if ($competition === null) { throw new ChannelEntryException('COMPETITION_INVALID', '赛事不可报名。'); } if (in_array($competition->status, ['signup_closed', 'reviewing', 'ended'], true)) { throw new ChannelEntryException('SIGNUP_CLOSED', '报名已截止。'); } if (! $competition->published || ! in_array($competition->status, ['published', 'signup_open'], true) || trim((string) $competition->slug) === '') { throw new ChannelEntryException('COMPETITION_INVALID', '赛事不可报名。'); } if ($competition->signup_open_at !== null && now()->isBefore($competition->signup_open_at)) { throw new ChannelEntryException('SIGNUP_NOT_STARTED', '报名尚未开始。'); } if ($competition->signup_close_at !== null && now()->isAfter($competition->signup_close_at)) { throw new ChannelEntryException('SIGNUP_CLOSED', '报名已截止。'); } return $competition; } private function assertTimestamp(string $timestamp): void { if (! ctype_digit($timestamp)) { throw new ChannelEntryException('TIMESTAMP_EXPIRED', '链接已失效,请从小程序重新进入。'); } $window = max(0, (int) config('channel_entry.timestamp_window_seconds', 300)); if (abs(time() - (int) $timestamp) > $window) { throw new ChannelEntryException('TIMESTAMP_EXPIRED', '链接已失效,请从小程序重新进入。'); } } /** * @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters */ private function assertUnambiguousParameters(array $parameters, SignupChannel $channel): void { $fields = ['state']; if (! $channel->entry_encryption_enabled) { $fields[] = 'name'; $fields[] = 'company_name'; } foreach ($fields as $field) { if (str_contains($parameters[$field], '&') || str_contains($parameters[$field], '=')) { throw new ChannelEntryException('HASH_INVALID', '渠道参数无效。'); } } } /** * @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters */ private function assertHash(array $parameters, SignupChannel $channel): void { if (! preg_match('/^[a-f0-9]{64}$/', $parameters['hash']) || ! hash_equals(ChannelEntrySignature::make($parameters, $channel->shared_secret), $parameters['hash'])) { throw new ChannelEntryException('HASH_INVALID', '渠道参数无效。'); } } /** * @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters * @return array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} */ private function decryptSensitiveParameters(array $parameters, SignupChannel $channel): array { try { $privateKey = trim((string) $channel->encryption_private_key); if ($privateKey === '') { throw new \RuntimeException('Channel encryption private key is missing.'); } $parameters['name'] = ChannelEntryEncryption::decrypt($parameters['name'], $privateKey); $parameters['mobile'] = ChannelEntryEncryption::decrypt($parameters['mobile'], $privateKey); $parameters['company_name'] = $parameters['company_name'] === '' ? '' : ChannelEntryEncryption::decrypt($parameters['company_name'], $privateKey); if ($parameters['name'] === '') { throw new \RuntimeException('Channel entry name is missing.'); } } catch (Throwable) { throw new ChannelEntryException('ENCRYPTION_INVALID', '渠道参数无效。'); } return $parameters; } private function assertMobile(string $mobile): void { if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) { throw new ChannelEntryException('MOBILE_INVALID', '手机号信息异常。'); } } /** * @param array{channel_code: string, timestamp: string, name: string, mobile: string, company_name: string, state: string, hash: string} $parameters * @return array */ private function logContext(Request $request, array $parameters): array { return [ 'channel_code' => $parameters['channel_code'] !== '' ? $parameters['channel_code'] : null, 'competition_id' => null, 'mobile_masked' => $this->maskMobile($parameters['mobile']), 'state' => $parameters['state'] !== '' ? $parameters['state'] : null, 'user_id' => null, 'application_id' => null, 'error_code' => null, 'client_ip' => $request->ip(), 'callback_result' => null, 'created_at' => now()->toIso8601String(), ]; } /** * @param array $context */ private function logEntry(string $result, array $context): void { Log::info('channel.entry', array_merge($context, ['result' => $result])); } /** * @param array $data */ private function secureView(string $view, array $data): Response { return response() ->view($view, $data) ->header('Cache-Control', 'private, no-store, no-cache, must-revalidate') ->header('Pragma', 'no-cache') ->header('Referrer-Policy', 'no-referrer'); } /** * @return array{id: int, mobile: string, name: ?string, email: ?string} */ private function handoffUser(User $user): array { return [ 'id' => $user->id, 'mobile' => $user->mobile, 'name' => $user->name, 'email' => $user->email, ]; } private function maskMobile(string $mobile): string { return preg_match('/^\\d{11}$/', $mobile) ? substr($mobile, 0, 3).'****'.substr($mobile, -4) : '***'; } private function trimmed(mixed $value): string { return is_scalar($value) ? trim((string) $value) : ''; } }